当前位置: 首页 > news >正文

架构师必备:分布式锁方案选型

大家好,我是Java烘焙师,本文结合笔者的经验和思考,对分布式锁做个总结。

当同时操作共享资源时,需要做并发控制。在单机上用synchronizedReentrantLock能解决的互斥问题,到了多机部署的分布式环境就行不通了,因为它们只在单个JVM进程内生效,跨多机就需要分布式锁了。

先说结论,最常用的分布式锁方案是:Redis锁、DB乐观锁、或DB逻辑锁。高并发、能容忍极端情况下短暂的弱一致,选Redis锁;并发不高、强一致要求高,选DB乐观锁、或DB逻辑锁;不推荐DB行锁、ZK锁。下面展开介绍。

一把合格的分布式锁要满足什么

先对齐一下标准,后续每种方案的好坏,都可以对照:

  • 互斥:最基本的要求,任意时刻只能有一个客户端持有同一把锁
  • 防死锁:持有锁的客户端如果宕机了,锁能自动释放,防止其它客户端永远加锁失败
  • 可重入:同一个客户端在持锁期间能再次拿到这把锁,避免自己阻塞自己
  • 性能:加锁、解锁开销要小,吞吐要高

悲观锁和乐观锁

悲观锁

觉得并发冲突大概率会发生,所以操作前先加锁,把资源独占起来,操作完再释放。

  • 典型实现:DB行锁select ... for update、DB逻辑锁(通过新增lock_status状态字段实现)、Redis的set nx px命令
  • 优点:严格互斥,不用像乐观锁那样反复重试
  • 缺点:持锁期间其它线程只能等、有死锁风险、吞吐被锁的粒度和持有时间卡住
  • 适用场景:适合写冲突频繁的场景

乐观锁

觉得冲突很少,所以一开始不加锁,等更新时再判断中途是否被其它线程修改了。

  • 典型实现:version字段、CAS先比较再修改
  • 优点:无锁实现,开销较低
  • 缺点:并发冲突较多时,需要不断重试;可能有ABA问题,但用递增的version能解决,仅CAS比较值则不行
  • 适用场景:适合读多写少的场景;如果写并发很高,但误用了乐观锁的话,会导致线程不停地重试,最终线程池耗尽的严重问题

两个并发更新请求的时序图如下:

sequenceDiagramparticipant G as 业务入口服务(无存储)participant S as 存储服务(持有数据)G->>S: 线程1:先到达请求,查询数据S-->>G: 线程1:返回业务数据、version=v1Note over G,S: 两个并发请求都基于 v1 计算后回写G->>S: 线程2:后到达请求,查询数据S-->>G: 线程2:返回业务数据、version=v1G->>S: 线程2:更新业务数据(带 v1,先到达存储)S->>S: version匹配,更新成功,version升到v2S-->>G: 线程2:操作成功G->>S: 线程1:更新业务数据(带 v1,因网络延迟后到达存储)S->>S: version不匹配(当前版本v2≠v1),更新失败S-->>G: 线程1:操作失败,需上游重试Note over G,S: 线程1先发起请求但因网络问题后到达,version校验阻止了旧数据覆盖新数据

上图展示了乐观锁的流程:业务入口服务本身无存储、数据都在下游存储服务上,查询时一并拿到业务数据、系统version,回写时带上这个version做校验。这样能解决一个典型问题:两个并发请求因网络抖动乱序到达存储服务,先到的请求反而后处理,导致旧数据覆盖新数据。有了乐观锁校验,version不匹配的更新会失败,从而避免旧覆盖新。

主流方案选型

