直接以数据库分布式锁为切口,聊聊高并发场景下的并发控制。这篇文章会拆解开“为什么单机锁不够用”“数据库分布式锁有哪些实现思路”“哪种方案最适合你的场景”,再带一组完整到能抄作业的实操代码和踩坑实录。
1. 为什么要做分布式锁:单机锁的边界与并发控制的困境
先从一个特别常见的线上事故说起。某天凌晨,定时任务平台突然报警,同一批次的数据被重复处理了两遍。排查原因的时候发现,在单机部署下一切正常,但后来应用扩到了三台实例,每台实例都会在准点触发同一批定时任务。三台机器同时去扫表、同时去调下游接口、同时去做状态流转,结果就是数据被重复处理、订单状态互相覆盖、对账不平。
这类问题的本质,是 JVM 内部的synchronized或者ReentrantLock只能锁住当前进程内的线程,它管不了其他机器上的线程。多实例部署之后,并发控制不再是“多线程争一把锁”,而是“多进程甚至多机器争一把全局锁”。这就是分布式锁出现的根本原因:在多个独立进程之间,建立一个统一的互斥机制。
分布式锁并没有想象中那么神秘,它本质上就是一个“所有人都认可的第三方仲裁者”。节点A说“我要锁”,节点B也说“我要锁”,仲裁者只会把锁授予其中一个,另一个只能等待或者失败。这个仲裁者可以是数据库、Redis、ZooKeeper,也可以是 etcd,各有各的取舍。而用数据库来做这个仲裁者,是技术上最直接、运维成本最低的路径,理由很现实:绝大多数业务系统本身就有数据库,不用额外引进中间件,也不存在高可用运维的负担。
但数据库分布式锁也是争议最大的一条路。新手听着觉得简单,老手却常常摇头,因为稍不注意就会踩进坑里:连接池被占满、锁超时后业务还在跑、锁自动续期没人管、删锁误删了别人的锁……这些问题会直接反噬业务可靠性。这篇文章不会绕开这些坑,而是把每条坑都摊开讲清楚,顺便给出对应的规避手段。
2. 数据库分布式锁的核心设计思路与选型
2.1 三种主流实现方式:唯一索引、悲观锁、乐观锁
用数据库做分布式锁,不是只有一种写法,根据不同场景可以分成三条路线。
第一条路线,基于唯一索引实现互斥锁。核心原理是利用数据库唯一约束天然具备“只能插入一条”的特性。多个节点同时执行insert into lock_table(resource, owner, expire_time) values(...)时,数据库层面只会允许一条插入成功,其余全部报DuplicateEntry错误。插入成功的节点认为是拿到了锁,执行完业务后删除这条记录释放锁。这种方案实现最简单、性能也不错,但需要预先建一张锁表,还要自己处理过期时间。
第二条路线,基于悲观锁实现行级互斥。利用的是select ... for update的行级锁机制。多个事务同时去锁同一行记录时,后到的事务会被阻塞,直到持锁事务提交或回滚。这种方式的优点是锁的粒度由数据库控制,不需要额外维护过期时间,但缺点也很明显:必须依赖事务,长事务会把连接池拖垮,而且 for update 只能锁已存在的行,如果业务里压根没有对应记录还得先插入。
第三条路线,基于乐观锁实现版本控制。核心在于update ... set version = version + 1 where version = ?,利用受影响行数判断是否抢锁成功。适合短平快的更新场景,不存在锁等待和死锁,但冲突频繁时 CPU 空转和重试次数会飙升。
从并发控制的目标来看,三条路线分别对应不同场景:唯一索引适合低频互斥任务,悲观锁适合并发量中等但要求强互斥的流程,乐观锁适合写多读少的资源更新。
2.2 方案选型背后的关键权衡
选方案不能光看性能,还要看团队对基础设施的掌控力。
用数据库当锁服务,最大的优势是“顺手”。不需要引入 Redis,不需要新增一套运维规范,锁表和数据表放在同一个库,事务边界一致,业务上更容易做一致性保证。很多金融、交易类系统之所以坚持数据库分布式锁,恰恰就是因为“同一个数据库事务里既做判断又做更新”,天然规避了 Redis 锁常见的“先加锁后操作缓存再更新数据库”这种跨存储系统的协调难题。
数据库方案最大的劣势是性能和扩展性。在高并发下,数据库行锁竞争会放大延迟,连接池会成为瓶颈,锁表的数据量膨胀后还需要清理策略。所以数据库分布式锁更适合:并发量控制在每秒几百以内的互斥场景,或者对一致性要求极高、可以接受一定延迟的业务。
拿我参与过的一个模拟项目 X 来说,系统需要在多台后端实例上保证同一个订单只能被一个回调线程处理,当时并发量大概每秒两百左右,团队既没有独立 Redis 集群的运维底气,又希望加锁和订单更新能在同一个事务里完成,最终选的就是“唯一索引 + 事务”的数据库锁方案。实测下来整个链路很稳,问题基本集中在连接池配置和异常分支处理上。
2.3 为什么说“轮询 + 唯一索引”是被低估的组合
很多文章喜欢直接踩数据库锁,说它性能烂、坑多。但这个问题要看场景。对于低频的定时任务防重、分布式节点选主、消息幂等这类操作,真正的并发量其实很低,但核心诉求是“绝对不能被抢跑”,这种情况下唯一索引锁反而是最可靠的选择。
原因有三层。第一层,唯一索引是数据库内核级别的约束,不像 Redis 锁依赖网络、也不像文件锁依赖共享磁盘,数据库只要能写入成功,约束就生效。第二层,唯一索引锁天然具备持久化能力,不像缓存锁那样可能因为节点宕机而丢失锁状态。第三层,唯一索引锁支持通过ON DUPLICATE KEY UPDATE做续期和全局状态更新,配合“插入失败即等待重试”的轮询模型,整个锁的复杂度可以控制得非常小。
我见过不少团队一上来就上 ZooKeeper 做锁,结果半年后发现业务量根本不需要这么重的组件。轻量级业务用轻量级锁,这也是方案选型的重要原则。
3. 核心实现细节:直接可用的数据库分布式锁实战
3.1 建锁表时容易被忽略的四个字段
既然要用数据库,第一步必然是建表。千万不要只建一个resource字段就完事,一套能用的锁表至少要有以下结构:
CREATE TABLE `distributed_lock` ( `id` bigint NOT NULL AUTO_INCREMENT, `resource` varchar(128) NOT NULL COMMENT '锁定的资源标识,如订单号、任务批次号', `owner` varchar(64) NOT NULL COMMENT '持有锁的节点标识,用 UUID 或 IP+线程号', `expire_time` datetime NOT NULL COMMENT '锁过期时间,防止持有者宕机后锁永不释放', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_resource` (`resource`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分布式锁表';四个关键字段各有各的职责。
resource:锁定资源标识,同一个资源同时只能有一把锁,所以必须加唯一索引。owner:锁持有者标识。为什么不能只靠 resource 判断归属?因为释放锁的时候必须确认“这把锁是我的,不是别人的”,否则会出现在 A 超时后,B 拿到了锁,A 反而把 B 的锁删掉的误删问题。expire_time:兜底字段。如果持有锁的节点在业务执行途中宕机了,数据库里的记录还在,其他节点永远拿不到锁,这就会出现死锁。有了过期时间,后续节点可以通过时间判断锁是否已经失效。update_time:虽然业务上不直接依赖它,但在排查问题和写清理脚本时非常有用。
注意:这里必须声明
ENGINE=InnoDB。MyISAM 不支持行锁和事务,分布式锁必须构建在支持事务的存储引擎上。
3.2 加锁、解锁、续期的完整实现
接下来写最核心的三个操作:加锁、解锁、续期。以 Java 为例,假定使用的是 MyBatis-Plus 或原生 MyBatis 操作库,思路是通用的,换成其他语言同样可以落地。
加锁操作(唯一索引插入法)
public boolean tryLock(String resource, String owner, int expireSeconds) { try { LocalDateTime expireTime = LocalDateTime.now().plusSeconds(expireSeconds); lockMapper.insert(resource, owner, expireTime); return true; } catch (DuplicateKeyException e) { // 唯一索引冲突,表示锁已存在,需要检查是否过期 LockRecord record = lockMapper.selectByResource(resource); if (record != null && record.getExpireTime().isBefore(LocalDateTime.now())) { // 锁已过期,尝试删除后再竞争 int deleted = lockMapper.deleteExpired(resource, record.getExpireTime()); if (deleted > 0) { return tryLock(resource, owner, expireSeconds); } } return false; } }这是加锁的一个严谨版本。首先尝试插入新记录,如果插入成功,说明此前没有其他节点持有锁。如果插入失败,说明锁被别人持有了,这时不能直接放弃,还得看一眼锁是否已经过期。如果锁过期了,就可以执行一次“安全删除”:只删掉那个过期时间等于当前记录的旧锁,避免误删新锁。
注意这里有一个非常重要的细节,删除旧锁时,条件必须带上过期时间,而不是只写where resource = ?。两个节点同时发现锁过期,同时尝试删除,如果没有时间条件,A 删掉了旧锁,B 也跟着删,然后 A 插入新锁成功,B 插入新锁失败,逻辑还算能接受。但更危险的情况是:A 删掉旧锁并拿到了新锁,B 此时也执行了同样的删除,然后把 A 的新锁给删了。加上过期时间条件后,B 的删除会因为过期时间不匹配而影响行数为 0,这个问题就被彻底规避了。
解锁操作(保证 only owner 可删)
public boolean unlock(String resource, String owner) { int rows = lockMapper.deleteByResourceAndOwner(resource, owner); return rows > 0; }解锁必须携带 owner,这就是前面说的防误删。试想这个场景:节点 A 拿到锁后,业务执行超过了预期时间,锁因为超时被节点 B 重新获取。此时 A 终于执行完了,它去释放锁,如果不带 owner 条件,就会把 B 刚拿到的那把新锁也删掉,C 又乘虚而入,整个互斥就崩了。带上 owner 条件后,A 的删除影响行数为 0,B 的锁完好无损。
续期操作(防止业务未执行完锁先过期)
public boolean renewLock(String resource, String owner, int expireSeconds) { LocalDateTime newExpireTime = LocalDateTime.now().plusSeconds(expireSeconds); int rows = lockMapper.updateExpireTime(resource, owner, newExpireTime); return rows > 0; }续期的本质是“只有持有者才能延长自己的锁”。这个动作可以在业务执行到一半、或者在一个定时调度器里每 5 秒执行一次。续期操作同样必须携带 owner,否则可能出现 A 正在续期,锁已经被 B 抢走,A 反手又把新锁的过期时间改掉的问题。
3.3 事务边界:为什么锁操作必须和业务操作放同一个事务
数据库分布式锁一个容易被误解的地方在于事务。很多同学把加锁当成一个独立方法,解锁当成另一个独立方法,中间夹着业务逻辑,结果出现“锁加了但没提交”“业务提交了但锁还在”的错位问题。
正确姿势是:在同一个事务中,先加锁,执行业务操作,最后提交事务,由事务提交后的钩子统一释放锁,或者依赖数据库事务自身的提交来自动释放行锁。
以悲观锁方案为例,下面这段伪代码是标准模板:
@Transactional(rollbackFor = Exception.class) public void processWithPessimisticLock(String orderId) { // 1. 锁定一行数据,其他事务在这个行上的 for update 会阻塞 Order order = orderMapper.selectByIdForUpdate(orderId); if (order == null) { throw new BizException("订单不存在"); } // 2. 业务逻辑:判断状态、更新数据 order.setStatus(Processed); orderMapper.updateById(order); // 3. 整个方法结束后,事务提交,行锁自动释放 }selectByIdForUpdate是触发行锁的关键。后续其他事务执行同样的for update时,会被数据库阻塞等待,直到当前事务提交或回滚。这个模式的好处是不需要手动解锁,事务结束锁就释放,逻辑上是天然一致的。
但这个方案有一个非常隐蔽的坑:如果你的业务逻辑里有对其他表数据的修改,或者调用了外部 HTTP 接口,事务的持有时间会被拉长,行锁长时间不释放会让数据库连接池迅速耗尽。我的建议是:凡是进入这个事务的业务逻辑,必须只做数据库操作,一切外部调用统统挪到事务提交之后,通过发送领域事件或者监听事务提交钩子去做。
3.4 轮询还是自旋:获取锁失败后怎么办
获取不到锁时,优雅的做法是有限次数的轮询重试。不要一次性失败就放弃,也不建议无脑死循环。推荐使用带随机退避的轮询策略。
public boolean acquireWithRetry(String resource, String owner, int maxRetryTimes, int baseIntervalMs) { for (int i = 0; i < maxRetryTimes; i++) { if (tryLock(resource, owner, 30)) { return true; } try { int randomBackoff = baseIntervalMs + ThreadLocalRandom.current().nextInt(200); Thread.sleep(randomBackoff); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } return false; }随机退避不是为了好看,而是为了避免多个节点在同一个时间点集中重试产生“惊群效应”。尤其是定时任务场景,如果三台机器同时醒来、同时抢锁、同时失败、再同时重试,那数据库会感受到一波非常规整的并发冲击。引入随机性之后,重试时间点被打散,压力会平缓很多。
4. 数据库分布式锁在不同并发场景中的落地实践
4.1 定时任务防重:多实例下最典型的需求
最经典的分布式锁场景,就是定时任务在多实例下的防重。
想象一下你的应用在 k8s 里跑了三个副本,定时任务框架会在每天凌晨两点触发“日结任务”。如果没有任何锁,这台机器扫到的数据可能会被另一台机器重复处理。用数据库分布式锁解决起来非常干净:
public void executeDailyBatchJob() { String lockResource = "daily_batch_job"; String owner = UUID.randomUUID().toString(); boolean locked = lockService.tryLock(lockResource, owner, 1800); if (!locked) { log.info("另一节点正在执行日结任务,当前节点跳过"); return; } try { doDailyBatch(); } finally { lockService.unlock(lockResource, owner); } }这个模式有几个值得细说的点。第一,锁的过期时间要明显大于任务的最大执行时长。比如任务最长执行 20 分钟,那就设 30 分钟甚至 40 分钟,宁多勿少。如果设得比执行时长短,会出现任务还在跑锁却被别人拿走的严重问题。第二,跳过而不是失败。拿不到锁说明别人正在做正确的处理,直接跳过是最优雅的,不要抛异常然后触发告警。第三,finally 里一定要释放锁。任何异常都不能阻止锁的释放,否则这次失败会锁死后续所有调度。
定时任务防重对锁的可用性要求其实不高,短时间的锁服务抖动完全可以通过下个周期补偿。但对锁的互斥性要求极高,唯一的索引恰好给了最强的保证。
4.2 库存扣减与秒杀:乐观锁和悲观锁的选择
很多同学一听到“库存扣减”就想到分布式锁,但其实库存扣减的核心是“原子更新”,不一定要用锁表,可以用数据库自身的原子操作完成。
一个最简单的无锁库存扣减是这样的:
UPDATE goods_stock SET stock = stock - #{count} WHERE goods_id = #{goodsId} AND stock >= #{count}这条 SQL 本身就具备并发安全:数据库的行锁保证了同一时间只有一个更新请求能成功,stock >= #{count}条件保证了不会超卖。如果受影响行数为 0,说明库存不足或并发冲突,业务层可以选择重试或返回失败。
那什么时候需要额外的分布式锁?答案是:当库存扣减之后,还要跟着做一系列非原子操作,比如创建订单、锁定优惠券、写流水、通知仓库,这些操作必须保证“同一个用户的多次请求不会同时处理”。此时就需要在事务开始前抢到分布式锁,然后再执行扣减和后续逻辑。
悲观锁方案适合库存量少、冲突概率高的场景,比如限量发售 100 件的商品,同时一万个人抢,用乐观锁会导致大量重试。乐观锁方案适合库存充足、冲突较少的场景,比如常规商品的单量购买,直接用update ... where stock >= count就能扛住很高的并发。
4.3 幂等控制:消息队列消费场景的兜底
分布式锁在消息队列消费场景里还有一个妙用,那就是幂等控制。消息中间件通常只保证“至少一次”投递,重复消息是家常便饭。在消费端加一个数据库分布式锁,可以做到同一笔业务消息只被处理一次。
public void consumeMessage(Message msg) { String bizId = msg.getBizId(); String lockResource = "biz:" + bizId; String owner = UUID.randomUUID().toString(); if (!lockService.tryLock(lockResource, owner, 60)) { log.warn("重复消息,直接丢弃: {}", bizId); return; } try { // 核心业务逻辑 processBiz(msg); } finally { lockService.unlock(lockResource, owner); } }在这个模式里,锁表同时充当了"消息去重表"的角色。重复消息来临时,第二次插入会撞唯一键,直接判定为“正在处理或者已处理过”,不会重复下单、不会重复发奖。
这套方案相比 Redis 去重有一个显著优势:锁表记录是持久化的,即使消费端服务重启,已经处理过的消息 ID 依然存在于表中,不会被内存清空影响。缺点则是锁表会持续增长,需要配合定期清理策略。
4.4 分布式节点选主:让集群只有一个“老大”
还有一种场景适合用数据库锁:多实例组成的集群中,同时只能有一个节点在干“领导”的活,比如消费某些数据源、执行某些资源密集型任务。
思路是这样的:每个节点启动时都尝试往锁表里插入一条自己的占位记录,如果插入成功,该节点成为主节点;如果插入失败,说明已有主节点存在,当前节点进入待命状态。主节点需要周期性续期,如果续期失败,说明主节点可能宕机了,待命节点就可以执行“抢占”。
这种选主方案在中小规模集群里非常实用,虽然不是官方推荐的 ZooKeeper 那种强一致方案,但胜在足够简单、足够好理解。而且数据库的持久化能力刚好保证了“主节点宕机后锁不会凭空消失,而是靠过期时间触发重新选举”,这是很多分布式系统都需要的兜底逻辑。
5. 集成 MyBatis 的完整实战:从建表到测试的复现路径
5.1 依赖与工程结构准备
为了让你看完就能复现,这里给出一个最小可运行的工程结构。假设项目基于 Spring Boot 2.7 + MyBatis-Plus 3.5 + MySQL 8.0,用 Maven 管理依赖。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>工程结构上,我习惯把锁服务独立成一个包,不跟业务代码混在一起,这样排查和维护都更清爽。
com.example.dlock ├── config │ └── LockProperties.java ├── entity │ └── DistributedLock.java ├── mapper │ └── DistributedLockMapper.java ├── service │ ├── DistributedLockService.java │ └── DistributedLockServiceImpl.java └── controller └── LockTestController.java5.2 Mapper 层的 SQL 方法定义
在DistributedLockMapper中定义四个核心方法:
public interface DistributedLockMapper extends BaseMapper<DistributedLock> { @Insert("INSERT INTO distributed_lock(resource, owner, expire_time) " + "VALUES(#{resource}, #{owner}, #{expireTime})") int insertLock(@Param("resource") String resource, @Param("owner") String owner, @Param("expireTime") LocalDateTime expireTime); @Update("UPDATE distributed_lock SET expire_time = #{newExpireTime} " + "WHERE resource = #{resource} AND owner = #{owner}") int updateExpireTime(@Param("resource") String resource, @Param("owner") String owner, @Param("newExpireTime") LocalDateTime newExpireTime); @Delete("DELETE FROM distributed_lock WHERE resource = #{resource} AND owner = #{owner}") int deleteByOwner(@Param("resource") String resource, @Param("owner") String owner); @Delete("DELETE FROM distributed_lock WHERE resource = #{resource} AND expire_time = #{expireTime}") int deleteExpired(@Param("resource") String resource, @Param("expireTime") LocalDateTime expireTime); }注意,insertLock这里不需要返回主键,只要插入时触发唯一键冲突,让异常在 service 层被捕获即可。
5.3 Service 层封装:加锁 - 执行 - 释放的标准模板
@Service public class DistributedLockServiceImpl implements DistributedLockService { private static final Logger log = LoggerFactory.getLogger(DistributedLockServiceImpl.class); @Autowired private DistributedLockMapper lockMapper; @Override public boolean tryLock(String resource, String owner, int expireSeconds) { LocalDateTime expireTime = LocalDateTime.now().plusSeconds(expireSeconds); try { lockMapper.insertLock(resource, owner, expireTime); return true; } catch (DuplicateKeyException e) { DistributedLock existing = lockMapper.selectByResource(resource); // 锁已过期,尝试抢占 if (existing != null && existing.getExpireTime().isBefore(LocalDateTime.now())) { int deleted = lockMapper.deleteExpired(resource, existing.getExpireTime()); if (deleted > 0) { return tryLock(resource, owner, expireSeconds); } } return false; } } @Override public boolean unlock(String resource, String owner) { int deleted = lockMapper.deleteByOwner(resource, owner); if (deleted > 0) { log.info("锁释放成功: resource={}, owner={}", resource, owner); return true; } log.warn("锁释放失败,可能已被其他节点接管: resource={}, owner={}", resource, owner); return false; } @Override public <T> T executeWithLock(String resource, int expireSeconds, Supplier<T> supplier) { String owner = UUID.randomUUID().toString(); boolean locked = tryLock(resource, owner, expireSeconds); if (!locked) { throw new LockAcquireException("获取分布式锁失败: " + resource); } try { return supplier.get(); } finally { unlock(resource, owner); } } }executeWithLock这个泛型方法是整块封装的精髓。业务方不需要关心锁获取和释放的细节,只要把核心逻辑塞进Supplier<T>即可。这个封装避免了满屏 try-finally,也让锁的“取、用、释”生命周期变得集中可控。
不过这里要提醒一点:如果你的业务执行时长可能超过锁的过期时间,就不能只用静态命名空间,而是要在executeWithLock内部起一个守护线程周期性调用续期方法,或者使用可续期锁的原生组件。
5.4 并发测试:如何验证锁真的生效
写完实现之后,怎么验证锁是对的?一种简单有效的测试手段是:人为让多个线程同时竞争同一个资源,记录“获取锁成功”的次数,再验证成功次数与预期一致。
@Test public void testConcurrentLock() throws InterruptedException { String resource = "test_resource"; int threadCount = 20; CountDownLatch readyLatch = new CountDownLatch(threadCount); CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch doneLatch = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(0); for (int i = 0; i < threadCount; i++) { new Thread(() -> { readyLatch.countDown(); try { startLatch.await(); boolean locked = lockService.tryLock(resource, UUID.randomUUID().toString(), 5); if (locked) { successCount.incrementAndGet(); Thread.sleep(50); lockService.unlock(resource, owner); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { doneLatch.countDown(); } }).start(); } readyLatch.await(); startLatch.countDown(); doneLatch.await(); assertEquals(1, successCount.get()); }正确的锁行为应该是:虽然 20 个线程同时竞争,但最终获得锁并成功执行的线程数一定是 1,而不是 20。跑到这个断言,基本就说明锁的核心互斥逻辑通了。
注意:测试和正式环境的唯一约束都必须存在。如果测试时没有唯一索引,这个用例会稳定失败,Unlike Business Code 那样一切异常都被吞掉。
6. 常见坑与排查手册:从连接池到死锁的完整避障地图
6.1 坑一:连接池耗尽与长事务
症状表现:系统在并发高峰时段频繁出现Connection is not available, request timed out,监控面板里活跃连接数长期处于高位。
排查思路:分布式锁里的行锁必须靠事务边界释放。如果加锁方法和业务方法没有共用同一个事务,连接要等到方法结束才归还连接池。更常见的问题是tryLock方法自己开了事务,业务操作又开了新事务,连接池里的连接数就被无效事务拖满。
解决建议:统一通过executeWithLock方式执行,确保锁的获取、业务操作、锁的释放在同一个事务内或者至少是同一个连接的生命周期内。另外,把连接池最大连接数设置在 50 以上,根据实际压测及时调整。
6.2 坑二:锁误删与过期时间错乱
症状表现:A 节点拿锁后在执行业务,B 节点却同时收到了锁获取成功的反馈。查看日志发现 B 在解锁时把 A 的锁删掉了。
排查思路:百分之百是解锁或者续期时没有带 owner 条件,导致删除了别人的锁。解决方式很简单,所有写操作必须带上owner字段作为条件。
额外提醒:续期逻辑同样要带 owner。假设有一个定时续期线程在跑,它拿到的对象是 A 的锁记录,但此刻锁已经被 B 抢占,如果不带 owner 条件,它直接update expire_time就会把 B 的锁延到过期时间,造成全局状态错乱。
6.3 坑三:宕机后锁永不释放
症状表现:某个资源突然被锁死,业务查询数据库里锁表的记录还在,且expire_time远远早于当前时间,但没有任何节点在拿到锁。
排查思路:原因有两种可能。一是加锁时没有设置过期时间,锁表是死锁状态;二是过期时间设置得太长,后续节点判断锁未过期所以一直不敢抢占。正确做法是:每把锁必须有合理的过期时间,并且在 tryLock 判断逻辑中补上“过期即抢占”的分支。
6.4 坑四:死锁与锁顺序不一致
症状表现:多条业务操作同时锁定多个资源,比如同时处理订单和库存,出现了数据库死锁报错。
排查思路:多资源加锁时,必须保持全局一致的加锁顺序。比如所有地方都先锁订单后锁库存,而不允许有的线程先锁库存后锁订单,否则就可能出现 A 拿订单锁等库存锁,B 拿库存锁等订单锁的循环等待。
解决建议:对一个事务里涉及多个锁表记录的加锁操作,先对资源名称排序,再按顺序依次加锁,这是最有效也最省心的方案。
6.5 坑五:滥用数据库锁导致整体性能劣化
症状表现:核心业务因为数据库锁的性能损耗,整体吞吐量下降了 20% 以上,连不带锁的读操作也变慢了。
排查思路:关键在于区分“必须锁”和“不需要锁”。库存扣减这类可以用原子 SQL 完成的操作,就不要引入外部锁;只有那些需要跨多步操作完成一致性的场景,才值得用分布式锁。如果一个业务既不需要互斥也不需要排队,完全可以去掉锁,用乐观锁或者异步消息重试替代。
6.6 问题速查表
| 现象 | 可能原因 | 推荐动作 |
|---|---|---|
| 连接池耗尽 | 长事务 / 事务边界错乱 | 锁与业务放入同一事务,必要时事务外释放 |
| 锁被误删 | 解锁未带 owner | 解锁条件增加 owner 匹配 |
| 锁自动失效后业务还在执行 | 过期时间设置过短 | 适当延长过期时间或增加续期机制 |
| 某资源长时间不可用 | 节点宕机且无过期兜底 | 所有锁必须设置 expire_time |
| 数据库死锁 | 多资源加锁顺序不一致 | 对资源名称排序后统一加锁顺序 |
| 性能严重下降 | 不必要的长锁 / 频繁重试 | 用原子 SQL 替代部分锁场景,重试增加退避 |
7. 与其他分布式锁方案的对比与延伸
7.1 数据库锁 vs Redis 锁 vs ZooKeeper 锁
聊分布式锁,永远绕不开这三个方案对比。直接给结论:没有绝对好坏,只有场景适配。
Redis 锁:性能最高,加锁释放都是毫秒级,适合高并发、短平快的互斥场景。缺点在于 Redis 是 AP 模型,极端情况下可能出现主从切换后锁丢失,传统SETNX方案需要严谨处理过期时间和续期问题。
ZooKeeper 锁:一致性最强,基于临时顺序节点能完美实现公平锁、可重入锁、自动释放,适合强一致、低并发的核心链路。代价是运维成本高,需要维护一套 ZooKeeper 集群,网络交互也相对较重。
数据库锁:一致性较强,靠唯一的索引和事务保证互斥,操作简单、无需额外中间件,适合低频到中频、不要极致性能的业务。缺点是吞吐量有天然瓶颈,不适合高并发场景。
用一张表总结:
| 维度 | 数据库锁 | Redis 锁 | ZooKeeper 锁 |
|---|---|---|---|
| 学习成本 | 低 | 中 | 较高 |
| 运维成本 | 无额外成本 | 依赖 Redis 集群 | 依赖 ZK 集群 |
| 性能 | 百级 TPS | 万级 TPS | 千级 TPS |
| 一致性 | 较强 | 较弱 | 强 |
| 自动释放 | 依赖过期时间 | 依赖过期时间 | 临时节点自动删除 |
| 适用场景 | 定时任务、幂等、低频互斥 | 缓存击穿、秒杀、短事务 | 核心选主、强一致性协调 |
7.2 混合方案:数据库锁 + 本地缓存标记
实际工程里有一个容易忽略的技巧:在数据库锁前面加一层本地缓存标记,可以减少大量无效的数据库写请求。比如多个业务请求同时到达,只有第一个请求能竞争数据库锁,其余可以快速返回“正在处理中”,不必每次都去查数据库。
这个本地标记可以是 JVM 的 ConcurrentHashMap 或者 Caffeine,添加一个短 TTL(比如 1 秒)。先查本地标记,标记存在则直接返回忙;标记不存在则去抢数据库锁。虽然本地标记在集群里不是绝对精确,但因为数据库锁本身已经做了兜底,它的作用只是“减少并发穿透”,安全性完全够用。
7.3 基于版本号的并发控制扩展
数据库分布式锁还可以和乐观锁合并使用。举个例子,处理同一个账户的多个充值请求时,锁表确保只允许一个线程处理,业务表里的 version 字段确保更新时不会被其他操作覆盖。
@Transactional public void recharge(Long accountId, BigDecimal amount) { String lockResource = "account:" + accountId; boolean locked = lockService.tryLock(lockResource, UUID.randomUUID().toString(), 30); if (!locked) { throw new BizException("账户处理中,请稍后重试"); } try { Account account = accountMapper.selectByPrimaryKey(accountId); int rows = accountMapper.updateBalance(accountId, account.getVersion(), account.getBalance().add(amount)); if (rows == 0) { throw new ConcurrentUpdateException("账户余额已被其他请求修改"); } } finally { lockService.unlock(lockResource, owner); } }这个组合既享受了数据库锁的强互斥,又利用了乐观锁的版本控制做最终一致性校验,双保险在很多资金场景里非常推荐。
8. 个人实操体会与扩展建议
做数据库分布式锁这几年,最深的感受是:大多数项目把“分布式锁”想得太重了,动不动就上 Redis、上中间件,最后发现维护成本远超业务价值。反而是一张简单的锁表、四个字段、三个方法,只要设计得当,能稳定服务业务很久。
有几个经验想分享给后来的开发者。第一,所有锁记录必须设置过期时间,这是系统稳定运行的第一道防线。第二,解锁和续期必须携带 owner 标识,这是防止全局错乱的第二道防线。第三,加锁失败后的重试一定要加随机退避,这是保护数据库不被打垮的关键。第四,锁表和业务表最好放在同一个库,这样事务的范围可控,出问题的时候排查路径也短。
这个内容后续还可以这样扩展:如果想追求更强的公平性,可以在锁表里加一个token字段,实现可重入锁;如果业务经常批量加锁,可以扩展成一个“锁令牌”模式,用一行记录代表一批资源的锁状态;如果实在担心数据库锁的性能,可以把它降级为“兜底锁”,正常流量走 Redis,Redis 不可用时再切换到数据库锁,形成双保险的降级策略。
但我依然建议,如果你的系统并发量真的很高,且团队有精力维护中间件,直接上 Redis 锁或者专用锁服务是更好的选择;如果业务相对简单、并发量在可控范围内,数据库分布式锁绝对是最务实的一条路。顺着这个思路,你可以先拿一张锁表跑通核心流程,再根据实际业务节奏决定是否升级更重的锁方案,这是我认为最理性的落地方案。