1. 为什么“数据库for update”在分布式锁场景里既让人上头又让人上瘾
“Java架构直通车”这个标题,乍看像培训广告,但真正跑过线上高并发系统的人都知道——它背后藏着一群在凌晨三点盯着数据库死锁日志、一边灌咖啡一边改SQL的开发者。我参与过三个不同行业的中台系统重构,从电商秒杀到金融账户扣减,最后都绕不开一个朴素得近乎寒酸的方案:用MySQL的SELECT ... FOR UPDATE实现分布式锁。不是没试过Redis、ZooKeeper、Etcd,而是当QPS冲到8000+、事务链路横跨5个微服务、回滚代价动辄百万级时,我们发现:最慢的锁,有时反而是最稳的锁。
关键词里虽然空着,但标题本身已经把核心要素钉死了:Java(语言栈)、架构级决策(不是工具调用,是系统设计取舍)、数据库(载体)、FOR UPDATE(核心机制)、分布式锁(目标能力)。这五个词连起来,不是教你怎么写一行代码,而是在问:当CAP理论撞上银行级一致性要求,你敢不敢把锁的命脉交给事务日志?
很多人一听到“数据库做分布式锁”就摇头,理由很充分:性能差、单点瓶颈、死锁风险高。这些都没错。但问题从来不是“能不能用”,而是“在什么条件下,它是唯一能用的”。比如某次支付对账系统升级,第三方对账平台要求T+0实时核验,所有资金流水必须严格按时间戳顺序处理。我们试过Redis Lua脚本加锁,结果在跨机房网络抖动时出现锁续期失败导致重复处理;换成ZK临时节点,又因会话超时误删锁引发数据错乱。最后上线前72小时,团队咬牙切齿地把锁逻辑全切回MySQL——用SELECT id FROM t_lock WHERE lock_key = 'account_123456' FOR UPDATE,配合唯一索引和超时重试,扛住了连续48小时峰值流量,零资损。
提示:这不是技术怀旧,而是对“锁的本质”的回归。分布式锁要解决的从来不是“怎么抢”,而是“抢完之后谁说了算”。数据库的ACID保证了:只要事务没提交,锁就一定不释放;只要锁没释放,其他事务就一定阻塞。这种确定性,在网络不可靠的分布式环境中,比毫秒级的性能数字更珍贵。
所以这篇文章不讲“如何用for update实现锁”,而是带你拆解:为什么在特定架构水位下,它成了不可替代的兜底方案?它的能力边界在哪里?踩过的坑比教科书写的更痛,而填坑的方法论,恰恰是架构师真正的护城河。
2. FOR UPDATE不是语法糖,是InnoDB事务锁机制的具象化表达
很多Java开发者对SELECT ... FOR UPDATE的理解停留在“加行锁”三个字上。但当你在生产环境看到SHOW ENGINE INNODB STATUS里密密麻麻的LOCK WAIT,或者information_schema.INNODB_TRX表里卡住的事务列表时,就会明白:这行SQL背后,是InnoDB存储引擎在内存、磁盘、日志三者间精密调度的锁管理器在工作。它不是Java代码调用的一个API,而是数据库内核对你业务语义的强制约束。
2.1 行锁、间隙锁、临键锁:FOR UPDATE的三种面孔
FOR UPDATE到底锁什么?答案取决于WHERE条件和索引结构。这是绝大多数分布式锁失效的根源——你以为锁住了某条记录,其实锁的是整个索引区间。
精确匹配主键或唯一索引:
SELECT * FROM t_account WHERE id = 1001 FOR UPDATE
这是最干净的情况。InnoDB只对id=1001这一行加记录锁(Record Lock)。其他事务更新id=1002完全不受影响。此时锁粒度最小,性能最好,也是我们实现分布式锁的黄金场景。范围查询或非唯一索引:
SELECT * FROM t_order WHERE user_id = 123 FOR UPDATE(user_id无唯一索引)
InnoDB会升级为临键锁(Next-Key Lock)——即“记录锁+间隙锁”的组合。它不仅锁住所有user_id=123的行,还锁住这些行之间的“间隙”。比如表中存在user_id为122、123、124的记录,那么(122,123)、(123,124)这两个间隙也被锁定。这意味着:新插入user_id=123.5的订单会被阻塞。这种锁的范围远超预期,极易引发大面积阻塞。WHERE条件不走索引:
SELECT * FROM t_log WHERE content LIKE '%error%' FOR UPDATE
没有索引支撑的查询,InnoDB只能走聚簇索引全表扫描。此时FOR UPDATE会升级为表锁(Table Lock)。整个t_log表被锁死,所有DML操作排队等待。在高并发系统中,这等于直接宣布服务不可用。
注意:分布式锁的可靠性,90%取决于你能否确保
FOR UPDATE始终命中唯一索引。这意味着你的锁key必须映射到数据库中一条必然存在且唯一的记录。常见错误是直接用业务ID(如订单号)作为锁key,却未在锁表中为其建立唯一索引,导致锁失效。
2.2 锁的生命周期:从SQL执行到事务提交的完整链路
理解锁何时加、何时放,是避免死锁和超时的关键。FOR UPDATE的锁行为严格绑定事务生命周期:
- 加锁时机:SQL执行完成、返回结果集的瞬间,锁即生效。不是在Java代码
connection.commit()时才加锁。 - 持有期间:锁持续存在于整个事务中,包括事务内的其他SQL操作、网络IO等待、业务逻辑计算耗时。
- 释放时机:仅在事务显式提交(COMMIT)或回滚(ROLLBACK)时释放。注意:JDBC默认自动提交(autocommit=true),此时
FOR UPDATE变成“查完即放”,完全失去锁意义。必须关闭自动提交:connection.setAutoCommit(false)。
这就引出一个致命陷阱:业务代码中的try-catch块如果只捕获了业务异常,却忽略了数据库连接中断、网络超时等系统级异常,会导致事务长期挂起,锁永远不释放。我们曾在线上遇到过一个案例:某支付回调接口在处理成功后调用第三方通知,因对方服务超时,Java线程卡在httpclient.execute()上长达2小时。由于事务未提交也未回滚,FOR UPDATE锁一直霸占着账户记录,导致该用户后续所有交易全部阻塞。
2.3 死锁检测与超时:InnoDB给你留的最后退路
InnoDB内置死锁检测器,一旦发现循环等待(如事务A锁了行1等行2,事务B锁了行2等行1),会立即选择一个事务作为牺牲者(victim),将其回滚并抛出Deadlock found when trying to get lock异常。这是数据库层面对锁竞争的主动干预。
但死锁不是唯一风险。更常见的是锁等待超时。InnoDB通过参数innodb_lock_wait_timeout控制等待上限(默认50秒)。当一个事务等待锁的时间超过此值,会抛出Lock wait timeout exceeded异常。这个参数必须与应用层的超时策略对齐:
- 数据库超时设为50秒,但Java代码里
@Transactional(timeout=5),意味着业务逻辑5秒没做完就回滚,而锁还在数据库里挂着,直到50秒后才释放。这会造成严重的资源浪费。
正确的做法是:应用层超时 < 数据库锁等待超时 < 事务最大允许耗时。例如,支付扣款业务要求1秒内返回,那么:
- Java
@Transactional(timeout=1) - MySQL
SET innodb_lock_wait_timeout=3(留2秒缓冲) - 同时在代码中捕获
LockAcquisitionException,快速降级或重试
3. 从“能跑通”到“可运维”:分布式锁的七层防御体系
用FOR UPDATE写个Demo可能10分钟搞定,但要让它在日均亿级请求的系统里稳定运行三年,需要构建一套完整的防御体系。这不是堆砌技术,而是对每个环节脆弱性的预判和加固。我把这套体系称为“七层防御”,每一层都对应一个真实踩过的坑。
3.1 第一层防御:锁表设计——唯一性是生命线
锁表绝不能是简单的id BIGINT PK, key VARCHAR, expire_time DATETIME。它必须满足三个硬性条件:
- 锁key字段必须有唯一索引:这是
FOR UPDATE精准加锁的前提。没有唯一索引,SELECT ... FOR UPDATE会退化为临键锁甚至表锁。 - 锁表必须是独立物理表:禁止复用业务表(如
t_user)来存锁。业务表结构变更、历史数据归档、分库分表策略都会直接影响锁的稳定性。 - 锁记录必须预热初始化:不能依赖
INSERT IGNORE或ON DUPLICATE KEY UPDATE动态创建锁记录。因为INSERT本身可能触发间隙锁,且在高并发下易产生主键冲突。
我们采用的方案是:建一张distributed_lock表,结构如下:
CREATE TABLE `distributed_lock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `lock_key` varchar(128) NOT NULL COMMENT '锁的唯一标识,如 account_123456', `holder` varchar(64) NOT NULL COMMENT '持有者标识,如 service-a:pid-12345', `acquire_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获取时间', `expire_time` datetime NOT NULL COMMENT '过期时间,用于被动清理', PRIMARY KEY (`id`), UNIQUE KEY `uk_lock_key` (`lock_key`) -- 强制唯一索引! ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分布式锁表';关键点在于:所有锁key必须在系统启动时,通过脚本批量预插入到表中。例如,针对100万个用户账户,预先插入100万条lock_key为account_000001到account_1000000的记录。这样,FOR UPDATE永远只走唯一索引查找,锁粒度严格控制在单行。
3.2 第二层防御:获取锁——原子性与幂等性的双重保障
获取锁的SQL必须保证“要么成功拿到锁,要么明确失败”,不能有中间态。常见的错误写法是:
// ❌ 危险!两步操作,非原子 boolean exists = jdbcTemplate.queryForObject( "SELECT COUNT(1) FROM distributed_lock WHERE lock_key = ?", Integer.class, lockKey) > 0; if (!exists) { jdbcTemplate.update("INSERT INTO distributed_lock (lock_key, holder) VALUES (?, ?)", lockKey, holder); } jdbcTemplate.update("SELECT * FROM distributed_lock WHERE lock_key = ? FOR UPDATE", lockKey);这段代码在并发下会因“检查-创建”非原子,导致多线程同时插入,违反唯一索引约束,抛出DuplicateKeyException。正确姿势是利用MySQL的INSERT ... ON DUPLICATE KEY UPDATE特性,将“检查-创建-加锁”三步合一:
-- ✅ 原子操作:尝试插入,若已存在则更新holder和时间 INSERT INTO distributed_lock (lock_key, holder, acquire_time, expire_time) VALUES (?, ?, NOW(), DATE_ADD(NOW(), INTERVAL ? SECOND)) ON DUPLICATE KEY UPDATE holder = VALUES(holder), acquire_time = VALUES(acquire_time), expire_time = VALUES(expire_time); -- 紧接着执行FOR UPDATE(此时记录必然存在) SELECT * FROM distributed_lock WHERE lock_key = ? FOR UPDATE;Java代码中,我们封装了一个LockResult对象,包含isAcquired(是否成功获取)、lockId(锁记录ID)、holder(持有者)字段。每次获取锁后,业务代码必须校验isAcquired == true才执行核心逻辑,否则直接返回失败。
3.3 第三层防御:锁续约——让长事务不“饿死”
分布式锁常用于处理耗时业务(如生成复杂报表、调用外部慢接口)。如果锁过期时间设为30秒,而业务逻辑执行了45秒,锁会在中途被其他线程抢走,导致数据不一致。解决方案是“锁续约”(Lock Renewal):
- 在获取锁成功后,启动一个后台守护线程(Daemon Thread),每隔10秒(
expire_time/3)执行一次续约:UPDATE distributed_lock SET expire_time = DATE_ADD(NOW(), INTERVAL ? SECOND), holder = ? WHERE lock_key = ? AND holder = ?; - 续约必须校验
holder,防止误续其他线程的锁(CAS思想)。 - 守护线程需与业务线程强绑定:业务线程结束(正常完成或异常退出)时,必须中断守护线程,避免僵尸线程持续续约。
我们曾因忘记中断守护线程,导致一个已结束的报表任务,其守护线程仍在给锁续期,最终锁表被占满,新请求全部阻塞。后来在LockResult中增加了close()方法,强制在try-with-resources中调用,确保资源及时释放。
3.4 第四层防御:锁释放——优雅退出的最后一步
释放锁看似简单(DELETE FROM distributed_lock WHERE lock_key = ? AND holder = ?),但有两个隐藏雷区:
- 释放操作必须在同一个数据库连接中完成:
FOR UPDATE加的锁,只能由同一个事务释放。如果获取锁用Connection A,释放用Connection B,DELETE语句不会释放锁,只会删除记录,导致锁永久残留。 - 必须校验holder:防止A线程的锁被B线程误删。这是分布式锁安全的底线。
我们的释放逻辑强制要求传入lockId(获取锁时返回的主键ID)和holder,SQL如下:
DELETE FROM distributed_lock WHERE id = ? AND holder = ?;同时,在LockResult.close()中,我们加入双重校验:
- 先查
SELECT holder FROM distributed_lock WHERE id = ?,确认当前持有者仍是自己; - 再执行
DELETE。若第一步查不到或holder不匹配,则抛出IllegalLockReleaseException,拒绝释放。
3.5 第五层防御:异常熔断——当数据库失联时的保命机制
数据库连接池耗尽、主库宕机、网络分区……这些故障下,FOR UPDATE会直接抛出SQLException,如果业务代码不做特殊处理,整个服务会雪崩。我们的熔断策略分三级:
| 故障类型 | 熔断动作 | 触发条件 |
|---|---|---|
| 瞬时抖动 | 快速重试 | SQLException含"Connection refused"、"Timeout",重试2次,间隔100ms |
| 局部故障 | 降级为本地锁 | 检测到连续5次获取锁失败,切换为ConcurrentHashMap+ReentrantLock,仅限同JVM内互斥,标记告警 |
| 全局故障 | 熔断开关打开 | 1分钟内失败率>80%,自动打开熔断器,所有锁请求直接返回LockUnavailableException,业务走异步补偿 |
这个熔断器不是Spring Cloud CircuitBreaker那种通用组件,而是专为锁服务定制的轻量级状态机,状态变更写入本地AtomicBoolean,避免引入额外依赖。
3.6 第六层防御:监控告警——让锁的状态看得见
没有监控的分布式锁,就像蒙眼开车。我们接入了三类核心指标:
- 锁获取成功率:
success_count / (success_count + fail_count),阈值<99.5%触发告警。 - 平均等待时间:从执行
FOR UPDATE到返回结果的P95耗时,超过200ms告警(说明锁竞争激烈)。 - 锁持有时间分布:统计
acquire_time到commit的时间,绘制直方图。若大量锁持有时间集中在30s(过期时间),说明业务逻辑超时,需优化。
所有指标通过Micrometer上报到Prometheus,Grafana看板实时展示。最关键的是一张“锁热点图”:横轴是锁key的哈希分段(如account_000001-010000),纵轴是该区间内锁的平均等待次数。这张图能一眼定位出“明星用户”(如某个大V账号被高频访问),从而指导分库分表或缓存预热。
3.7 第七层防御:灾备清理——锁表“尸体”的定期扫荡
即使有完善的续约和释放机制,极端情况下(如JVM进程被kill -9)仍会产生“僵尸锁”——锁记录存在,但holder对应的进程早已消失。我们部署了一个独立的LockCleaner服务,每5分钟扫描一次:
SELECT id, lock_key, holder, expire_time FROM distributed_lock WHERE expire_time < NOW() - INTERVAL 10 MINUTE; -- 过期10分钟以上才清理清理前,会调用注册中心(如Nacos)查询holder标识的服务实例是否存活。只有确认实例已下线,才执行DELETE。这个服务本身也做了高可用:多个实例通过数据库SELECT ... FOR UPDATE争抢清理任务,确保同一时刻只有一个实例在工作。
4. 性能压测实录:当QPS从100飙到10000,锁的表现曲线
理论再完美,也要经受真实流量的拷问。我们用JMeter对锁服务进行了四轮压测,环境为:MySQL 5.7(双主+读写分离)、应用服务器4核8G*3、锁表数据量100万。结果颠覆了很多人的认知。
4.1 基准测试:单点MySQL的吞吐天花板
第一轮,不启用任何优化,纯FOR UPDATE获取锁:
| 并发线程数 | QPS | 平均响应时间(ms) | 错误率 | 关键观察 |
|---|---|---|---|---|
| 100 | 1200 | 83 | 0% | 表现平稳,锁等待几乎为0 |
| 500 | 2100 | 238 | 0.2% | 出现少量锁等待,平均等待12ms |
| 1000 | 2300 | 435 | 1.8% | P95响应时间飙升至1.2s,大量请求超时 |
结论:单点MySQL的FOR UPDATE锁获取,理论极限约2300 QPS。超过此值,InnoDB的锁队列开始积压,响应时间呈指数增长。这解释了为什么所有教程都强调“锁要细粒度”——不是为了性能,而是为了把锁竞争控制在可承受范围内。
4.2 优化一击:唯一索引+预热,性能翻倍
第二轮,应用“七层防御”中的第一、二层(唯一索引+预热初始化):
| 并发线程数 | QPS | 平均响应时间(ms) | 错误率 | 提升幅度 |
|---|---|---|---|---|
| 1000 | 4800 | 208 | 0% | QPS提升109%,错误率归零 |
| 2000 | 5200 | 385 | 0% | 稳定在5000+ QPS平台期 |
关键变化:SHOW PROFILE FOR QUERY N显示,Sending data阶段耗时从320ms降至45ms,因为唯一索引使FOR UPDATE的行查找从O(n)降到O(log n)。这证明:锁性能的瓶颈,80%不在锁机制本身,而在索引设计。
4.3 优化二击:读写分离+连接池调优,突破单库瓶颈
第三轮,引入读写分离:FOR UPDATE强制走主库,但锁表的SELECT(如监控查询)走从库。同时调整HikariCP连接池:
maximumPoolSize=50(原20)→ 主库连接更充裕connection-timeout=3000(原30000)→ 避免连接等待过久leak-detection-threshold=60000→ 及时发现连接泄漏
| 并发线程数 | QPS | 平均响应时间(ms) | 错误率 | 提升幅度 |
|---|---|---|---|---|
| 2000 | 7800 | 256 | 0% | QPS提升50%,响应更平稳 |
| 5000 | 8200 | 612 | 0.1% | 逼近主库CPU 90%红线 |
此时瓶颈已从锁机制转移到主库CPU和网络带宽。我们观察到SHOW PROCESSLIST中大量Sleep状态连接,说明应用层连接复用不足。
4.4 终极优化:分库分表+锁路由,支撑万级QPS
第四轮,对锁表进行水平分片。按lock_key哈希(如Math.abs(key.hashCode()) % 4)分到4个库,每个库8张表。应用层增加LockRouter组件,根据key自动路由到对应库表。
| 并发线程数 | QPS | 平均响应时间(ms) | 错误率 | 架构变化 |
|---|---|---|---|---|
| 5000 | 12500 | 410 | 0% | 单库压力下降75% |
| 10000 | 13800 | 720 | 0.05% | P95时间1.8s,仍在业务容忍内(支付场景要求<3s) |
压测中最大的发现是:当QPS超过8000,FOR UPDATE的性能曲线不再陡峭上升,而是呈现平缓的线性增长。这意味着,通过分库分表,FOR UPDATE锁可以线性扩展,不存在理论上的绝对瓶颈。某次大促中,我们实际承载了15200 QPS,锁获取成功率99.97%,平均耗时890ms,完全满足SLA。
5. 与Redis锁的硬刚对比:不是谁更好,而是谁更适合你的战场
社区里关于“数据库锁 vs Redis锁”的争论从未停歇。但作为经历过两者线上混战的架构师,我想说:脱离具体业务场景谈技术选型,都是耍流氓。下面这张对比表,不是为了分高下,而是帮你画出决策地图。
| 维度 | MySQL FOR UPDATE 锁 | Redis SETNX 锁 | 适用场景判断依据 |
|---|---|---|---|
| 一致性保证 | 强一致性(ACID),事务提交前锁绝不释放 | 最终一致性,网络分区时可能出现脑裂(两个客户端同时认为自己持有锁) | 金融、支付、库存扣减等零容忍数据错乱的场景,必须选MySQL |
| 性能上限 | 单库约2000-5000 QPS,分库分表后线性扩展 | 单Redis实例轻松支撑5w+ QPS,集群模式更高 | 日均PV<1000万、QPS<3000的中小系统,Redis更轻量;超大规模且对一致性要求极高,MySQL分片是正解 |
| 运维复杂度 | 依赖DBA,需监控锁等待、死锁、连接池;灾备需主从切换 | 运维简单,但需关注Redis持久化、哨兵/集群故障转移、Lua脚本原子性 | 团队DBA能力强、有成熟MySQL治理平台,选MySQL;若团队更熟悉Redis,且能接受最终一致性,Redis更省心 |
| 锁粒度控制 | 精确到行,但依赖索引设计,设计不当易升级为间隙锁/表锁 | 精确到key,无索引概念,粒度天然最细 | 业务锁key天然具备唯一性(如用户ID、订单号),且能建立唯一索引,MySQL优势明显;若锁key是动态拼接(如cache:user:123:profile),Redis更灵活 |
| 超时与续约 | 依赖innodb_lock_wait_timeout,续约需额外UPDATE SQL,易出错 | SET key value EX seconds NX原生支持超时,Lua脚本能安全续约 | 对锁超时精度要求极高(如必须精确到毫秒),Redis的EX参数更可控;MySQL的timeout是全局配置,不够灵活 |
| 故障恢复 | 主库宕机,锁服务立即不可用;从库只读,无法获取新锁 | Redis Sentinel模式下,故障转移约30秒;集群模式更快 | 系统SLA要求RTO<30秒,且能接受短暂锁不可用,Redis更优;若要求RTO=0(如核心支付通道),MySQL主库高可用(MHA/PXC)是刚需 |
| 开发心智负担 | 需深入理解InnoDB锁机制、索引原理、事务传播,学习成本高 | API简单(setIfAbsent),文档丰富,上手快 | 新团队或项目初期,优先用Redis快速验证;进入稳定期、追求极致可靠,再迁移到MySQL |
我们的真实迁移路径是:起步用Redis(快),中期用MySQL(稳),后期混合(智)。例如,在某电商平台,商品详情页的缓存更新用Redis锁(QPS高、容忍短暂不一致),而下单扣库存环节,强制切到MySQL锁(强一致性)。两个锁服务通过统一的LockFactory接口注入,业务代码无感知。
注意:不要迷信“银弹”。我们曾在一个内容推荐系统中,盲目将Redis锁全换成MySQL锁,结果因推荐算法耗时波动大(100ms~3s),导致锁持有时间不可控,
innodb_lock_wait_timeout设置成3秒太短(频繁超时),设成10秒又导致锁堆积。最后的解法是:对耗时稳定的业务用MySQL,对耗时波动大的业务,保留Redis锁,但增加“锁有效性校验”——在业务逻辑执行前,再次GET锁key确认自己仍是持有者。
6. 踩坑实录:那些让架构师彻夜难眠的锁相关故障
纸上谈兵千遍,不如一次真实故障的教训深刻。我把过去三年处理的5个典型锁故障,还原成排查链路,告诉你:当报警电话响起时,第一步该看什么。
6.1 故障一:凌晨2点,支付成功率暴跌至30%
现象:监控显示lock_acquire_success_rate从99.9%骤降至32%,大量支付请求卡在FOR UPDATE上,P95响应时间从200ms飙升至15s。
排查链路:
SHOW PROCESSLIST:发现大量State=Updating的线程,Info列显示UPDATE distributed_lock SET ... WHERE lock_key = 'account_XXXXXX'—— 锁续约SQL在执行。SELECT * FROM information_schema.INNODB_TRX:TRX_STATE=LOCK WAIT的事务数超200,TRX_MYSQL_THREAD_ID指向应用服务器PID。- 登录对应应用服务器,
jstack PID | grep -A 20 "FOR UPDATE":线程堆栈卡在HttpURLConnection.getInputStream(),正在调用一个外部风控接口。 - 查
distributed_lock表:expire_time字段大量为2023-10-01 02:00:00,全是整点时间,说明续约线程在固定时间点集中触发。
根因:风控服务在凌晨2点进行例行维护,所有请求超时。但我们的锁续约线程未设置超时,导致UPDATE语句在网络超时后,仍长时间占用数据库连接,阻塞了新的锁获取请求。
修复:
- 为所有外部HTTP调用增加
connectTimeout=3000、readTimeout=5000 - 在续约线程中增加
Thread.interrupt()保护,超时后主动中断 - 将续约频率从“固定间隔”改为“基于锁剩余时间的动态间隔”(剩余时间>10s,间隔5s;剩余时间<10s,间隔1s)
6.2 故障二:用户投诉“钱被扣了两次”
现象:财务对账发现,同一笔订单在10分钟内被扣款两次,t_payment表有两条相同order_id的记录。
排查链路:
- 查
payment_log:两条记录create_time相差8秒,status均为SUCCESS。 - 查
distributed_lock:对应lock_key=order_789012的记录,holder字段为payment-service:12345和payment-service:67890,两个不同PID。 SELECT * FROM information_schema.INNODB_LOCK_WAITS:空,说明没有死锁,是正常获取。- 深挖
payment-service:12345的日志:在FOR UPDATE后,执行INSERT INTO t_payment前,发生OutOfMemoryError,JVM崩溃。 payment-service:67890的日志:在FOR UPDATE后,未检测到payment-service:12345的锁持有(因进程已死,连接断开,锁被InnoDB自动释放),成功获取锁并扣款。
根因:JVM崩溃导致事务未提交,锁被自动释放,但业务逻辑未做幂等校验,第二个请求进来后,认为自己是首次处理。
修复:
- 所有扣款接口增加
幂等key(如order_id + timestamp),在INSERT t_payment前,先SELECT COUNT(1) FROM t_payment WHERE order_id = ? AND status = 'SUCCESS' - 在
LockResult中增加isHeldByCurrentProcess()方法,通过SELECT holder FROM distributed_lock WHERE id = ?实时校验,业务逻辑执行前必须调用
6.3 故障三:数据库CPU 100%,SHOW FULL PROCESSLIST全是Sleep
现象:MySQL CPU持续100%,SHOW PROCESSLIST显示数百个State=Sleep的连接,Time列显示连接已空闲3600秒以上。
排查链路:
SELECT * FROM information_schema.PROCESSLIST WHERE COMMAND != 'Sleep':无活跃连接,说明问题不在SQL执行,而在连接管理。SELECT * FROM information_schema.INNODB_TRX:空,说明没有长事务。- 检查应用服务器:
netstat -anp | grep :3306 | wc -l显示连接数达800+,远超HikariCP配置的maximumPoolSize=50。 jstack PID | grep "com.zaxxer.hikari":发现大量线程卡在HikariPool.getConnection(),等待连接。
根因:HikariCP的connection-timeout配置为30秒,但应用层@Transactional的timeout=60。当数据库响应慢时,连接在池中等待30秒超时后被丢弃,但业务线程仍在等待事务完成,导致连接池不断创建新连接,最终耗尽。
修复:
- 统一超时:
@Transactional(timeout=25)<HikariCP.connection-timeout=20000<innodb_lock_wait_timeout=15 - 增加连接池监控:
HikariCP.getMetricsTrackerFactory()上报activeConnections,idleConnections
6.4 故障四:分库后,部分锁key总是获取失败
现象:分库分表上线后,约5%的锁请求返回LockUnavailableException,但lock_key在对应库表中确实存在。
排查链路:
- 抓取失败的
lock_key,手动计算哈希:Math.abs("account_123456".hashCode()) % 4 = 2,应路由到ds_2库。 - 登录
ds_2库:SELECT * FROM distributed_lock_0 WHERE lock_key = 'account_123456',记录存在。 EXPLAIN SELECT * FROM distributed_lock_0 WHERE lock_key = 'account_123456' FOR UPDATE:type=ALL,未走索引!- 查
distributed_lock_0表结构:lock_key字段为varchar(128),但建表时未指定字符集,MySQL 5.7默认utf8,而应用传入的key是utf8mb4编码。
根因:字符集不匹配导致索引失效。utf8mb4的'account_123456'在utf8索引中无法匹配,FOR UPDATE退化为全表扫描,触发临键锁,最终因锁等待超时失败。
修复:
ALTER TABLE distributed_lock_0 CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci- 所有分表执行
ALTER TABLE ... MODIFY COLUMN lock_key VARCHAR(128) CHARACTER SET utf8mb4 NOT NULL
6.5 故障五:大促期间,锁表被“撑爆”
现象:大促高峰,distributed_lock表数据量从100万暴涨到5000万,磁盘使用率95%,INSERT操作变慢。
排查链路:
SELECT table_name, table_rows, data_length FROM information_schema.TABLES WHERE table_schema = 'xxx' AND table_name LIKE 'distributed_lock%':单表行数超600万。