1. DB行锁(不推荐)

  • 原理
    借助InnoDB的排他锁,在事务里执行select ... for update,对查到的行加行锁,事务提交或回滚后释放。别的事务再尝试上DB行锁时,会阻塞住。

  • 实现细节

    • 必须在数据库事务里用,否则for update一执行完就释放了,起不到持锁的作用
    • where条件必须命中主键或唯一索引,否则会锁表、或者产生大段间隙锁
    • 数据库事务内不要做耗时操作,比如调外部RPC接口
    • 设置合理的锁等待超时innodb_lock_wait_timeout,别让被阻塞的请求一直干等
  • 最佳场景:低并发、强一致、已有DB不想引入新中间件
    比如公司内部系统的定时任务,多个实例都可能触发该定时任务,需要保证同一时间只有一个实例在跑。

2. DB乐观锁

  • 原理
    不靠数据库自带的行锁,而是在已有业务表里新增一个version字段,依靠版本号、乐观锁来实现。

  • 实现细节

    • 先查询得到版本号v1
    • 然后更新时再判断version是否为版本号v1,即update ... where biz_id=? and version=?
    • 最后判断影响行数:如果大于0,则说明修改成功;如果是0,则说明中途被别的线程修改了,需要重试
-- 通用更新:带上version条件,影响行数为0则说明被并发修改,需重试
UPDATE biz_record
SET name = #{name}, status = #{status}, version = version + 1
WHERE biz_id = #{bizId} AND version = #{version};

对应的Java示例:

    // 业务代码:查询版本号 -> 带version更新 -> 判断影响行数,失败则重试public boolean updateBiz(String bizId, String name, int status) {int maxRetry = 3;for (int i = 0; i < maxRetry; i++) {// 1. 先查询得到版本号v1Biz biz = bizMapper.selectByBizId(bizId);// 2. 更新时带上version条件,即 where biz_id=? and version=v1int affected = bizMapper.updateByBizId(bizId, name, status, biz.getVersion());// 3. 影响行数大于0说明成功;为0说明被别的线程改过(version变了),重试if (affected > 0) {return true;}}throw new BizException("并发冲突,更新失败");}
  • 最佳场景:中等并发、读多写少、能接受DB的性能上限
    比如:库存扣减、余额扣减、业务状态流转,并发冲突不算特别频繁,乐观锁足够。又比如,核心链路上减少不必要的外部依赖,不用Redis锁、只用DB乐观锁,毕竟DB的稳定性要高于Redis。

3. DB逻辑锁

  • 原理
    不靠数据库自带的行锁,而是在业务表里新增lock_status状态字段,用一条带条件的UPDATE抢占锁:抢到就置为占用状态,释放再置回空闲状态。靠DB写操作的行级一致性保证同一时刻只有一个客户端抢成功,是悲观锁。

  • 实现细节

    • 加锁:update ... set lock_status=1 where biz_id=? and (lock_status=0 or lock_expire_at<now()),影响行数大于0即加锁成功
    • 解锁时带上lock_owner校验,防止误删其它线程上的锁
    • 防死锁:持有者宕机没正常释放,状态会一直占用,所以要加lock_expire_at过期时间,靠定时任务清理过期锁、或持有者续期
-- 加锁:状态空闲、或已过期才能抢到,同时记下持有者和过期时间
UPDATE biz_record
SET lock_status = 1, lock_owner = #{owner}, lock_expire_at = #{expireAt}
WHERE biz_id = #{bizId} AND (lock_status = 0 OR lock_expire_at < NOW());-- 解锁:带上 lock_owner 校验,防止误删别人的锁
UPDATE biz_record
SET lock_status = 0, lock_owner = NULL, lock_expire_at = NULL
WHERE biz_id = #{bizId} AND lock_owner = #{owner};-- 兜底定时任务:清理持有者宕机导致的过期锁
UPDATE biz_record SET lock_status = 0, lock_owner = NULL
WHERE lock_status = 1 AND lock_expire_at < NOW();

