One self-hosted console to run your entire business — commerce, ERP, HRM, CRM & manufacturing

高负载下的数据层:PostgreSQL、连接池、Redis 与 pgvector

流量高峰来临,人们的第一反应往往是给数据库升配,而这通常是错的。本文讲如何按写入吞吐定规格,用 PgBouncer 做连接池,按用途拆分 Redis 或 Valkey,并让 pgvector 搜索不拖慢结账。

Author

Anichur Rahaman

2 周前12 min read1 views
高负载下的数据层:PostgreSQL、连接池、Redis 与 pgvector

闪购当天早上 9:02,在这个示意场景里,值班工程师盯着监控面板。1,000 个数据库连接已经用了 940 个,结账耗时超过三秒。有人提议,趁第二波流量到来之前,把数据库升到下一档规格。

这是在凭感觉下注。那 940 个连接大多是空闲的,被 PHP 进程在两次请求之间攥在手里,数据库 CPU 只有 35% 左右。真正排队的是十一个 worker,它们都在等着写同样那几行数据。连接数只是症状,升配是在治症状。

真实的版本我见过。在一个平台上,我为一次定时流量高峰做过准备,大约 1,000 名用户会在同一分钟内操作。托管数据库(16 GB 内存、4 vCPU、约 1,000 个连接上限)从头到尾都不是瓶颈,瓶颈是一个串行执行的 worker。这篇文章按我现在规划数据层的方式来讲:该测什么,如何做连接池,副本能做什么、不能做什么,怎样让 Redis 或 Valkey 各司其职,以及如何在不给事务数据库增加向量查询压力的前提下加上 AI 搜索。

本文是“高并发系统工程”系列的第 3 篇。第 1 篇讲边缘层与 Web 层,第 2 篇讲队列与 worker。这一篇再往下一层,讲数据。

按写入吞吐定规格,而不是按连接数

“最大连接数 1,000”只说明有多少客户端可以连进来,并不说明服务器能干多少活。4 个 vCPU 在同一时刻只能执行寥寥几条查询,其余的都在排队,不管它连没连着。

所以要测真正会把关系型数据库压满的指标:写入吞吐(每秒提交数和 WAL 量)、CPU、磁盘延迟和锁等待。连接数只是别的问题的征兆,比如慢查询把连接占住不放。

我的规矩很简单:压测没有证明数据库已经饱和,就不要升级它。在上面那套环境里,办法是把 Web 节点和 worker 节点分开,并运行约 12 个 worker 进程。数据库轻松扛住了额外负载。worker 超过 20 个左右后,任务只是在排队等数据库写入,真正要盯的天花板是这个,而不是连接数。这些数字来自同一套环境,不是通用基准,请自己做压测。

数据层示意图:PgBouncer 位于 PostgreSQL 主库和副本之前,Redis 或 Valkey 按用途拆分,独立的 pgvector 数据库,以及与计算资源同区域的对象存储
一层数据,几种存储,各管一件事,各有各的限额。

给连接做池化

PHP 会打开大量短连接:每个请求、每个队列任务都可能连上又断开。PostgreSQL 为每个连接启动一个独立进程,所以一千个客户端可能还没执行一条有用的查询,就已经吃掉大量内存。PgBouncer 这样的连接池放在应用和数据库之间,数百个客户端连接共用几十个真实的服务器连接。

算一笔示意账。两个 Web 节点各有 60 个 PHP-FPM 子进程,最多能占 120 个连接;12 个 worker 进程再加 12 个;调度器和几个管理会话再加约 8 个。合计约 140 个连接,也就是 140 个 PostgreSQL 进程在争 4 个 vCPU。把 PgBouncer 设为 transaction 模式,连接池给 20 个服务器连接,同样这 140 个客户端就共用这 20 个。如果平均每个事务占用连接 5 ms,20 个连接最多能支撑 20 ÷ 0.005 = 4,000 个事务每秒。CPU 会让你远低于这个数就到顶,而这正是意义所在:连接池让真实的上限浮出水面,而不是淹没在频繁的建连和断连里。

PgBouncer 有三种池模式,选哪一种决定了应用能做什么。

池模式服务器连接被占用的时长最适合注意
Session整个客户端连接期间长期运行的监听器、依赖会话状态的工具节省最少;空闲客户端也占着服务器连接
Transaction一个事务Web 请求和队列任务;高并发下的常规选择凡是依赖会话状态的都会出问题
Statement一条语句简单的、纯 autocommit 的负载不允许多语句事务

对承压的 Web 应用,transaction 模式收益最大。但切换之前,要先了解它的几个限制。

transaction 模式下会坏什么

同一个客户端的下一个事务可能落到另一个服务器连接上,所以放在会话里的状态靠不住:

  • 会话级设置。普通的 SET 不会延续;事务内的 SET LOCAL 才会。
  • 会话级 advisory lock 和 LISTEN/NOTIFY。这类需求请用直连或 session 模式。
  • 临时表和 WITH HOLD 游标,它们会活过事务本身。
  • 预处理语句(prepared statement)。旧版 PgBouncer 在 transaction 模式下不支持。从 1.21 版开始,把 max_prepared_statements 选项设为大于零,PgBouncer 就能跟踪它们。别想当然,先核对你的版本和驱动。

