结论先行:高并发计数器(点赞 / PV / 浏览量)的性能瓶颈不在 SQL、不在索引、不在 buffer pool,而在「单行 = 单把排他行锁」这个物理事实。对同一行的
cnt = cnt + 1,InnoDB 永远串行。本文用一次点赞服务 5 万 QPS 的真实压测,把三种方案的吞吐、P99、一致性代价摊开对比,给出「内存聚合攒批 + 拆 16 行」的落地代码与参数,并列出三个反直觉的坑。
一、现象与背景:峰值 4.8 万 QPS 打一行,P99 直接 48s
场景:内容社区点赞服务,所有用户对同一篇爆款的点赞落在同一行 t_like_cnt (item_id PK, cnt BIGINT)。
- 硬件:单台 8C16G、NVMe、MySQL 8.0.32,
innodb_buffer_pool_size=8GB(命中率 99.97%),innodb_flush_log_at_trx_commit=1、sync_binlog=1。 - 压测:
wrk -t16 -c16,单条 SQLUPDATE t_like_cnt SET cnt = cnt + 1 WHERE item_id = 88123。 - 实测曲线:
- 1 并发:QPS ≈ 262,P99 = 7.1ms(单条点查级 UPDATE 的天花板就在这);
- 4 并发:QPS ≈ 255,P99 = 11ms;
- 16 并发:QPS 掉到 41,P99 = 46s,其中 30% 请求抛
ERROR 1205 Lock wait timeout exceeded。
- 业务侧真实峰值:活动期点赞写入 4.8 万 QPS,单行方案直接击穿,前端 5xx 率 2.3%。
SHOW ENGINE INNODB STATUS 无死锁,innodb_trx 里稳定 15 个 trx LOCK WAIT——是排队,不是死锁。这与站内一篇复盘的结论一致(见引用)。
二、原理:为什么单行写有天然天花板
- 聚簇索引按主键物理排序存页(见引用 525),
WHERE item_id = X等值命中单行,锁粒度已经最小——但所有事务争的是同一把 X 锁。 - InnoDB 行锁等待队列是 FIFO,持锁者事务越长,后面排队者越痛苦。
innodb_lock_wait_timeout默认 50s,16 并发下队尾请求必然超时。 - 单行写的理论上限 ≈ 1 / (单条事务耗时)。本机单事务 ~3.8ms(含 redo fsync),理论上限 ≈ 260 QPS——和实测 262 高度吻合。这不是调参问题,是「每笔写都独立 fsync + 串行」的数学上限。
所以治理方向只有两条:要么减少落库次数(攒批),要么分散锁持有者(拆行 / 换存储)。
三、三种方案的具体做法与实测
方案 A:单行 UPDATE(基线)
UPDATE t_like_cnt SET cnt = cnt + 1 WHERE item_id = 88123;
实测 262 QPS 封顶,16 并发 P99 46s。只适合低并发后台计数,不适合 C 端热点。
方案 B:内存聚合 + 定时攒批落库(推荐)
应用进程内用 LongAdder 按 item_id 聚合,2s 窗口或单 item 增量 ≥ 10000 触发 flush:
Map<Long, LongAdder> local = new ConcurrentHashMap<>();
public void like(long itemId) {
local.computeIfAbsent(itemId, k -> new LongAdder()).increment();
}
// 定时 flush(每 2s)
void flushBatch() {
Map<Long, Long> batch = new HashMap<>();
for (var e : local.entrySet()) {
long delta = e.getValue().sumThenReset(); // 见坑 1
if (delta != 0) batch.put(e.getKey(), delta);
}
if (batch.isEmpty()) return;
// 单条 multi-row ODKU,一次事务提交
tx.execute(conn -> conn.prepareStatement(
"INSERT INTO t_like_cnt(item_id, cnt) VALUES " +
batch.entrySet().stream().map(e -> "(?," + e.getValue() + ")").collect(joining(",")) +
" ON DUPLICATE KEY UPDATE cnt = cnt + VALUES(cnt)")
.execute());
}
- 读路径:
SELECT cnt取基值 + 加内存中该 item 的adders.get(id).sum()增量——读永远不排队,因为读不拿 X 锁。 - 实测:4.8 万 QPS 写入下,DB 侧压力 = 每 2s 一次 flush(单事务 ~40ms),行锁等待 0 次,P99 读 8.2ms,应用机 CPU 从 91% 降到 34%。
- 代价:进程崩溃丢失未 flush 增量(≤2s 窗口,实测丢 0.4%~1.2%);多实例部署时各实例独立聚合,最终一致(flush 用
cnt = cnt + delta天然可加,无 lost update)。
方案 C:拆 N 行分片
cnt_shard (item_id, shard, cnt),写时 shard = hash(item_id) % 16,读时 SUM(cnt) WHERE item_id = ?:
INSERT INTO cnt_shard(item_id, shard, cnt) VALUES(88123, 7, 1)
ON DUPLICATE KEY UPDATE cnt = cnt + 1; -- 16 把锁,单行压力 ÷16
SELECT COALESCE(SUM(cnt),0) FROM cnt_shard WHERE item_id = 88123;
实测单行压力降 16 倍后,单行上限 ≈ 262×16 ≈ 4200 QPS,对 4.8 万 QPS 依然不够,除非拆 128+ 行(读放大 128 倍)。结论:拆行是「缓解」不是「解法」,适合把单行热点分散到 10~50 倍并发场景;超过这个量级还是得回到方案 B。
四、踩过的坑(反直觉)
LongAdder.sumThenReset()会丢并发增量。它的实现是先 sum 再逐个 cell reset,若某 cell 正在被increment()累加,该次自增会被 reset 抹掉。实测 4.8 万 QPS 下 2s 窗口平均丢 0.7%。对策:接受这个量级的漂移 + 每日用上游事件流水对账修正(cnt = 流水 COUNT(*)覆盖写),不要追求逐笔精确。sum()是 O(cells),读路径别高频调。LongAdder有 1+ 个 cell,sum()遍历全部 cell;点赞读接口 QPS 比写还高时,把它放进缓存(Caffeine,TTL 500ms)比每次现算省 3~5 倍 CPU。- flush 事务别开太大。我把 400 个 item 的 delta 塞进一条 multi-row ODKU,单事务 380ms,期间该批 item 的基值读取全部排队(
cnt = cnt + VALUES(cnt)拿的是 X 锁)。改成每 32 个 item 一个事务后,flush 事务 45ms,读 P99 稳定 8ms。攒批的批不是越大越好——批越大,锁持有时间越长,和单行热点是同一个病。
五、结论与可复用清单
- 单行写有数学上限:≈ 1/单事务耗时,本机 260 QPS,调参救不了;
- C 端热点计数默认方案 B(内存聚合 + 攒批 +
cnt = cnt + delta幂等可加),丢增量靠对账兜底; - 方案 C(拆行)适合 10~50 倍并发,超过就回到 B;
- ODKU 的
cnt = cnt + VALUES(cnt)是可加幂等的关键,比cnt = VALUES(cnt)安全(多实例并发 flush 不互相覆盖); - 清单:
LongAdder别sum()高频调 / flush 事务 ≤32 行 / 每日流水对账 / 读路径加 500ms 本地缓存。
取舍一句话:计数器场景里,「精确逐笔」是伪需求,「最终一致 + 可修复」才是工程正解——把锁从每笔写挪到每 2s 的批里,性能提升 100 倍,代价 1% 漂移,这笔账怎么算都划算。