后端系统工程 02:负载模型与容量预算
后端系统工程 02:负载模型与容量预算
本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收
“峰值每秒一千多条消息”这样的数字本身不能指导设计。同样是每秒 1250 条消息,单聊为主的负载和大群为主的负载对数据库、网关和带宽的压力相差一个数量级以上;同样的消息量,客户端每收到一条就确认一次和攒够十几条再确认一次,数据库写入量也相差数倍。容量预算要回答的不是“总量多大”,而是“哪一种资源先用完,以及哪个参数一变,瓶颈就会换位置”。
本篇把第 01 篇定义的接口操作折算成五类资源消耗,用一个带单位的脚本 examples/design/capacity.py 计算,再通过敏感性扫描找出决定性参数。全部规模参数都是教学假设,不来自任何真实系统;结论只在这些假设下成立。
1. 模型要回答的问题
- 在基线参数下,哪类资源最先达到预算?超出多少?
- 改变单个参数(群规模、每用户设备数、心跳间隔、确认合并、保留期)时,瓶颈是否转移?
- 哪些量是模型无法给出、必须测量的?
第三个问题与前两个同等重要。模型能做的是把乘法做对,把单位对齐,并指出哪些项的贡献最大;它不能给出每个连接占多少内存、数据库每秒能写多少行。这些是预算的输入,不是模型的输出。
2. 规模假设
| 参数 | 基线值 | 单位 | 说明 |
|---|---|---|---|
online_devices |
200 000 | 台 | 峰值在线连接数 |
devices_per_user |
1.6 | 台/人 | 在线用户平均在线设备数 |
active_sender_ratio |
0.05 | — | 峰值分钟内发送消息的在线用户比例 |
msgs_per_sender_per_s |
0.2 | 条/(人·s) | 活跃发送者的发送速率 |
group_share |
0.4 | — | 群消息占比 |
group_members_avg |
30 | 人 | 群消息的平均成员数;单聊为 2 |
online_ratio_of_members |
0.35 | — | 发送时刻成员设备的在线比例 |
redelivery_ratio |
0.03 | — | 重试与重投扫描带来的额外推送比例 |
events_per_ack |
1 | 条/次 | 一次送达确认覆盖的事件数 |
group_pull_threshold |
200 | 人 | 超过此规模的群改为“提示 + 按需拉取”(第 08 篇) |
pull_read_ratio |
0.2 | — | 收到提示后立即打开会话并拉取的设备比例 |
heartbeat_s |
30 | s | 心跳间隔 |
reconnect_fraction / reconnect_window_s / backlog_msgs_per_reconnect |
0.3 / 60 / 25 | —/s/条 | 网关故障时 30% 设备在 60 s 内重连,每台补拉 25 条 |
record_bytes |
600 | B | 每条消息的存储占用,含行头、索引与填充的估计 |
push_bytes / hint_bytes |
450 / 40 | B | 一帧完整事件推送 / 一帧提示的线上字节数 |
retention_days |
30 | 天 | 保留期(第 11 篇) |
预算同样是假设:
| 资源 | 预算 | 依据 |
|---|---|---|
| 数据库写入 | 4 000 行/s | 假设值,单主库,须在目标硬件上测量 |
| 数据库读取 | 40 000 行/s | 假设值 |
| 网关帧数 | 150 000 帧/s | 假设值,整个网关集群 |
| 网关出口 | 60 MB/s | 假设值 |
| 存储 | 2 TB | 假设值 |
record_bytes=600 需要说明来源。PostgreSQL 的每个堆元组有 23 字节的元组头,每个元组在页内还占一个 4 字节的行指针,此外还有对齐填充、主键索引和幂等唯一索引的条目。600 字节是“平均正文约 200 字节再加上这些开销”的粗略估计,不是测量值。在需求卡中它属于假设 A3;需要精确值时,应在目标环境写入代表性数据后用 pg_total_relation_size 测量。
3. 从接口操作到资源消耗
模型的结构可以用一张图表示:
1 | |
几个关键的折算关系:
写入与扇出分开计算。 每条消息只在会话日志中写一行(第 04 篇的 events 表按会话存储),写入量与群规模无关;群规模只影响送达侧。测试 test_writes_do_not_depend_on_group_size 固定了这一性质:把群规模从 30 改为 100,写入量不变,推送帧增加。
接收设备数排除发送设备。 单聊每条消息的接收设备数为 (2 × 1.6 − 1) × 0.35 = 0.77:两个用户共 3.2 台设备,减去发送设备,再乘在线比例。群消息为 (30 × 1.6 − 1) × 0.35 = 16.45。
送达确认是写入。 设备每确认一次,数据库执行一次 device_cursors 的 upsert(第 06 篇)。这一项在很多容量估算中被遗漏,而在本模型中它是写入量的主体。
重连补拉是读取。 网关故障后(第 10 篇),重连设备通过 sync 补拉积压消息,按 设备数 × 重连比例 × 积压条数 ÷ 时间窗口 折算为读取行数。
4. 基线结果
手算核对(与 test_hand_computed_baseline 中的断言一致):
- 在线用户:200 000 ÷ 1.6 = 125 000
- 写入:125 000 × 0.05 × 0.2 = 1 250 条/s,其中单聊 750、群聊 500
- 推送帧:(750 × 0.77 + 500 × 16.45) × 1.03 = (577.5 + 8 225) × 1.03 = 9 066.6 帧/s
- 心跳:200 000 ÷ 30 = 6 666.7 帧/s
- 游标写入:9 066.6 ÷ 1 = 9 066.6 行/s
- 重连补拉:200 000 × 0.3 × 25 ÷ 60 = 25 000 行/s
- 存储:1 250 × 86 400 × 30 × 600 B ≈ 1.944 TB
脚本输出(evidence/design-02/capacity-sweep.txt 开头的 JSON):
| 资源 | 用量 | 预算 | 利用率 |
|---|---|---|---|
| 数据库写入 | 1 250 + 9 066.6 = 10 316.6 行/s | 4 000 | 2.579 |
| 数据库读取 | 9 066.6 + 25 000 = 34 066.6 行/s | 40 000 | 0.852 |
| 网关帧数 | 9 066.6 + 6 666.7 = 15 733.3 帧/s | 150 000 | 0.105 |
| 网关出口 | ≈ 4.16 MB/s | 60 MB/s | 0.069 |
| 存储 | ≈ 1.944 TB | 2 TB | 0.972 |
基线下的瓶颈是数据库写入,超出预算约 1.6 倍。值得注意的是构成:消息本身只有 1 250 行/s,占预算的 31%;剩下的 9 066.6 行/s 全部来自逐条送达确认。数据库读取的大头也不是推送,而是重连补拉的 25 000 行/s——这是一个只在故障时出现、但必须预留的负载。存储利用率 0.972,几乎贴着预算,任何增加保留期的需求都会立即让它成为瓶颈。
5. 敏感性扫描
单个参数变化时的利用率(完整表格见 evidence/design-02/capacity-sweep.txt):
5.1 确认合并
events_per_ack |
数据库写 | 数据库读 | 存储 | 瓶颈 |
|---|---|---|---|---|
| 1 | 2.579 | 0.852 | 0.972 | 数据库写 |
| 4 | 0.879 | 0.852 | 0.972 | 存储 |
| 16 | 0.454 | 0.852 | 0.972 | 存储 |
| 64 | 0.348 | 0.852 | 0.972 | 存储 |
确认合并是对基线瓶颈最有效的参数。每 4 条确认一次,写入利用率就从 2.579 降到 0.879,瓶颈转移到存储。继续合并的收益递减:从 16 到 64,写入只从 0.454 降到 0.348,剩下的是消息写入本身。
合并确认不会破坏正确性,原因在第 06 和 07 篇展开:送达游标的语义是“连续收到的最大序号”,确认 seq=n 隐含确认了 n 之前的所有事件;服务端用 GREATEST 合并,不会因确认顺序而回退。代价是重投范围变大:设备崩溃时,最后一次确认之后的事件都会被重新推送或补拉。客户端需要按序号去重,第 07 篇的本地存储以序号为键,重复应用是幂等的。
5.2 群规模
确认合并固定为 16 时:
group_members_avg |
每条扇出 | 数据库写 | 数据库读 | 网关帧 | 瓶颈 |
|---|---|---|---|---|---|
| 30 | 7.25 | 0.454 | 0.852 | 0.105 | 存储 |
| 100 | 23.4 | 0.770 | 1.356 | 0.239 | 数据库读 |
| 199 | 46.24 | 1.216 | 2.070 | 0.430 | 数据库读 |
| 201 | 46.71 | 0.502 | 0.929 | 0.434 | 存储 |
| 1000 | 231.05 | 1.222 | 2.081 | 1.970 | 数据库读 |
199 与 201 之间的跳变来自投递模式切换:超过 200 人的群不再推送完整事件,而是推送 40 字节的提示,只有 20% 的设备随后拉取。推送模式下每个在线设备都要从数据库读一次事件并确认;提示模式下只有打开会话的设备才读和确认。扇出(帧数)几乎不变,但每帧的数据库成本不同。
这个跳变也说明了阈值本身是一个设计参数:它把成本从“每个在线设备”转移到“每个实际阅读的设备”。如果 pull_read_ratio 在实际中接近 1(例如工作群里所有人都在盯屏幕),提示模式就没有收益,反而多了一轮往返。第 08 篇的本地实验对同一负载比较了两种模式的字节数,结论同样取决于阅读比例。
1000 人群时网关帧利用率达到 1.97:即使每帧只有 40 字节,帧数本身也成为瓶颈。这类负载需要的不是更小的帧,而是更少的帧,例如按时间窗口合并提示,或者只向打开了会话的设备发提示。这两种做法都没有在本批实现中出现。
单独看群规模 500 人、确认合并 16 的组合(evidence/design-02/capacity-group500-ack16.json):提示帧 144 019.8 帧/s,网关帧利用率 1.009;拉取事件 28 804 行/s 加上重连补拉 25 000 行/s,数据库读利用率 1.36,是瓶颈。
5.3 设备数、心跳与保留期
| 变化 | 主要影响 | 结果 |
|---|---|---|
| 每用户设备数 1.0 → 4.0(在线设备总数固定) | 用户数减少,写入从 2000 降到 500 条/s;每条扇出从 4.4 升到 18.67 | 数据库写利用率 2.699 → 2.459,几乎不变;存储从 1.555 降到 0.389 |
| 心跳 10 s → 120 s | 只影响网关帧 | 网关帧 0.194 → 0.072,其他项不变 |
| 保留期 30 → 90 天(确认合并 16) | 存储 | 存储 2.916,瓶颈 |
设备数一行的结论有些反直觉:在“在线设备总数固定”的前提下,多设备意味着更少的用户、更少的消息,但每条消息要送达更多设备,两者近似抵消。如果前提改为“在线用户数固定”,设备数增加就会直接放大送达成本。参数的定义方式决定了扫描的含义,这类前提必须写在表格旁边。
心跳间隔在本模型中只影响网关帧数,这符合预期(测试 test_heartbeat_only_moves_gateway_frames)。它的真实影响在别处:心跳越稀疏,半开连接被发现得越晚(第 05 篇),推送到死连接上的消息越多,这部分成本最终体现在重投比例上。模型没有把这两个参数耦合起来,这是它的局限。
6. 方案比较:读扩散与写扩散的存储
本系列的消息存储采用“每个会话一份日志”的读扩散方式。另一种常见方式是写扩散,即给每个接收用户维护一个收件箱,每条消息写入 N 份。在同样的基线参数下手算(这一节不是脚本输出):
- 读扩散:每条消息 1 行,写入 1 250 行/s。
- 写扩散:单聊每条写入 1 个接收方收件箱,群聊每条写入 29 个。合计 750 × 1 + 500 × 29 = 15 250 行/s,是读扩散的 12.2 倍,存储也按同一比例增长。
写扩散的好处在读取侧:一个用户的所有会话更新集中在一张表中,按用户一次查询即可得到全部未读,不需要逐会话查询。本系列在第 09 篇用派生查询计算未读,对每个会话做一次计数,这在会话数很多的用户身上代价更高。选择读扩散是因为在本组假设下写入已经是瓶颈,放大 12 倍不可接受;如果负载以单聊为主、读取远多于写入,结论可能相反。
7. 模型无法给出的量
| 量 | 状态 | 原因 | 获取方法 |
|---|---|---|---|
| 每连接内存(U1) | NOT_RUN | 取决于 JVM、虚拟线程栈、内核套接字缓冲 | 目标环境建立大量空闲连接后测量 |
| 数据库写入/读取上限 | NOT_RUN | 取决于硬件、WAL 配置、并发度 | 目标硬件上运行代表性负载 |
record_bytes 实际值 |
NOT_RUN | 取决于正文分布与索引 | 写入代表性数据后测量表与索引大小 |
pull_read_ratio |
NOT_RUN | 取决于用户行为 | 线上埋点 |
| 峰值与均值之比 | 未建模 | 模型直接以峰值参数输入 | — |
网关的应用层缓冲可以从配置推出一个上界,但这不是内存测量:每个连接的发件队列默认最多 64 帧(IM_OUTBOX,第 05 篇),按 450 字节计约 28.8 KB;200 000 个连接全部填满时约 5.8 GB。这个数字只说明“发件队列有界”带来的最坏情况,不包括线程栈、套接字内核缓冲和 JSON 对象开销。
8. 模型的测试
模型本身也需要测试,否则一处单位错误就会让所有结论失效。examples/design/test_capacity.py 检查五个结构性质:
| 用例 | 类型 | 检查内容 |
|---|---|---|
test_hand_computed_baseline |
正常 | 基线写入、推送帧、心跳与手算值一致 |
test_writes_do_not_depend_on_group_size |
边界 | 群规模只影响扇出,不影响写入 |
test_pull_threshold_switches_mode |
边界 | 群规模 201 时切换为提示模式,提示帧大于 0 |
test_heartbeat_only_moves_gateway_frames |
边界 | 心跳只改变网关帧 |
test_ack_coalescing_moves_bottleneck |
失败转移 | 基线瓶颈为数据库写,合并到 16 后瓶颈转移 |
运行:
1 | |
输出保存在 evidence/design-02/test_capacity.txt。重新生成扫描表:python3 capacity.py --sweep。这些测试属于模型级验证:它们说明脚本按设计的公式计算,不说明公式符合真实系统。
9. 对后续设计的约束
模型结论转化为后续各篇的设计约束:
- 送达确认必须可合并且单调(第 06、09 篇)。服务端用
GREATEST写游标,客户端确认“连续收到的最大序号”。 - 大群需要另一种投递模式(第 08 篇)。阈值是可配置的会话属性
delivery_mode,而不是写死在代码中的常量。 - 重连必须限速(第 10 篇)。25 000 行/s 的补拉假设 60 秒的窗口;如果所有设备在同一秒重连,窗口缩短 60 倍,读取量随之放大。准入令牌桶和抖动退避就是为了把窗口拉开。
- 保留期是容量参数(第 11 篇)。存储已贴近预算,保留期不能作为纯产品需求单独决定。
- 心跳间隔的选择要同时考虑网关帧数与半开连接的检测时间(第 05 篇)。
下一篇搭建单库基线:怎样用一个 PostgreSQL 实例和一组可注入的故障点,把这些设计约束变成可重复运行的实验。
参考资料
- PostgreSQL 16 文档,73.6 Database Page Layout(页头 24 字节、行指针 4 字节、堆元组头 23 字节)。https://www.postgresql.org/docs/16/storage-page-layout.html
- PostgreSQL 16 文档,9.27.7 Database Object Management Functions(
pg_total_relation_size)。https://www.postgresql.org/docs/16/functions-admin.html - RFC 6455,§5.5.2–5.5.3 Ping/Pong(心跳帧为控制帧,载荷不超过 125 字节)。https://www.rfc-editor.org/rfc/rfc6455

