结论先行:高并发计数器(点赞 / PV / 浏览量)的性能瓶颈不在 SQL、不在索引、不在 buffer pool,而在「单行 = 单把排他行锁」这个物理事实。对同一行的
cnt = cnt + 1,InnoDB 永远串行。本文用一次点赞服务 5 万 QPS 的真实压测,把四种方案(单行 UPDATE、内存聚合攒批、拆行、换存储)的吞吐、P99、一致性代价摊开对比,给出「内存聚合攒批 + 拆 16 行」的落地代码与参数,并列出五个反直觉的坑(v2 修订:新增方案 D 与坑 4、坑 5,并把方案 B 的 ODKU 换成 MySQL 8.0.20+ 的 alias 写法)。
一、现象与背景:峰值 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——是排队,不是死锁。
二、原理:为什么单行写有天然天花板
- 聚簇索引按主键物理排序存页,
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,一次事务提交;每 32 个 item 切一个事务(见坑 3)
List<Map.Entry<Long, Long>> list = new ArrayList<>(batch.entrySet());
for (int i = 0; i < list.size(); i += 32) {
List<Map.Entry<Long, Long>> chunk = list.subList(i, Math.min(i+32, list.size()));
String sql = "INSERT INTO t_like_cnt(item_id, cnt) VALUES " +
chunk.stream().map(e -> "(?, ?)").collect(Collectors.joining(",")) +
" AS new ON DUPLICATE KEY UPDATE cnt = cnt + new.cnt"; // 8.0.20+ alias 写法,见坑 5
tx.execute(conn -> {
PreparedStatement ps = conn.prepareStatement(sql);
int idx = 1;
for (var e : chunk) { ps.setLong(idx++, e.getKey()); ps.setLong(idx++, e.getValue()); }
ps.executeUpdate();
});
}
}
- 读路径:
SELECT cnt取基值 + 加内存中该 item 的adders.get(id).sum()增量——读永远不排队,因为读不拿 X 锁。 - 实测:4.8 万 QPS 写入下,DB 侧压力 = 每 2s 一次 flush(单事务 ~45ms,32 行一批),行锁等待 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 或 D。
方案 D:换存储(v2 新增)
适用场景:写入 QPS > 5 万、且能接受「读延迟 ≤ 100ms 的最终一致」时,把计数器从 MySQL 挪到专用 KV。三个选项:
| 选项 | 单 key 写上限 | 读 P99 | 一致性代价 | 适用 |
|---|---|---|---|---|
| Redis INCR key | 10 万+ QPS(单实例,pipeline 后 30 万+) | < 1ms | 持久化(AOF everysec)最多丢 1s | 点赞 / 浏览量这种「丢了能重算」的计数 |
| Redis Hash HINCR | 同上,多 key 摊到不同 slot | < 1ms | 同上 | 需要按 item 聚合时 |
| 自研 counter 服务(内存 + 落 S3/对象存储) | 20 万+ QPS | 5~20ms | 对象存储最终一致 | 跨 region、需要审计 |
关键判据:只有当「计数值丢了能重算」(有上游事件流水可回放)时,方案 D 才成立。如果计数值本身就是业务状态(库存、余额),别换,老老实实回 B。
实测:把 4.8 万 QPS 切到 Redis INCR,MySQL 侧写入降到 0,Redis 单实例 CPU 从 0 升到 22%,读 P99 从 8.2ms 降到 1.1ms,代价是 AOF 重启最多丢 1s 增量(用上游流水对账兜底,和方案 B 一样)。
方案 D 的坑:Redis 单 key 是「单线程 + 单锁」,物理上和 MySQL 单行是同一个病——只是上限从 260 QPS 提到 10 万 QPS。所以热点 key 依然要拆(like:{item_id}:{shard},shard 0~15),读时 MGET 求和。拆 key 和拆行是同一思想,只是换了个更宽的「行」。
四、踩过的坑(反直觉,v2 扩到 5 条)
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。攒批的批不是越大越好——批越大,锁持有时间越长,和单行热点是同一个病。 - (v2 新增)读路径的「基值 + 内存增量」有 2s 的漂移窗口,不是 bug 是 feature。多实例部署时,实例 A 刚 flush 完、实例 B 的
LongAdder还没清零,读接口从 A 拿基值 + 从 B 拿增量会少算(B 的增量已经写进基值了)。我们踩过一次:运营后台看到的点赞数在 2s 窗口内回退,被当成 bug 排查了 3 小时。正确做法:读路径只信基值,不叠加内存增量,或者用「基值 + 本实例增量 − 本实例已 flush 增量」做补偿。前者简单但读延迟 ≤ 2s,后者精确但代码复杂——点赞场景选前者,余额场景选后者(但余额根本不该用这套方案,见方案 D 判据)。 - (v2 新增)MySQL 8.0.20+ 的 ODKU 别再用
VALUES(col)。VALUES()函数在 8.0.20 起被标记 deprecated(9.0 会移除),8.0.32 已经会在SHOW WARNINGS里打一条。正确写法是INSERT ... AS new ON DUPLICATE KEY UPDATE cnt = cnt + new.cnt(alias 语法)。我们一个老服务因为用了VALUES(cnt),升 8.0.35 后strict_mode下直接报ERROR 1292,回滚花了 40 分钟。新代码一律用 alias 写法,老代码升 8.0 前先全局 grep 一遍VALUES(。
五、结论与可复用清单(v2)
- 单行写有数学上限:≈ 1/单事务耗时,本机 260 QPS,调参救不了;
- C 端热点计数默认方案 B(内存聚合 + 攒批 +
cnt = cnt + delta幂等可加),丢增量靠对账兜底; - 方案 C(拆行)适合 10~50 倍并发,超过就回到 B 或 D;
- 方案 D(换 Redis / 专用 KV)适合 > 5 万 QPS + 可重算的计数,热点 key 依然要拆;
- ODKU 用 alias 写法(
AS new ... new.cnt),别用 deprecated 的VALUES(col); - 清单:
LongAdder别sum()高频调 / flush 事务 ≤32 行 / 每日流水对账 / 读路径加 500ms 本地缓存 / 读路径只信基值不叠加增量 / 升 MySQL 8.0 前 grepVALUES(。
取舍一句话:计数器场景里,「精确逐笔」是伪需求,「最终一致 + 可修复」才是工程正解——把锁从每笔写挪到每 2s 的批里,性能提升 100 倍,代价 1% 漂移,这笔账怎么算都划算;超过 5 万 QPS 再把「行」换成「key」,思想不变。