对应的Java示例:

    // 加锁:影响行数大于0即成功public boolean tryLock(String bizId, String owner, long leaseMs) {Date expireAt = new Date(System.currentTimeMillis() + leaseMs);return bizMapper.acquireLock(bizId, owner, expireAt) > 0;}// 释放锁:带上 owner 校验,影响行数大于0即释放成功public boolean unlock(String bizId, String owner) {return bizMapper.releaseLock(bizId, owner) > 0;}
  • 最佳场景:中等并发、需要持久化锁状态
    比如跨多个服务、多个步骤的长流程,需要持锁一段时间、且锁状态要落库(重启不丢、可审计追溯)。DB逻辑锁持锁不绑事务、状态可持久化,比DB行锁灵活。

4. Redis锁

单节点redis锁

  • 原理
    依靠Redis单线程串行执行的特点,用SET key value NX PX timeout一条命令原子完成“键不存在才设置 + 设置过期时间”。设置成功就算拿到锁,过期时间一到自动释放、防死锁。

  • 实现细节

    • value必须是唯一标识(比如UUID),释放锁时先比对再删,防止误删其它线程上的锁
    • 释放锁需要保证原子性:比对value、与删除是两步,要用lua脚本包成原子操作
    • 锁续期:业务执行时长不可控,过期时间设短了业务没跑完锁就释放了,设长了宕机后锁仍然占用。Redisson用看门狗机制解决,后台线程每隔一段时间自动检查、并续期锁的TTL,直到业务跑完、并主动释放锁
    • 可重入:用Hash结构记下持有者和重入次数,同一客户端就能多次获取

举例:手写Redis锁

    // 加锁:NX保证互斥,PX设置过期时间,value用唯一标识防误删public boolean tryLock(String lockKey, String requestId, long expireMs) {return "OK".equals(jedis.set(lockKey, requestId, "NX", "PX", expireMs));}// 解锁:用lua脚本保证“比对+删除”的原子性public boolean unlock(String lockKey, String requestId) {String lua ="if redis.call('get', KEYS[1]) == ARGV[1] then " +"  return redis.call('del', KEYS[1]) " +"else " +"  return 0 " +"end";return jedis.eval(lua, Collections.singletonList(lockKey),Collections.singletonList(requestId)).equals(1L);}

