news 2026/10/13 19:26:43

MySQL FOR UPDATE实现分布式锁的原理与高可用实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL FOR UPDATE实现分布式锁的原理与高可用实践

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的锁行为严格绑定事务生命周期:

  1. 加锁时机:SQL执行完成、返回结果集的瞬间,锁即生效。不是在Java代码connection.commit()时才加锁。
  2. 持有期间:锁持续存在于整个事务中,包括事务内的其他SQL操作、网络IO等待、业务逻辑计算耗时。
  3. 释放时机:仅在事务显式提交(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)
  • MySQLSET innodb_lock_wait_timeout=3(留2秒缓冲)
  • 同时在代码中捕获LockAcquisitionException,快速降级或重试

3. 从“能跑通”到“可运维”:分布式锁的七层防御体系

用FOR UPDATE写个Demo可能10分钟搞定,但要让它在日均亿级请求的系统里稳定运行三年,需要构建一套完整的防御体系。这不是堆砌技术,而是对每个环节脆弱性的预判和加固。我把这套体系称为“七层防御”,每一层都对应一个真实踩过的坑。

3.1 第一层防御:锁表设计——唯一性是生命线

锁表绝不能是简单的id BIGINT PK, key VARCHAR, expire_time DATETIME。它必须满足三个硬性条件:

  1. 锁key字段必须有唯一索引:这是FOR UPDATE精准加锁的前提。没有唯一索引,SELECT ... FOR UPDATE会退化为临键锁甚至表锁。
  2. 锁表必须是独立物理表:禁止复用业务表(如t_user)来存锁。业务表结构变更、历史数据归档、分库分表策略都会直接影响锁的稳定性。
  3. 锁记录必须预热初始化:不能依赖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 = ?),但有两个隐藏雷区:

  1. 释放操作必须在同一个数据库连接中完成:FOR UPDATE加的锁,只能由同一个事务释放。如果获取锁用Connection A,释放用Connection B,DELETE语句不会释放锁,只会删除记录,导致锁永久残留。
  2. 必须校验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 第六层防御:监控告警——让锁的状态看得见

没有监控的分布式锁,就像蒙眼开车。我们接入了三类核心指标:

  1. 锁获取成功率:success_count / (success_count + fail_count),阈值<99.5%触发告警。
  2. 平均等待时间:从执行FOR UPDATE到返回结果的P95耗时,超过200ms告警(说明锁竞争激烈)。
  3. 锁持有时间分布:统计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)错误率关键观察
1001200830%表现平稳,锁等待几乎为0
50021002380.2%出现少量锁等待,平均等待12ms
100023004351.8%P95响应时间飙升至1.2s,大量请求超时

结论:单点MySQL的FOR UPDATE锁获取,理论极限约2300 QPS。超过此值,InnoDB的锁队列开始积压,响应时间呈指数增长。这解释了为什么所有教程都强调“锁要细粒度”——不是为了性能,而是为了把锁竞争控制在可承受范围内。

4.2 优化一击:唯一索引+预热,性能翻倍

第二轮,应用“七层防御”中的第一、二层(唯一索引+预热初始化):

并发线程数QPS平均响应时间(ms)错误率提升幅度
100048002080%QPS提升109%,错误率归零
200052003850%稳定在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)错误率提升幅度
200078002560%QPS提升50%,响应更平稳
500082006120.1%逼近主库CPU 90%红线

此时瓶颈已从锁机制转移到主库CPU和网络带宽。我们观察到SHOW PROCESSLIST中大量Sleep状态连接,说明应用层连接复用不足。

4.4 终极优化:分库分表+锁路由,支撑万级QPS

第四轮,对锁表进行水平分片。按lock_key哈希(如Math.abs(key.hashCode()) % 4)分到4个库,每个库8张表。应用层增加LockRouter组件,根据key自动路由到对应库表。

并发线程数QPS平均响应时间(ms)错误率架构变化
5000125004100%单库压力下降75%
10000138007200.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。

排查链路:

  1. SHOW PROCESSLIST:发现大量State=Updating的线程,Info列显示UPDATE distributed_lock SET ... WHERE lock_key = 'account_XXXXXX'—— 锁续约SQL在执行。
  2. SELECT * FROM information_schema.INNODB_TRX:TRX_STATE=LOCK WAIT的事务数超200,TRX_MYSQL_THREAD_ID指向应用服务器PID。
  3. 登录对应应用服务器,jstack PID | grep -A 20 "FOR UPDATE":线程堆栈卡在HttpURLConnection.getInputStream(),正在调用一个外部风控接口。
  4. 查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的记录。