务实的做法是准备两条连接路径:应用走连接池,迁移、表结构工具和一切需要监听的程序走直连。PgBouncer 配置参考列出了所有选项,包括池大小和超时。

副本:能解决什么,什么必须留在主库

只读副本是跟在主库后面、略有延迟的一份拷贝。适合能容忍稍微落后的工作:报表、导出、搜索列表、看板和分析查询,否则这些会和结账抢资源。

它不是通用的加速手段。复制延迟是常态,写入一多还会拉大。下面这些要留在主库:

  • 写完立刻要读的操作,比如结账后展示订单页。
  • 库存预留、支付、身份认证,以及一切决定金钱或权限的逻辑。
  • 同时包含写入的事务里的任何查询。

落到实践上,这就是一条一页纸写得下的路由规则。

含三个判断的流程图:需要会话状态走直连,写入或读取刚写的数据走主库,能容忍延迟走副本,否则走主库
每条查询去哪里执行,以及决定它的三个问题。

索引与 N+1 查询:最便宜的算力

加硬件之前,先找出最耗时的查询。PostgreSQL 的 pg_stat_statements 扩展按总耗时给查询排名,带 ANALYZE 和 BUFFERS 选项的 EXPLAIN 则能看出一条查询读的是几页还是一百万页。

大部分浪费来自两个问题:

  • 缺失或错误的索引。对没有匹配索引的列做过滤和排序,会把毫秒级的查找变成全表扫描。请按真实的过滤条件和排序建立复合索引。
  • N+1 查询。列表页先查一次列表,再给每一行各查一次。五十行就是五十一次往返。把要展示的关联数据预先加载(eager loading)。

Redis 与 Valkey:一台服务器,几份差事

先说说名字。2024 年 3 月,Redis 改用源码可见(source-available)许可,Linux Foundation 随后推出 Valkey,它是从 Redis 7.2.4 分叉出来的,沿用宽松的 BSD 许可。Valkey 使用相同的协议,所以下面的内容对两者都适用。

最常见的错误是把缓存、会话、API 令牌和队列塞进同一个键空间。这样一次清空,或一次内存告急,就会同时伤到所有这些。请让每种用途拥有自己的逻辑库或独立实例,以及自己的键前缀。缓存可以放心清空,队列不行。

用途丢了意味着什么淘汰策略原则
缓存短暂变慢;数据会重新生成allkeys-lru所有键都带 TTL;被淘汰没关系
会话与 API 令牌用户被登出volatile-ttl 或 volatile-lru容量留足,保证永远不触发淘汰
队列任务丢失noeviction写入大声失败,而不是悄悄丢任务;给内存设告警

Redis 淘汰机制文档逐一介绍了各种策略。最关键的有两点。使用 allkeys-lru 时,Redis 为了不超内存上限,可以删除任意键,这正是缓存想要的,也正是队列绝不能有的。使用 noeviction 时,内存满了的服务器会拒绝新的写入而不是删数据,所以队列保持正确,你会通过报错和告警得知情况。

永远不要用 FLUSHDB

在运维手册和应用里都禁用 FLUSHDB 和 FLUSHALL。需要清缓存时,按前缀删除。这样即使在最糟糕的时刻手误,清缓存也绝不会删掉队列里的任务。

按实际用量定容量

在同一个平台上,托管的 Valkey 实例配置了 16 GB,实际只用了约 62 MB。请在真实负载下观察内存实际用量,为突发时的队列留出充足余量,然后缩到那个水位。

用 pgvector 做 AI 搜索,放进独立的数据库

如果你的商店或 ERP 里有 AI 搜索、推荐,或者能检索文档的助手,你就在存储嵌入向量(embedding):用一长串数字表示语义。pgvector 扩展为 PostgreSQL 增加了向量类型和最近邻搜索,所以起步时不需要另外引入向量数据库产品。

我的设计原则是把它放在一个独立的 PostgreSQL 数据库里,有自己的连接和自己的迁移路径。我最近搭的一套用的是 PostgreSQL 18 上的 pgvector 0.8,PostgreSQL 18 于 2025 年 9 月发布。向量查询耗 CPU 和内存,建索引更甚。放在独立数据库里,AI 负载拖不慢结账,你也可以按自己的节奏为它定容量、备份和升级。

从商品变更到入队任务,再到生成嵌入向量的 worker、存入 pgvector,最后带过滤条件的最近邻查询的流程
嵌入向量由 worker 在变更之后生成,绝不在 Web 请求里生成。

在 worker 里生成嵌入向量