生产环境考虑直接用Redisson框架,可重入、锁自动续期、lua脚本都封装好了:

    RLock lock = redisson.getLock("distributedLock:" + bizId);try {// 尝试加锁,最多等待10s,锁自动续期if (lock.tryLock(10, TimeUnit.SECONDS)) {doBusiness();}} finally {if (lock.isHeldByCurrentThread()) {lock.unlock();}}
  • 最佳场景:高并发写场景、能容忍极端情况下短暂的弱一致
    比如秒杀场景防重复提交、消息消费的幂等处理。并发大,要求加锁解锁快,能接受Redis主从切换那一瞬间极小概率的锁失效,必须要做兜底的业务幂等校验。

多节点RedLock锁(不推荐)

Redis的高可用是靠主从异步复制来实现的。有个经典问题:客户端A在master加锁成功,锁还未同步到slave,此时master宕机了,slave升成新master,客户端B加锁也能成功。于是A、B同时持锁,产生并发冲突。

针对这个,Redis作者提出了RedLock:向n个(一般5个)互相独立的Redis节点申请锁,多数(n/2+1)成功才算加锁成功,少数节点挂了不影响。
但这个方案引入了复杂度、牺牲了性能,在GC暂停或网络延迟下仍然有边界问题,就不推荐了。单节点Redis锁,配合业务幂等兜底,已经能解决核心问题了,兼顾了性能和正确性。

5. ZK锁(不推荐)

  • 原理
    靠ZK的临时顺序节点。客户端在/lock路径下创建一个临时顺序节点/lock/seq-00000001,然后取出/lock下所有子节点排个序,看自己是不是序号最小的:
    • 是最小的,拿到锁
    • 不是最小的,就监听前一个节点的删除事件,前一个节点一删,自己被唤醒,再判断一次

释放锁就是删掉自己创建的节点。客户端宕机导致session失效,ZK会自动删掉它建的临时节点,锁跟着释放。

  • 最佳场景:对一致性要求极高、能接受性能和运维代价
    比如集群选主节点,宁可慢也不能错。在业务场景中就不用考虑了,更适合用于集群运维场景,比如Hadoop、Kafka那套大数据生态。

总结

方案 一致性 性能 实现复杂度 防死锁 最适合的场景
DB行锁(悲观锁)(不推荐) 强一致 需配置超时 低并发强一致、已有 DB(内部系统的定时任务)
DB乐观锁(乐观锁) 强一致 中高 无死锁 中等并发、读多写少(库存扣减、余额扣减、业务状态流转等)
DB逻辑锁(悲观锁) 强一致 靠过期兜底 中等并发、需持久化锁状态
Redis锁(悲观锁) 弱一致 靠过期+续期 高并发写场景、可容忍极端弱一致(秒杀、幂等)
ZK锁(不推荐) 强一致 自动释放 集群选主,不要在业务场景使用

对照上面的表格,就能快速找到合适的方案。优先考虑DB乐观锁、DB逻辑锁、Redis锁,不推荐DB行锁、ZK锁。

延伸阅读:也可以看看笔者之前写的一些文章。

  • 架构师必备:分布式事务方案选型
  • 架构师必备:系统性解决幂等问题
http://www.jsqmd.com/news/1279538/

相关文章:

  • 3大Flipper Zero固件编译方案对比:从入门到精通的全流程指南
  • 二分查找算法原理、实现与优化全解析
  • AI代码审查安全吗?从Claude Code看人机协同安全防线构建
  • 并查集原理、优化与应用实战指南
  • 微服务多环境架构设计实战:5套环境2个参数搞定部署(从混乱到体系化)
  • 校招投递多少家公司合适?别用海投数量掩盖低匹配度
  • LDAP与NoSQL注入攻击:超越SQL的Web安全新威胁
  • 释放游戏潜能:Wand-Enhancer 的本地化增强方案
  • 基于行空板与GStreamer的低延迟图传系统实现与优化
  • 2026本溪持证防水补漏商家权威TOP3榜单 卫生间厨房外墙屋面天花板漏水检测靠谱师傅维修指南 - 宅安选房屋修缮
  • NVIDIA Profile Inspector终极指南:3步快速优化显卡性能,提升游戏体验50%
  • Caddy WAF测试方法论:从单元测试到ELK日志分析的完整流程
  • 千笔与万方智搜AI:学术写作工具深度对比与应用技巧
  • AI入门五大核心技能:Python数据处理与机器学习实战
  • 苹果触控板Windows驱动终极指南:5分钟实现原生级触控体验
  • 零成本搭建私有知识库:Dify整合DeepSeek实现本地RAG方案
  • IMU与GPS数据融合的卡尔曼滤波实现与优化
  • 解决tldr-python-client常见问题:网络错误、缓存失效与平台兼容
  • 大统一逻辑链7.0(GULP7.0):演化的涌现(草稿)
  • 2026巴中持证防水补漏商家权威TOP3榜单 卫生间厨房外墙屋面天花板漏水检测靠谱师傅维修指南 - 宅安选房屋修缮
  • 智慧树学习效率革命:三招告别手动刷课的智能解决方案
  • 【Bug已解决】[Bug]: Image URL errors return HTTP 500 instead of 422 for unprocessable content 解决方案
  • ZigbeeTLc高级配置指南:温度湿度偏移、显示设置与测量间隔调整
  • Jellium Desktop网络连接增强工具:提升连接稳定性的终极指南
  • 2026安顺黄金回收避坑指南:认准万金汇全国连锁直营门店 - 观金堂黄金回收
  • 《江西GEO优化哪家好:前五排名 专业测评解析》 - 服务品牌热点
  • jsonschema2md命令行工具全攻略:参数配置与批量处理技巧
  • AzurLaneAutoScript技术架构解析:构建高效碧蓝航线自动化系统的完整指南
  • Seq vs Python:为什么生物信息学需要高性能编程语言?
  • LangChain嵌入向量技术解析与应用实战