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 为什么计数系统要有 “重建” 能力?

▸ 答案:因为合批、缓存剔除、并发删除、机器故障都会让计数偏离真实值,而计数是派生数据——真实值在明细表/事件日志里。没有重建能力,一次错乱就是永久脏数据,只能人工修。

关联