生成嵌入向量要调用模型,既慢又可能失败。请由数据库提交之后触发的队列任务来做,不要放在 Web 请求里。让任务具备幂等性:保存源文本的哈希,内容没变就跳过;每行同时记录模型版本,日后便能在后台重新生成。这就是第 2 篇讲过的那套 worker 纪律。

HNSW 还是 IVFFlat

pgvector 提供两种近似索引,都是拿一点精度换大量速度。

HNSWIVFFlat
速度与召回率通常是更好的折中不错,取决于探测多少个列表
构建时间与内存构建较慢,占内存较多构建较快,占内存较少
是否需要先有数据不需要,空表上就能建需要,加载有代表性的数据后再建
典型用途多数在线搜索的默认选择体量很大、很少变动、构建成本要紧的数据集

对大多数电商和 ERP 场景,我从 HNSW 起步。两种索引的调优选项见 pgvector 项目文档。

过滤是搜索悄悄出问题的地方

真实的查询从来不只是“离这段文字最近”,而是“最近、有库存、属于这家店、用这种语言”。使用近似索引时,过滤发生在扫描索引之后。如果某个条件只匹配 10% 的行,而 hnsw.ef_search 的默认值是 40,那么即使你要十条,平均也只能拿到四条符合条件的结果。

2024 年底发布的 pgvector 0.8.0 正是为这个问题加入了迭代索引扫描(iterative index scan)。过滤后剩下的行太少时,扫描会继续,直到凑够数量或碰到上限。

设置作用
hnsw.iterative_scan为 HNSW 开启迭代扫描:strict_order 保持精确的距离顺序,relaxed_order 允许轻微乱序以换取更好的召回率
hnsw.max_scan_tuples迭代扫描最多可访问多少条索引项
ivfflat.iterative_scanIVFFlat 的同类设置,使用宽松排序
ivfflat.max_probes迭代扫描中最多探测多少个列表

让对象存储紧挨着计算资源

文件也是状态。商品图片、发票和导出文件应放在兼容 S3 的对象存储里,而不是 Web 节点的磁盘上,否则无法把两个节点放到负载均衡器后面。

请把存储桶放在与服务器相同的区域。在一次压测中,跨区域的存储桶让每个生成文件的上传多出约 50 到 80 ms。另外,把文件以流的方式读写,而不是先下载到本地磁盘,也让一个任务的内存峰值从约 288 MB 降到约 128 MB。

活动前的数据层检查清单

  1. 按目标速率跑压测,记录数据库 CPU、磁盘延迟、锁等待和每秒提交数。之后再决定要不要升配。
  2. 在应用前面部署 transaction 模式的 PgBouncer,并为迁移和监听程序保留一条直连。
  3. 核对 PgBouncer 版本和驱动是否支持预处理语句。
  4. 用 pg_stat_statements 给查询排名,修掉最重的几条,并清除最繁忙页面上的 N+1。
  5. 列出哪些读取可以走副本,并确认结账、库存和支付仍留在主库。
  6. 按用途拆分 Redis 或 Valkey 并使用不同的键前缀,为每种用途设定淘汰策略,并禁用 FLUSHDB。
  7. 给队列内存设告警,避免 noeviction 演变成无声的故障。
  8. 让 AI 搜索跑在独立的 PostgreSQL 数据库里,测试带过滤的查询,召回率下降时开启迭代扫描。
  9. 确认对象存储与计算资源在同一区域。

回到 9:02。连接池处于 transaction 模式时,940 个客户端连接收敛成 20 个服务器连接,面板上那个吓人的数字不见了。销售报表跑在副本上,缓存和队列各用独立的键空间,值班工程师问的是“什么饱和了?”,而不是“下一档规格是什么?”。压测早已给出答案:是写入,而数据库还留有宽裕的余量。

数据层调好之后,你还得在活动当天看清它在做什么。第 4 篇讲的就是这个:真正能据此行动的日志、指标与链路追踪。

要点回顾

  • 数据库规格按写入吞吐和实测的饱和程度来定,不看连接数上限;压测没有显示需要时,绝不升配。
  • 应用使用 transaction 模式的 PgBouncer,并清楚它会破坏什么:会话状态、会话级锁、LISTEN/NOTIFY,以及旧的预处理语句配置。
  • 副本承担能容忍延迟的读取;写后即读的操作,以及涉及金钱和库存的操作,留在主库。
  • 给缓存、会话和队列各自独立的 Redis 或 Valkey 库和前缀,缓存用 allkeys-lru,队列用 noeviction,绝不整库清空。
  • pgvector 放在独立的 PostgreSQL 数据库里,嵌入向量由 worker 生成,起步选 HNSW,带过滤的搜索使用迭代扫描。
  • 缓存内存按实际用量配置,对象存储与计算资源放在同一区域。

Anichur Rahaman 是一名软件架构师,也是 StoreConsole 的创建者。他为成长型企业设计电商与 ERP 系统,专注于事件驱动架构、数据完整性和自托管部署。

About the Author

Anichur Rahaman

Continue Reading