后端系统工程 02:负载模型与容量预算

本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收

“峰值每秒一千多条消息”这样的数字本身不能指导设计。同样是每秒 1250 条消息,单聊为主的负载和大群为主的负载对数据库、网关和带宽的压力相差一个数量级以上;同样的消息量,客户端每收到一条就确认一次和攒够十几条再确认一次,数据库写入量也相差数倍。容量预算要回答的不是“总量多大”,而是“哪一种资源先用完,以及哪个参数一变,瓶颈就会换位置”。

本篇把第 01 篇定义的接口操作折算成五类资源消耗,用一个带单位的脚本 examples/design/capacity.py 计算,再通过敏感性扫描找出决定性参数。全部规模参数都是教学假设,不来自任何真实系统;结论只在这些假设下成立。

1. 模型要回答的问题

  1. 在基线参数下,哪类资源最先达到预算?超出多少?
  2. 改变单个参数(群规模、每用户设备数、心跳间隔、确认合并、保留期)时,瓶颈是否转移?
  3. 哪些量是模型无法给出、必须测量的?

第三个问题与前两个同等重要。模型能做的是把乘法做对,把单位对齐,并指出哪些项的贡献最大;它不能给出每个连接占多少内存、数据库每秒能写多少行。这些是预算的输入,不是模型的输出。

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
2
3
4
5
6
7
8
9
10
11
12
图 2-1  容量模型的计算链:从在线设备到五类资源
online_devices ──÷ devices_per_user──> online_users
online_users ──× active_ratio × rate──> writes (条/s) ──────────────┬─> 存储 = writes × 86400 × 保留天数 × record_bytes
│
writes ─┬─ 单聊 (1-group_share) × 接收设备数 ─┐ │
└─ 群聊 group_share × 接收设备数 ─────┼─> 推送帧 或 提示帧 │
│ × (1+redelivery) │
▼ ▼
送达事件 ──÷ events_per_ack──> 游标写入 ──(+writes)──> 数据库写
推送帧 + 拉取事件 + 重连补拉 ─────────────────────> 数据库读
推送帧 + 提示帧 + 心跳 ───────────────────────────> 网关帧
(推送+拉取)×push_bytes + 提示×hint_bytes + 心跳 ──> 网关出口

几个关键的折算关系:

写入与扇出分开计算。 每条消息只在会话日志中写一行(第 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
2
3
4
$ cd examples/design && python3 test_capacity.py
...
Ran 5 tests in 0.002s
OK

输出保存在 evidence/design-02/test_capacity.txt。重新生成扫描表:python3 capacity.py --sweep。这些测试属于模型级验证:它们说明脚本按设计的公式计算,不说明公式符合真实系统。

9. 对后续设计的约束

模型结论转化为后续各篇的设计约束:

  1. 送达确认必须可合并且单调(第 06、09 篇)。服务端用 GREATEST 写游标,客户端确认“连续收到的最大序号”。
  2. 大群需要另一种投递模式(第 08 篇)。阈值是可配置的会话属性 delivery_mode,而不是写死在代码中的常量。
  3. 重连必须限速(第 10 篇)。25 000 行/s 的补拉假设 60 秒的窗口;如果所有设备在同一秒重连,窗口缩短 60 倍,读取量随之放大。准入令牌桶和抖动退避就是为了把窗口拉开。
  4. 保留期是容量参数(第 11 篇)。存储已贴近预算,保留期不能作为纯产品需求单独决定。
  5. 心跳间隔的选择要同时考虑网关帧数与半开连接的检测时间(第 05 篇)。

下一篇搭建单库基线:怎样用一个 PostgreSQL 实例和一组可注入的故障点,把这些设计约束变成可重复运行的实验。

参考资料