1. 分布式锁从单机到集群的必然选择在单机应用时代我们处理并发问题一个synchronized关键字或者一个ReentrantLock就能搞定因为它们锁住的是同一个JVM进程内的共享资源。但当我们把应用拆成多个服务部署到不同的机器上甚至跨机房、跨地域时问题就来了。一个订单服务部署了三个实例它们同时去扣减同一个商品的库存这三个实例运行在不同的JVM里内存都不互通你本地的锁再厉害也管不了隔壁机器上的进程。这时候我们就需要一个所有服务实例都能“看见”并“认可”的锁这就是分布式锁。分布式锁的核心目标很简单在分布式系统或集群环境下保证对共享资源进行互斥访问。听起来简单但要实现一个靠谱的分布式锁需要考虑的细节非常多。它必须满足几个基本条件互斥性同一时间只有一个客户端能持有锁、避免死锁锁必须有超时机制防止持有者挂掉后锁永远不释放、可重入性同一个线程可以多次获取同一把锁、高性能与高可用加锁解锁操作要快锁服务本身要稳定。今天我们就来深入聊聊在Java世界里实现分布式锁的六种主流方法从最经典的数据库方案到如今大厂标配的Redis、ZooKeeper再到一些你可能没太留意但很有意思的实现方式。我会结合我这些年踩过的坑和实战经验把每种方法的原理、实现细节、优缺点以及最关键的“避坑指南”给你讲透。2. 基于数据库的实现最朴素也最“重”的方案当我们手头没有Redis、ZooKeeper这些中间件时数据库往往是第一个被想到的解决方案。毕竟哪个系统还没个数据库呢利用数据库的排他性来实现分布式锁主要有两种思路基于唯一索引或主键约束的乐观锁和基于SELECT ... FOR UPDATE的悲观锁。2.1 基于唯一索引/主键的乐观锁这种方法的核心是创建一张锁表比如叫distributed_lock。表里至少要有这几个字段id主键或唯一索引代表锁的名称如order_lock_123、expire_time锁的过期时间。加锁的过程就是向这张表插入一条记录。CREATE TABLE distributed_lock ( id VARCHAR(128) PRIMARY KEY COMMENT 锁标识如资源ID, expire_time DATETIME NOT NULL COMMENT 锁过期时间, client_id VARCHAR(64) COMMENT 持有锁的客户端标识 );假设我们要锁住订单ID为1001的修改操作锁的标识就是order_lock_1001。加锁的SQL如下INSERT INTO distributed_lock (id, expire_time, client_id) VALUES (order_lock_1001, DATE_ADD(NOW(), INTERVAL 30 SECOND), service_A_instance_1) ON DUPLICATE KEY UPDATE expire_time IF(expire_time NOW(), VALUES(expire_time), expire_time), client_id IF(expire_time NOW(), VALUES(client_id), client_id);这条SQL的精髓在于ON DUPLICATE KEY UPDATE和后面的条件判断。它的逻辑是尝试插入如果主键id冲突说明锁已存在则检查现有记录的expire_time是否已过期小于当前时间。如果已过期我就“抢占”它更新过期时间和客户端ID如果未过期说明锁还被别人有效持有我加锁失败。解锁就更简单了删除这条记录或者将过期时间设为过去时即可。但这里有个大坑谁加的锁必须由谁自己来解。你不能让客户端A加的锁被客户端B给释放了。所以解锁时必须带上client_id作为条件DELETE FROM distributed_lock WHERE id order_lock_1001 AND client_id service_A_instance_1;这个方案的优缺点非常明显优点实现简单依赖少只需要数据库理解成本低。缺点性能瓶颈数据库的插入和更新操作在高并发下会成为严重的性能瓶颈尤其是锁竞争激烈时。锁失效风险严重依赖数据库的时钟。如果数据库服务器时间不同步或者发生时钟回拨可能导致锁提前失效或永不失效。非阻塞与重试它本质上是非阻塞的获取失败后需要业务层自己实现重试逻辑增加了复杂度。数据库连接压力频繁的加锁解锁操作会消耗大量数据库连接。注意在实际生产环境中我强烈不建议将这种方案用于高频、核心的分布式锁场景。它更适合作为在特定过渡期或非核心场景下的备选方案。如果一定要用请务必确保数据库的高可用并给锁表加上合适的索引。2.2 基于SELECT ... FOR UPDATE的悲观锁这是利用数据库事务和行锁的特性。在事务中使用SELECT ... FOR UPDATE语句查询一条特定的记录这条记录就代表锁数据库会为这条记录加上排他锁X锁直到事务提交或回滚。// 伪代码示例 Transactional public boolean tryLock(String lockKey, String clientId, int expireSeconds) { // 1. 清理过期锁可选需要定时任务配合 // 2. 尝试锁定 LockRecord record lockDao.selectForUpdate(lockKey); if (record null) { // 记录不存在插入一条表示获取锁 lockDao.insert(new LockRecord(lockKey, clientId, expireSeconds)); return true; } else if (record.isExpired()) { // 记录存在但已过期更新为当前客户端持有 lockDao.updateLockOwner(lockKey, clientId, expireSeconds); return true; } else if (record.getClientId().equals(clientId)) { // 记录存在且未过期但持有者就是自己可重入 lockDao.updateExpireTime(lockKey, expireSeconds); return true; } else { // 锁被其他客户端有效持有 return false; } }解锁就是提交或回滚事务释放行锁。这个方案的坑更深连接持有问题FOR UPDATE锁住的行会一直占用数据库连接。如果获取锁后业务逻辑执行时间过长这个连接会一直被占用容易导致数据库连接池耗尽。死锁风险如果多个事务以不同的顺序去获取多把锁很容易引发数据库死锁。性能与可扩展性同样存在数据库单点性能和扩展性问题。在大并发下事务的开启、提交、回滚开销巨大。锁表风险如果WHERE条件不走索引可能会升级为表锁灾难性后果。实战心得基于数据库的分布式锁我个人的经验是“能不用就不用”。它把分布式协调的压力完全转嫁给了数据库而数据库的设计初衷并非为此。在早期的单体应用拆分初期如果团队技术栈单一且并发量极低比如后台管理类操作可以作为一个快速上手的方案。但一旦业务量起来这绝对是第一个需要被替换掉的组件。3. 基于Redis的实现高性能场景下的首选Redis以其高性能、丰富的数据结构和原子操作命令成为了实现分布式锁最流行的选择。其核心是利用SET命令的NXNot eXist和PX毫秒过期时间参数在一条命令中完成“判断是否存在”和“设置值并过期”两个操作这是原子性的。3.1 基础版SET NX PX最基础的命令如下SET lock_key unique_client_id NX PX 30000lock_key: 锁的键。unique_client_id: 一个唯一标识通常用UUID或“客户端ID线程ID”用于保证锁只能由加锁者释放。NX: 仅当key不存在时才设置。PX 30000: 设置key的过期时间为30000毫秒30秒。如果命令返回OK表示加锁成功返回nil表示锁已被占用。解锁时需要先比较unique_client_id再删除key。为了防止比较和删除两个操作之间的原子性问题需要使用Lua脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这就是著名的“Redlock”算法出现之前最常用的单Redis实例分布式锁方案。但它有一个致命缺陷主从切换时的锁丢失问题。假设我们在一个主从架构的Redis中客户端A在主节点上成功加锁。在锁数据同步到从节点之前主节点宕机了。哨兵或集群将某个从节点提升为新主节点但这个新主节点上没有刚才的那把锁。此时客户端B向新主节点申请同一把锁也会成功。这就导致了两个客户端同时持有锁违反了互斥性。3.2 进阶版Redisson与看门狗机制直接使用原生Redis命令实现一个健壮的分布式锁非常繁琐需要考虑重试、锁续期、可重入等诸多问题。因此我们通常使用成熟的客户端库比如Redisson。Redisson提供的RLock对象封装了所有复杂逻辑。Redisson实现分布式锁的核心机制包括可重入性在Redis中通过Hash结构存储锁字段名是客户端ID线程ID字段值是重入次数。看门狗Watchdog锁续期这是Redisson最精华的部分。如果你没有显式指定锁的超时时间Redisson会启动一个看门狗线程。默认情况下它给锁设置的初始过期时间是30秒。看门狗线程会每隔10秒过期时间的1/3检查一下如果客户端还持有这把锁就自动将锁的过期时间重置为30秒。这样就避免了因为业务执行时间过长导致锁自动过期而被其他客户端获取的问题。Lua脚本保证原子性所有加锁、解锁、重入计数增减的操作都通过Lua脚本完成确保原子性。// Redisson 使用示例 Config config new Config(); config.useSingleServer().setAddress(redis://127.0.0.1:6379); RedissonClient redisson Redisson.create(config); RLock lock redisson.getLock(myLock); try { // 尝试加锁最多等待100秒上锁以后10秒自动解锁 boolean isLocked lock.tryLock(100, 10, TimeUnit.SECONDS); if (isLocked) { // 执行业务逻辑 doBusiness(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 释放锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } }Redisson的优缺点优点功能完善可重入、公平锁、联锁、红锁等社区活跃文档丰富基本涵盖了分布式锁的所有高级特性。缺点引入了客户端复杂性看门狗机制需要额外的后台线程增加了系统开销。在极端网络分区情况下依然可能存在问题这是分布式系统共识问题而非Redisson的缺陷。3.3 高可用方案RedLock算法为了应对Redis主从架构锁丢失的问题Redis作者提出了RedLock算法。它的核心思想是不再依赖单个Redis实例而是同时向多个独立的Redis主节点申请锁当且仅当从超过半数的节点上成功获取锁且总耗时小于锁的有效时间时才算加锁成功。假设我们有5个独立的Redis主节点N5。客户端获取当前毫秒级时间戳T1。依次向5个实例发送加锁命令SET key random_value NX PX 30000并记录每个命令的耗时。客户端计算获取锁的总耗时 当前时间T2 - T1。只有当满足以下两个条件时锁才获取成功客户端从至少3个N/2 1实例上获取到了锁。总耗时小于锁的自动释放时间比如30秒。如果获取锁失败客户端需要向所有Redis实例发送释放锁的Lua脚本。RedLock的争议与注意事项RedLock算法自提出以来就争议不断主要焦点在于它依赖一个“不可靠”的假设所有Redis节点之间的时钟是近似同步的且GC停顿、进程停顿等“Stop-The-World”事件不会同时发生在多个节点上。Martin Kleppmann曾撰文详细讨论过其潜在问题。我的实战建议是对于绝大多数业务场景使用单Redis实例Redisson开启看门狗的方案已经足够可靠。你需要确保Redis本身是高可用的例如Redis Sentinel或Cluster并且网络环境稳定。RedLock算法的实施成本很高需要部署多个独立的主节点且并不能提供理论上的绝对安全。它更适合那些对锁的可靠性要求极高且愿意为此付出复杂性和性能代价的场景同时你需要清醒地认识到它并非银弹。无论用哪种方案锁的过期时间设置都至关重要。它必须大于业务逻辑的平均执行时间并留出足够余量。使用Redisson的看门狗机制可以很好地缓解这个问题。4. 基于ZooKeeper的实现强一致性的代表与Redis的“AP”倾向高可用和分区容忍性不同ZooKeeper是一个典型的“CP”系统强一致性和分区容忍性。基于ZooKeeper的分布式锁利用的是其有序临时节点Ephemeral Sequential Node和Watcher机制。4.1 实现原理与流程定义锁在ZooKeeper中一个锁对应一个持久节点Persistent Node例如/locks/order_lock。申请锁客户端在锁节点下创建临时顺序节点例如/locks/order_lock/seq-000000001。ZooKeeper会保证节点序号递增。判断锁获取客户端获取/locks/order_lock下的所有子节点并按序号排序。如果自己创建的节点是序号最小的则获取锁成功。如果不是最小的则说明前面有排队者。客户端需要监听Watch自己前一个序号节点的存在状态。等待锁被监听的节点被删除即前一个客户端释放了锁ZooKeeper会通知当前客户端。客户端被通知后重新执行步骤3判断自己是否变成了最小节点。释放锁客户端完成业务后只需删除自己创建的那个临时节点。由于是临时节点如果客户端会话Session超时或断开节点也会被自动删除这就天然避免了死锁。这种实现方式天然具备了互斥性、避免死锁临时节点、可重入性需要在客户端内存中记录重入次数和公平性顺序节点先到先得。4.2 Curator框架开箱即用的最佳实践和Redis领域的Redisson一样ZooKeeper领域也有一个神器——Apache Curator。它封装了所有复杂的底层操作提供了多种分布式锁的实现最常用的是InterProcessMutex可重入互斥锁。// Curator 使用示例 CuratorFramework client CuratorFrameworkFactory.newClient(zk-host:2181, new ExponentialBackoffRetry(1000, 3)); client.start(); InterProcessMutex lock new InterProcessMutex(client, /locks/order_lock); try { // 获取锁带超时时间 if (lock.acquire(10, TimeUnit.SECONDS)) { try { // 执行业务逻辑 doBusiness(); } finally { // 释放锁 lock.release(); } } } catch (Exception e) { // 处理异常 }Curator帮你处理了所有细节连接管理、重试策略、节点创建、Watcher注册与回调、锁重入计数等。4.3 ZooKeeper方案的深度剖析与选型思考优点强一致性保证锁的状态在集群内是强一致的不存在Redis主从切换导致锁丢失的问题。可靠性高临时节点的特性使得客户端崩溃时锁能自动释放非常可靠。原生支持公平锁顺序节点天然实现了公平排队。缺点性能相对较低每次加锁、解锁都需要在集群中创建/删除节点并可能触发Watcher通知性能远低于基于内存的Redis。在锁竞争激烈时大量Watcher也会给服务器带来压力。复杂性高需要额外维护一个ZooKeeper集群增加了运维成本。客户端需要处理会话超时、连接断开等复杂状态。“羊群效应”在旧版本的实现中当一个锁释放时会通知所有监听它的客户端导致大量客户端同时去竞争锁引发惊群问题。Curator通过“只监听前一个节点”优化了这一点。选型建议如果你的系统已经强依赖ZooKeeper比如用于服务发现、配置中心那么使用Curator实现分布式锁是一个很自然、也很可靠的选择可以避免引入新的中间件。如果对锁的强一致性有极致要求且可以接受一定的性能损耗如金融核心交易中的某些环节ZooKeeper是比Redis更稳妥的选择。如果业务是超高并发、高性能敏感型如秒杀、库存扣减那么Redis通常是更好的选择。你需要做的是通过良好设计合理的过期时间、熔断降级来规避其理论上的不一致风险。5. 基于Etcd的实现后起之秀的强一致性选择Etcd是一个高可用的分布式键值存储常用于服务发现和配置共享它使用Raft协议保证强一致性。基于Etcd的分布式锁原理上与ZooKeeper类似但API更现代简洁。5.1 核心机制租约Lease与事务TransactionEtcd实现锁的核心是两大特性租约Lease可以创建一个具有生存时间TTL的租约。将键值对与该租约绑定当租约过期所有绑定的键都会被自动删除。这完美对应了锁的“过期释放”需求。事务TransactionEtcd支持原子性的比较-设置Compare-and-Swap, CAS操作通过事务可以原子性地完成“判断key是否存在”和“创建key”的操作。加锁流程客户端创建一个租约例如TTL30秒。发起一个事务Transaction比较Compare判断锁的key如/lock/service)是否存在。成功操作Success Operations如果不存在则执行PUT操作创建该key并将其值与当前租约ID绑定。失败操作Failure Operations如果存在则事务失败。如果事务成功则加锁成功。客户端需要定期续租Keep Alive以防止租约过期。如果事务失败则监听Watch这个key的变化删除事件等待锁释放。解锁流程删除对应的key并释放租约。5.2 客户端库与实战同样我们一般使用客户端库如jetcdJava客户端或更高层次的封装。下面是一个概念性示例// 伪代码展示基于 etcd 租约和事务的锁概念 Client client Client.builder().endpoints(http://localhost:2379).build(); Lease leaseClient client.getLeaseClient(); Lock lockClient client.getLockClient(); // 1. 创建租约 long leaseId leaseClient.grant(30).get().getID(); // 2. 尝试加锁 (通过事务实现) Txn txn client.getKvClient().txn(); // 判断 /lock/mylock 是否存在 ByteSequence key ByteSequence.from(/lock/mylock.getBytes()); TxnResponse txnResp txn.If(new Cmp(key, Cmp.Op.EQUAL, CmpTarget.version(0))) // 版本为0表示不存在 .Then(Op.put(key, ByteSequence.from(holder1.getBytes()), PutOption.newBuilder().withLeaseId(leaseId).build())) .Else(Op.get(key, GetOption.DEFAULT)) .commit().get(); if (txnResp.isSucceeded()) { // 加锁成功 // 启动一个线程定期续租 leaseClient.keepAliveOnce(leaseId) try { doBusiness(); } finally { // 解锁删除key释放租约 client.getKvClient().delete(key); leaseClient.revoke(leaseId); } } else { // 加锁失败监听key变化 // Watch watch client.getWatchClient().watch(key, listener); }Etcd方案的定位优点提供与ZooKeeper类似的强一致性保证且API更简单性能通常优于ZooKeeper。租约机制设计上更贴合锁的场景。缺点作为分布式锁方案的普及度和社区成熟度特别是Java客户端目前仍略低于Redis和ZooKeeper。需要额外维护Etcd集群。它适合那些已经使用或计划使用Etcd作为核心基础设施如Kubernetes生态并且对一致性要求高的团队。6. 基于Consul的实现服务网格生态中的轻量级选项Consul是HashiCorp推出的服务发现与配置工具它也提供了用于分布式协调的Key-Value存储和会话Session机制可以用来实现分布式锁。6.1 实现机制会话与KVConsul锁的核心是会话Session。一个会话代表一个客户端实例的有效期会话可以绑定到KV存储中的键上。创建会话客户端创建一个会话并指定TTL或健康检查行为。如果会话失效客户端失联或TTL到期Consul会自动释放该会话关联的所有锁。竞争锁客户端尝试在Consul的KV存储中对指定的Key执行acquire操作并关联上一步创建的会话。锁获取Consul会原子性地检查该Key是否已被其他会话锁定。如果没有则当前客户端获取锁并将Key的值与当前会话关联如果已被锁定则获取失败。释放锁客户端执行release操作或者直接让会话过期。6.2 特点与适用场景优点与Consul生态无缝集成如果你的微服务架构已经使用Consul做服务发现和健康检查那么用Consul实现锁可以避免引入新的组件运维更统一。基于HTTP API使用相对简单。通过会话机制也天然避免了死锁。缺点性能通常认为其锁性能不如Redis。成熟度在分布式锁这个细分领域其社区讨论和最佳实践积累不如Redis和ZooKeeper丰富。强一致性Consul默认提供的是强一致性读但写操作在集群间的同步延迟需要根据配置考量。选型思考Consul锁是一个“场景驱动”的选择。它并非分布式锁领域的性能冠军或功能最强者但它是Consul技术栈内的自然延伸。当你已经在使用Consul并且需要一把轻量级、可靠性尚可、运维简单的分布式锁时它是一个非常方便的内置选项。7. 基于数据库行锁的另类思路SELECT ... FOR UPDATE NO WAIT除了前面提到的基于数据库表的独立锁方案还有一种更“嵌入式”的思路直接利用业务数据本身的行锁。这并非严格意义上的独立分布式锁组件但在某些特定场景下非常巧妙和高效。假设我们有一张商品库存表product_stock其中有product_id和stock字段。传统的扣减库存做法是BEGIN; SELECT stock FROM product_stock WHERE product_id 1001 FOR UPDATE; -- 应用层判断 stock 0 UPDATE product_stock SET stock stock - 1 WHERE product_id 1001; COMMIT;这里的SELECT ... FOR UPDATE会对product_id1001的记录加上行锁其他事务会被阻塞直到当前事务提交。这本质上就是利用数据库行锁实现了对“商品1001库存”这一共享资源的互斥访问。但这里有个问题它是阻塞的。如果锁竞争激烈大量事务会排队等待消耗数据库连接。一些数据库如Oracle PostgreSQL提供了NOWAIT或SKIP LOCKED选项SELECT ... FOR UPDATE NOWAIT如果无法立即获取行锁直接报错而不是等待。SELECT ... FOR UPDATE SKIP LOCKED跳过那些已经被锁定的行直接查询并锁定未被锁定的行在批量处理场景有用。利用NOWAIT我们可以实现一种非阻塞的、乐观的锁尝试Transactional public boolean deductStock(Long productId) { try { // 尝试立即获取行锁失败则抛出异常 ProductStock stock productStockDao.selectForUpdateNowait(productId); if (stock.getStock() 0) { productStockDao.decreaseStock(productId); return true; } return false; } catch (CannotAcquireLockException e) { // 获取锁失败说明有其他事务正在操作 return false; } }这种方案的适用场景非常特定优点极度轻量没有额外的锁组件开销锁的粒度就是数据行本身锁的释放与事务绑定非常严谨。缺点数据库压力所有锁竞争压力直接落在数据库上。事务依赖锁的持有时间与事务执行时间强绑定长事务会导致锁长期占用。可移植性NOWAIT或SKIP LOCKED语法并非所有数据库都支持。功能单一它只是一个“尝试-失败”的互斥原语不具备可重入、锁续期、公平性等高级特性。实战心得这种方案我曾在一些并发量不是极高、业务逻辑简单且执行快速的“准实时”扣减场景中使用过例如发放数量有限的优惠券。它的好处是架构简单无需引入任何外部组件。但前提是你必须对数据库的性能和连接数有充分的信心和监控并且业务逻辑能快速完成。这更像是一种“模式”Pattern而非一个通用的“锁服务”Lock Service。