基础设施与扩容中小企业的云回迁:离开超大规模云何时省钱,何时不省
逐项对比计算、托管数据库、egress、备份和运维工时,附 37signals 的数据、留下或搬走的决策树,以及一份直到最后一步都保留回退路径的迁移计划。
流量高峰来临,人们的第一反应往往是给数据库升配,而这通常是错的。本文讲如何按写入吞吐定规格,用 PgBouncer 做连接池,按用途拆分 Redis 或 Valkey,并让 pgvector 搜索不拖慢结账。
Author
Anichur Rahaman

闪购当天早上 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 个左右后,任务只是在排队等数据库写入,真正要盯的天花板是这个,而不是连接数。这些数字来自同一套环境,不是通用基准,请自己做压测。

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 模式收益最大。但切换之前,要先了解它的几个限制。
同一个客户端的下一个事务可能落到另一个服务器连接上,所以放在会话里的状态靠不住:
务实的做法是准备两条连接路径:应用走连接池,迁移、表结构工具和一切需要监听的程序走直连。PgBouncer 配置参考列出了所有选项,包括池大小和超时。
只读副本是跟在主库后面、略有延迟的一份拷贝。适合能容忍稍微落后的工作:报表、导出、搜索列表、看板和分析查询,否则这些会和结账抢资源。
它不是通用的加速手段。复制延迟是常态,写入一多还会拉大。下面这些要留在主库:
落到实践上,这就是一条一页纸写得下的路由规则。

加硬件之前,先找出最耗时的查询。PostgreSQL 的 pg_stat_statements 扩展按总耗时给查询排名,带 ANALYZE 和 BUFFERS 选项的 EXPLAIN 则能看出一条查询读的是几页还是一百万页。
大部分浪费来自两个问题:
先说说名字。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 和 FLUSHALL。需要清缓存时,按前缀删除。这样即使在最糟糕的时刻手误,清缓存也绝不会删掉队列里的任务。
在同一个平台上,托管的 Valkey 实例配置了 16 GB,实际只用了约 62 MB。请在真实负载下观察内存实际用量,为突发时的队列留出充足余量,然后缩到那个水位。
如果你的商店或 ERP 里有 AI 搜索、推荐,或者能检索文档的助手,你就在存储嵌入向量(embedding):用一长串数字表示语义。pgvector 扩展为 PostgreSQL 增加了向量类型和最近邻搜索,所以起步时不需要另外引入向量数据库产品。
我的设计原则是把它放在一个独立的 PostgreSQL 数据库里,有自己的连接和自己的迁移路径。我最近搭的一套用的是 PostgreSQL 18 上的 pgvector 0.8,PostgreSQL 18 于 2025 年 9 月发布。向量查询耗 CPU 和内存,建索引更甚。放在独立数据库里,AI 负载拖不慢结账,你也可以按自己的节奏为它定容量、备份和升级。

生成嵌入向量要调用模型,既慢又可能失败。请由数据库提交之后触发的队列任务来做,不要放在 Web 请求里。让任务具备幂等性:保存源文本的哈希,内容没变就跳过;每行同时记录模型版本,日后便能在后台重新生成。这就是第 2 篇讲过的那套 worker 纪律。
pgvector 提供两种近似索引,都是拿一点精度换大量速度。
| HNSW | IVFFlat | |
|---|---|---|
| 速度与召回率 | 通常是更好的折中 | 不错,取决于探测多少个列表 |
| 构建时间与内存 | 构建较慢,占内存较多 | 构建较快,占内存较少 |
| 是否需要先有数据 | 不需要,空表上就能建 | 需要,加载有代表性的数据后再建 |
| 典型用途 | 多数在线搜索的默认选择 | 体量很大、很少变动、构建成本要紧的数据集 |
对大多数电商和 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_scan | IVFFlat 的同类设置,使用宽松排序 |
| ivfflat.max_probes | 迭代扫描中最多探测多少个列表 |
文件也是状态。商品图片、发票和导出文件应放在兼容 S3 的对象存储里,而不是 Web 节点的磁盘上,否则无法把两个节点放到负载均衡器后面。
请把存储桶放在与服务器相同的区域。在一次压测中,跨区域的存储桶让每个生成文件的上传多出约 50 到 80 ms。另外,把文件以流的方式读写,而不是先下载到本地磁盘,也让一个任务的内存峰值从约 288 MB 降到约 128 MB。
回到 9:02。连接池处于 transaction 模式时,940 个客户端连接收敛成 20 个服务器连接,面板上那个吓人的数字不见了。销售报表跑在副本上,缓存和队列各用独立的键空间,值班工程师问的是“什么饱和了?”,而不是“下一档规格是什么?”。压测早已给出答案:是写入,而数据库还留有宽裕的余量。
数据层调好之后,你还得在活动当天看清它在做什么。第 4 篇讲的就是这个:真正能据此行动的日志、指标与链路追踪。
Anichur Rahaman 是一名软件架构师,也是 StoreConsole 的创建者。他为成长型企业设计电商与 ERP 系统,专注于事件驱动架构、数据完整性和自托管部署。
About the Author
Anichur Rahaman
Continue Reading