MySQL 热点行更新卡住是因为高并发下 InnoDB 行锁排队,所有事务争抢同一 record lock 导致串行化;表现为 Lock wait timeout、Threads_running 突增但 QPS 低、慢日志中 UPDATE 耗时超 100ms。

MySQL 热点行更新为什么会卡住
因为 InnoDB 的行锁在高并发下会排队,而 UPDATE 语句如果反复修改同一行(比如计数器、库存字段),所有事务都在等同一个 record lock,实际变成串行执行。这时候 CPU 可能不高,但 innodb_row_lock_waits 和 innodb_row_lock_time_avg 会明显升高。
常见错误现象:Lock wait timeout exceeded;监控里看到 Threads_running 突增但 QPS 上不去;慢日志里大量 UPDATE …… WHERE id = ? 耗时集中在 100ms+。
- 别用
SELECT …… FOR UPDATE+ 应用层计算再UPDATE,这延长了锁持有时间 - 避免在事务里做 HTTP 请求、文件读写等外部依赖,锁住热点行的同时还干别的事,等于主动拖长队列
- 确认是否真需要实时精确值——很多场景其实可以接受“最终一致”,比如浏览量、点赞数
用 INSERT … ON DUPLICATE KEY UPDATE 合并写请求
这是最轻量的合并方案:把多次小更新攒成一次,靠唯一键触发“插入或更新”逻辑,绕过显式加锁流程。适用于有自增主键 + 唯一键(如 user_id)的计数表。
使用场景:用户行为埋点汇总(如 click_count)、轻量级库存预占(配合后续校验)。
示例表结构:
CREATE TABLE user_counter (user_id BIGINT PRIMARY KEY, click_count INT DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP);
批量合并写法(应用层聚合后一次性提交):
INSERT INTO user_counter (user_id, click_count) VALUES (123, 1), (456, 1), (123, 1), (789, 1) ON DUPLICATE KEY UPDATE click_count = click_count + VALUES(click_count);
-
VALUES(click_count)是关键,它取当前这一行 INSERT 值,不是整条语句的原始值 - 必须有
PRIMARY KEY或UNIQUE KEY,否则ON DUPLICATE KEY UPDATE不生效 - 不适用于需要原子性条件判断的场景(比如“只有余额 > 100 才扣减”),它不支持
WHERE条件过滤
用 Redis 队列做写缓冲和批量落库
当更新频率远超 MySQL 单行吞吐(比如每秒上千次同 key 更新),就得把写操作从数据库前置到内存队列,再由消费者按需合并、降频、批量刷入。
核心思路:应用只往 Redis 的 LPUSH 推更新指令(如 JSON 字符串),后台常驻进程用 BRPOP 拉取、去重、聚合,再拼成一条 INSERT …… ON DUPLICATE KEY UPDATE 或 REPLACE INTO。
- Redis 列表本身不保证去重,聚合逻辑必须在消费者里做(例如按
user_id分组 sumdelta) - 注意消费者崩溃时消息丢失风险,建议用 Redis Streams +
GROUP+ACK,或者加 MySQL 表记录消费位点 - 不要让单个消费者扛全量热点 key,可按
user_id % N分片,起 N 个 worker 并行处理 - 批量大小建议控制在 100–500 行之间;太小没收益,太大导致单次 SQL 执行久、失败回滚成本高
排队控制:用 GET_LOCK() 做应用层限流还不够
GET_LOCK('hot_user_123', 0) 看似能强制排队,但实际问题很多:锁名长度限制(32 字符)、连接断开自动释放、无法跨实例协调、且锁粒度粗(整个 key 一把锁,哪怕更新不同字段也互斥)。
更稳妥的做法是结合业务逻辑做“软排队”:
- 对热点 key,应用层本地缓存一个
last_update_time,两次更新间隔低于 100ms 就直接丢弃或合并(适合幂等指标) - 用 Redis 的
SETNX+ 过期时间模拟分布式信号量,但仅用于“是否允许进入合并窗口”,不是代替数据库锁 - 真正关键的强一致性更新(如支付扣款),仍要走数据库行锁,但得确保这个路径极短——只做
UPDATE …… SET balance = balance - ? WHERE balance >= ?,不查不判不调外部服务
容易被忽略的一点:即使用了合并和队列,也要定期检查 information_schema.INNODB_TRX 里有没有长事务卡住锁,尤其是那些忘记 COMMIT 的调试代码。热点问题往往不是设计问题,而是某条脏数据或一段残留事务在持续堵路。