44.22 计数系统
一句话
计数器只有三个接口(加、取、清零),难点全在量级:万亿级 key × 百万级 QPS 读 × 十万级 QPS 写——省内存靠换存储布局(70 字节/条 → 12 字节/条),抗写靠合批(写次数 ÷ 25),控成本靠冷热分层。这是 “用数据结构换机器” 的典型题,不是 “上个 Redis” 的配置题。
先破除四个误区
| 误区 | 现实 |
|---|---|
计数就是 INCR,放 Redis 就完事 | 一个 key 在 Redis 里实占 70 字节以上(key 的 sdshdr + 数字串 + dictEntry + robj)。一万亿个计数 = 70 TB 内存,不是 “多买几台机器” 能解决的 |
| 计数存 MySQL 最稳 | 单表几千万行后索引放不进内存,读索引要走磁盘;高频 UPDATE 还会锁行,把写入并发压死 |
| 计数必须强一致 | 点赞数显示 10 万还是 10.0001 万,用户看不出来。可合并、可延迟、可近似才是计数系统的设计空间 |
| 一个业务对象一组计数就该一个 key | 一条微博有转发/评论/点赞/阅读四类计数,四个 key 就是 4 份 key 开销;合并成一个 value 能省掉 3 份对象 ID |
量级:先看数字再选方案
| 维度 | 量级(微博公开口径) |
|---|---|
| 内容条目 | 千亿 |
| 核心计数(微博维度) | 几千亿,含用户维度过万亿 |
| 读 | 百万 QPS,要求毫秒返回 |
| 写 | 首页信息流每秒几十万,计数写流量更高 |
| 单表容量现实 | 单表几千万行时 MySQL 已明显损耗 |
这条量级决定了三件事,缺一件就撑不住:写侧合批、读侧多从分片、冷数据下沉。
为什么计数最终会离开 MySQL
不是因为 “MySQL 慢”,而是因为双写不一致:写库成功、更新缓存失败,就留一份脏计数;反过来先写缓存再落库,掉电就丢。计数这种 “高频、幂等、可近似” 的数据,最划算的做法是只保留一份可写存储,把它做成内存结构:
写路径:业务事件 → MQ(削峰) → 计数服务(内存累加 + 合批) → 存储
读路径:业务 → 计数服务(多从 + 分片) → 毫秒返回
冷路径:定期任务把久远计数 dump 到 SSD,内存只留最近窗口
内存:70 字节怎么变成 12 字节
先拆 Redis 里一个 “8 字节整数 key + 4 字节整数 value” 的真实开销:
| 组成 | 字节 | 说明 |
|---|---|---|
sdshdr 头 | 8 | 字符串对象头 |
| 数字字符串本体 | 19 | 8 字节整数最多 19 位十进制 |
'\0' 结尾 | 1 | C 字符串惯例 |
dictEntry(哈希桶节点) | 24–32 | key 指针 + value 指针 + next + hash |
robj(value 对象) | 16 | 类型 + 编码 + 引用计数 + 指针 |
| 合计 | ≈ 70+ | 3 字节的有效数据,70 字节的实际成本 |
自己管内存时才可能压到 12 字节:8 字节 ID + 4 字节计数,中间不留指针。
| 布局 | 每条开销 | 说明 |
|---|---|---|
| 哈希表(dict / Redis) | 70+ B | 通用、灵活、支持任意 key |
| 超大数组 + 双哈希定位 | 12 B | pos = (h1 + h2 * s) % tsize,冲突线性探 1000 次 |
| 下标即 ID 的数组 | 4 B | 只在 ID 稠密连续时可用 |
删除怎么办?全内存数组不能真删(会打乱下标),做法是把该槽写成特殊标记(如 0xFFFF),读的时候当作 “无此计数”。
本机实测三种布局的每条开销(Python 3.14 / macOS arm64,tracemalloc 计实际分配):
import tracemalloc, array
N = 1_000_000
def measure(name, build):
tracemalloc.start()
keep = build()
cur, _ = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"{name:<26} {cur/1e6:>7.1f} MB 每条 {cur/N:>5.1f} B")
measure("dict[int, int]", lambda: {i: i % 7 for i in range(N)})
measure("array('I') 下标即 key", lambda: array.array('I', (i % 7 for i in range(N))))
measure("array('Q') + array('I')", lambda: (array.array('Q', range(N)), array.array('I', (i % 7 for i in range(N)))))
dict[int, int] 73.9 MB 每条 73.9 B
array('I') 下标即 key 4.1 MB 每条 4.1 B
array('Q') + array('I') 12.3 MB 每条 12.3 B
🔑 结论不是 “别用哈希表”,而是在万亿量级上,通用结构的那几十字节开销就是几 TB 的账单。Redis 适合 “千万到亿级热计数”,到了万亿级必须自己做布局——这是 Pika、SSDB、字节/微博自研计数服务的动机。
写侧:合批省的是网络往返,不是 CPU
单条计数写一次网络往返,百万 QPS 就是百万次 RTT。合批把同一窗口内同一 key 的多次 +1 合成一次 +N:
import time
KEYS, EVENTS, WINDOW = 20, 1_000_000, 500
# ① 逐条写:每条一次 INCR(一次网络往返)
t0 = time.perf_counter()
cnt = [0] * KEYS
for e in range(EVENTS):
cnt[e % KEYS] += 1
naive = time.perf_counter() - t0
# ② 合批:每 500 个事件把窗口内的增量一次性落一次
t0 = time.perf_counter()
cnt2, buf, writes, inwin = [0] * KEYS, {}, 0, 0
for e in range(EVENTS):
k = e % KEYS
buf[k] = buf.get(k, 0) + 1
inwin += 1
if inwin == WINDOW:
for kk, v in buf.items():
cnt2[kk] += v
writes += len(buf)
buf.clear(); inwin = 0
for kk, v in buf.items():
cnt2[kk] += v; writes += 1
batch = time.perf_counter() - t0
print(f"逐条写: {EVENTS:>10,} 次写 耗时 {naive*1000:6.1f} ms")
print(f"合批写: {writes:>10,} 次写 耗时 {batch*1000:6.1f} ms (每 {WINDOW} 个事件落一次)")
print(f"写次数降低 {EVENTS/writes:.0f} 倍; 结果一致 {cnt == cnt2}")
逐条写: 1,000,000 次写 耗时 85.0 ms
合批写: 40,000 次写 耗时 172.5 ms (每 500 个事件落一次)
写次数降低 25 倍; 结果一致 True
注意第二行:本机 CPU 时间反而变长 2 倍。合批买到的是 25 倍的网络往返削减,代价是延迟(最多一个窗口)与内存(缓冲)。所以在业务上要先回答:这个计数能容忍多大延迟? 点赞可以忍 1 秒,库存不能。
| 窗口 | 写次数 | 最大延迟 | 适合 |
|---|---|---|---|
| 逐条 | 1,000,000 | 0 | 库存、余额 |
| 500 条 | 40,000 | 一个窗口 | 点赞、阅读数、粉丝数 |
| 5 秒定时 | 更少 | 5 秒 | 榜单、热度 |
未读数是计数的一个变体:它记的不是 “有多少”,而是 “读到哪儿了”,见 44.23 未读数系统。
存储:冷热分层
| 层 | 存什么 | 介质 | 访问方式 |
|---|---|---|---|
| 热 | 最近 N 天的计数 | 内存(自研数组 / Redis) | 直接读 |
| 冷 | 久远计数 | SSD / 磁盘(Pika、SSDB 同类实现) | 按需异步载入 “冷缓存” |
磁盘能挂 2 TB、内存只有 128 GB(差 16 倍),所以 “全放内存” 在万亿量级上不成立;而绝大多数读请求只关心最近的内容,冷数据天然少人问。
生产落地:指名 → 可抄 → 可验 → 可退
指名:千万~亿级热计数用 Redis 7.x(Hash + 多从 + 分片);需要把冷数据放磁盘且兼容 Redis 协议用 Pika 或 SSDB;削峰用 Kafka。万亿级自研时,存储层通常是 “内存数组 + 定期 dump”。
可抄(Redis 侧,单实例验证):
# 一个业务对象一组计数:用一个 Hash,避免 4 个 key 的 4 份开销
redis-cli HSET cnt:weibo:1001 forward 0 comment 0 like 0 read 0
redis-cli HINCRBY cnt:weibo:1001 like 1
redis-cli HGETALL cnt:weibo:1001
# 1) "forward"
# 2) "0"
# 3) "comment"
# 4) "0"
# 5) "like"
# 6) "1"
⚠️ 以上命令未在本机验证(本机没有 Redis)。
HINCRBY是原子操作;HGETALL在 key 字段很多时会阻塞,字段多请用HMGET指定字段。
可验:三条命令判断 “是不是该改布局了”。
redis-cli INFO memory | grep used_memory_human # 实际占用
redis-cli INFO keyspace # key 数量与过期分布
redis-cli --bigkeys # 有没有超大 Hash/ZSet
判据:used_memory / 有效数据字节 ≥ 5 说明布局开销已经主导成本,进入 “该自研或该下沉” 的区间。
可退:计数是可近似、可重建的数据,这给了三层退路:
| 退路 | 做法 | 代价 |
|---|---|---|
| 降级读 | 读不到时返回上一周期的快照值 | 显示旧值 |
| 关闭合批 | 开关切回逐条写 | 写压力上升 |
| 全量重建 | 从明细表/日志重算计数 | 分钟级不可用 |
反模式
| 反模式 | 后果 |
|---|---|
| 每个计数一个 Redis key | key 开销 ×4,万亿量级直接多出几 TB |
用 SELECT COUNT(*) 实时算总数 | 全表扫,QPS 一高就锁死 |
| 计数与业务数据同表同行 | 每次 UPDATE 锁行,写入并发被压死 |
| 不合批,每条事件一次写 | 网络往返成为瓶颈,扩容只加成本不加吞吐 |
| 把计数当强一致数据(库存/余额) | 合并与延迟会造成少卖/超卖 |
只增不减,删除时直接 -1 | 并发删除会让计数变负数;应写标记或重算 |
| 冷热不分,全部常驻内存 | 内存成本随数据线性增长,最终不可承受 |
| 没有重建能力 | 一旦计数错乱,永远修不回来 |
三条铁律
🔑 先算 “每条多少字节”,再谈 “用哪个 Redis”。70 字节 × 一万亿 = 70 TB,这个乘法决定架构。
🔑 写侧合批 + 读侧多从:合批降的是网络往返,多从分的是读流量,两者不能互相替代。
🔑 计数是可重建的派生数据:允许延迟、允许近似、必须能重算;把它当强一致数据用,就是把系统拖死。
自检
Q1 一万亿个计数,为什么 “上 Redis 集群” 这个答案不成立?
▸ 答案:因为单条计数在 Redis 里实占 70+ 字节(sdshdr 8 + 数字串 19 + \0 1 + dictEntry 24~32 + robj 16),一万亿条就是 70 TB 内存。Redis 的优势是 “通用 + 省事”,代价是每 key 的固定开销;万亿量级要自己做布局(12 字节/条)或做冷热分层,把内存里的条数降到千万~亿级。
Q2 合批把 100 万次写降到 4 万次,为什么 CPU 时间反而变长?
▸ 答案:因为合批的收益在网络往返,不在 CPU:逐条写的成本主要是 “一次 RTT × 100 万”,本地 CPU 只是累加;合批多了 “缓冲字典 + 窗口判断” 的本地开销,所以 CPU 时间上升、网络次数下降。判断该不该合批,要算 “RTT 成本 vs 可容忍延迟”,不是算本地 CPU。
Q3 点赞计数能合批,库存计数为什么不能?
▸ 答案:点赞幂等且可近似——晚 1 秒、少算 1 个,用户看不出来,也能重算;库存不可超卖,任何延迟或合并都会造成 “卖了没扣”,必须走强一致的路径(行锁/条件更新/预扣)。同一套 “计数” 外观下,业务语义完全不同,这是选型的第一道分界线。
Q4 为什么 “删除计数” 比 “增加计数” 难?
▸ 答案:全内存数组不能真删(会打乱下标),只能写特殊标记(如 0xFFFF),读时当作不存在;而删除与并发增加交错时,-1 会把计数打成负数。工程做法是 写标记 + 定期重建,而不是在热路径上做减法。
Q5 used_memory / 有效数据字节 这个比值说明什么?多少算高?
▸ 答案:它衡量 “存储布局开销占比”。比值 ≥ 5 说明有效数据只占 1/5,其余都是 key、指针、对象头——这时继续扩容是在为元数据付费,应该改布局(合并 key、换紧凑结构)或把冷数据下沉。
Q6 计数读要求 “毫秒返回、百万 QPS”,多从和分片各解决什么?
▸ 答案:多从解决读 QPS(同一份数据被多副本分担),分片解决单机内存与连接数上限(不同 key 落在不同实例)。两者缺一不可:只加从库,单机内存仍装不下全部 key;只分片,每个分片仍要扛全量读。
Q7 为什么计数系统要有 “重建” 能力?
▸ 答案:因为合批、缓存剔除、并发删除、机器故障都会让计数偏离真实值,而计数是派生数据——真实值在明细表/事件日志里。没有重建能力,一次错乱就是永久脏数据,只能人工修。