排查链路:

  1. 查payment_log:两条记录create_time相差8秒,status均为SUCCESS。
  2. 查distributed_lock:对应lock_key=order_789012的记录,holder字段为payment-service:12345和payment-service:67890,两个不同PID。
  3. SELECT * FROM information_schema.INNODB_LOCK_WAITS:空,说明没有死锁,是正常获取。
  4. 深挖payment-service:12345的日志:在FOR UPDATE后,执行INSERT INTO t_payment前,发生OutOfMemoryError,JVM崩溃。
  5. 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秒以上。

排查链路:

  1. SELECT * FROM information_schema.PROCESSLIST WHERE COMMAND != 'Sleep':无活跃连接,说明问题不在SQL执行,而在连接管理。
  2. SELECT * FROM information_schema.INNODB_TRX:空,说明没有长事务。
  3. 检查应用服务器:netstat -anp | grep :3306 | wc -l显示连接数达800+,远超HikariCP配置的maximumPoolSize=50。
  4. 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在对应库表中确实存在。

排查链路:

  1. 抓取失败的lock_key,手动计算哈希:Math.abs("account_123456".hashCode()) % 4 = 2,应路由到ds_2库。
  2. 登录ds_2库:SELECT * FROM distributed_lock_0 WHERE lock_key = 'account_123456',记录存在。
  3. EXPLAIN SELECT * FROM distributed_lock_0 WHERE lock_key = 'account_123456' FOR UPDATE:type=ALL,未走索引!
  4. 查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操作变慢。

排查链路:

  1. SELECT table_name, table_rows, data_length FROM information_schema.TABLES WHERE table_schema = 'xxx' AND table_name LIKE 'distributed_lock%':单表行数超600万。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 19:23:28

STM32驱动AD9959四通道DDS:从硬件连接到寄存器配置与调试避坑

简介&#xff1a;这份资源是面向嵌入式开发者的AD9959与STM32F103联合工程代码包&#xff0c;聚焦DDS直接数字频率合成应用&#xff0c;适合已具备一定单片机基础、希望快速上手高频信号源开发的读者。工程基于正点原子STM32mini开发板实测通过&#xff0c;涵盖AD9959驱动、SPI…

作者头像 李华
网站建设 2026/10/13 19:21:48

2025计量设备招标腰斩背后:行业从扩量转向提质竞争

2025年计量设备招标计划一披露&#xff0c;不少厂商的销售群气氛肉眼可见地变了。电网系统今年的集中招标规模大幅缩水&#xff0c;业内直接用了“腰斩”这个词。上年同期还能看到二十多个标包、几百万只表计的采购安排&#xff0c;今年直接砍到一半左右&#xff0c;有些区域甚…

作者头像 李华
网站建设 2026/10/13 19:17:17

bin点云转PCD全攻略:字节布局、批量转换与避坑指南

简介&#xff1a;面向3D点云处理开发者&#xff0c;这份资料专门解决激光雷达原始bin文件与PCL标准PCD文件之间的互操作问题。bin格式紧凑读写快&#xff0c;但不含元数据&#xff0c;PCD则支持颜色、法线、头信息等丰富属性&#xff0c;两者转换是点云预处理中的常见需求&…

作者头像 李华
网站建设 2026/10/13 19:14:36

STM32 LIN从节点通信失败排查:帧结构、校验和与PID配置详解

简介&#xff1a;这份资源面向嵌入式开发初学者与汽车电子方向工程师&#xff0c;提供在STM32F103C8上实现LIN总线主机与从机通信的完整工程代码。LIN作为低成本低速串行协议&#xff0c;常用于车身传感器与执行器互联&#xff0c;资源围绕USART的LIN模式配置、时钟同步中断、帧…

作者头像 李华
网站建设 2026/10/13 19:14:10

语义分割环境搭建:PyTorch CPU与GPU版本安装及验证全攻略

1. 系列前情回顾与本期核心目标语义分割学习系列写到第四篇&#xff0c;前面几篇我分别梳理了语义分割的基本概念、常见数据集的组织方式、以及几个主流分割模型的结构差异。按照正常的学习路线&#xff0c;理论铺垫到这一步&#xff0c;接下来就该动手了。动手的第一步就是装环…

作者头像 李华