分布式锁三种实现:Redis SetNX、Zookeeper临时节点、数据库乐观锁的工程选型
分布式锁三种实现:Redis SetNX、Zookeeper临时节点、数据库乐观锁的工程选型
适读人群:中高级Java工程师 | 阅读时长:约18分钟 | 技术栈:Spring Boot 3.x、Redis、Zookeeper、MySQL
开篇故事
2022年双十一前两周,我们的促销活动秒杀模块出了一个让整个团队深夜加班的事故。
那天晚上八点,测试同学反馈:压测环境下,同一个优惠券ID被重复核销了17次。系统设计上,每张券只能核销一次,核销前我们有如下逻辑:查DB判断是否已核销 -> 未核销则更新状态为已核销 -> 返回成功。
在100 QPS时跑得好好的,压到1万QPS时就开始重复核销。原因很简单:没有分布式锁。两个线程同时进入"未核销"判断,都读到了未核销的状态,然后都执行了更新。
这个事故让我在凌晨两点对着电脑屏幕思考了一个问题:我们到底该用哪种分布式锁?当时我们有三个方案摆在面前,Redis SetNX、Zookeeper临时节点、数据库乐观锁,争论了整整两个小时。
那次之后,我把这三种方案全部在生产环境跑过一遍,也踩了各自的坑。今天把这些积累完整说清楚。
一、核心问题分析
为什么单机锁解决不了问题
在单体应用里,synchronized 和 ReentrantLock 工作得很好。但在分布式系统中,同一个服务会部署多个实例,这些实例跑在不同的 JVM 进程里,内存不共享。线程A在实例1上加的 synchronized 锁,对实例2上的线程B完全无效。
分布式锁的核心需求只有一条:同一时刻,只有一个节点能持有某个资源的锁。围绕这条需求,延伸出三个工程属性:
互斥性:这是基本要求,任意时刻只有一个客户端持有锁。
防死锁:持有锁的客户端崩溃了,锁必须能自动释放,不能让其他客户端永远等待。
容错性:锁服务本身要高可用,否则锁服务挂了,整个业务就卡死了。
这三种方案——Redis SetNX、Zookeeper临时节点、数据库乐观锁——在这三个属性上的实现思路完全不同。
三种方案的本质差异
Redis SetNX 依赖的是 Redis 单线程模型的原子性,用过期时间来防死锁,用主从复制或 Redlock 来保证容错。它的优点是性能极高,每秒钟可以处理数十万次加锁操作,缺点是时钟漂移和主从切换的瞬间可能出现锁丢失。
Zookeeper 临时节点依赖的是 ZAB 协议的强一致性,客户端与 ZK 之间维持 Session,Session 断开则临时节点自动删除。它的互斥性靠创建同名节点的原子性来保证,防死锁靠 Session 超时机制,容错靠 ZK 集群的 Quorum 机制。优点是一致性强,缺点是每次加锁都涉及网络往返和 ZAB 广播,延迟比 Redis 高一个数量级。
数据库乐观锁则不是传统意义上的锁,而是用版本号来做并发控制。每次更新时检查版本号是否与读取时一致,不一致则失败重试。它不依赖任何外部中间件,但高并发下 CAS 重试风暴会让数据库压力急剧上升。
二、原理深度解析
Redis SetNX 原理
Redis 的 SET key value NX PX milliseconds 命令是原子操作,NX 表示 key 不存在才设置成功,PX 设置过期时间(毫秒)。整个命令在 Redis 单线程模型下执行,天然保证了互斥性。
加锁时用唯一值(UUID)作为 value,释放时先 GET 验证是自己的锁再 DEL,这两步必须用 Lua 脚本保证原子性,否则会出现"验证通过后、DELETE前,锁恰好过期被别人抢走,然后你把别人的锁删掉"的问题。
Zookeeper 临时节点原理
ZK 的临时顺序节点(EPHEMERAL_SEQUENTIAL)是实现公平锁的关键。每个客户端在 /locks/resource_name/ 下创建临时顺序节点,编号最小的节点持有锁,其他节点监听它前一个节点的删除事件(而非都监听最小节点,这样可以避免惊群效应)。
临时节点的关键特性:客户端与 ZK 的 Session 一旦断开(网络故障、进程崩溃),ZK 会在 Session 超时后自动删除该客户端创建的所有临时节点,锁自然释放,不需要手动清理。
数据库乐观锁原理
乐观锁不真正"锁"资源,而是在更新时用 WHERE 条件检查版本号。只有版本号匹配的更新才能成功,失败则重试。
三、完整代码实现
Redis 分布式锁实现
@Component
@Slf4j
public class RedisDistributedLock {
@Autowired
private StringRedisTemplate redisTemplate;
// Lua 脚本:原子性释放锁(验证 + 删除)
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
/**
* 尝试获取锁
* @param lockKey 锁的 key
* @param lockValue 唯一值(推荐使用 UUID + 线程ID)
* @param expireMs 过期时间(毫秒)
*/
public boolean tryLock(String lockKey, String lockValue, long expireMs) {
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofMillis(expireMs));
return Boolean.TRUE.equals(result);
}
/**
* 带重试的加锁
* @param retryTimes 重试次数
* @param retryIntervalMs 重试间隔(毫秒)
*/
public boolean tryLockWithRetry(String lockKey, String lockValue,
long expireMs, int retryTimes, long retryIntervalMs) {
for (int i = 0; i <= retryTimes; i++) {
if (tryLock(lockKey, lockValue, expireMs)) {
return true;
}
if (i < retryTimes) {
try {
TimeUnit.MILLISECONDS.sleep(retryIntervalMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
}
return false;
}
/**
* 释放锁(Lua 脚本保证原子性)
*/
public boolean releaseLock(String lockKey, String lockValue) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(script,
Collections.singletonList(lockKey),
lockValue);
return Long.valueOf(1L).equals(result);
}
/**
* 生成唯一锁值
*/
public static String generateLockValue() {
return UUID.randomUUID().toString().replace("-", "") +
":" + Thread.currentThread().getId();
}
}业务层使用示例:
@Service
@Slf4j
public class CouponService {
@Autowired
private RedisDistributedLock distributedLock;
@Autowired
private CouponMapper couponMapper;
private static final String COUPON_LOCK_PREFIX = "coupon:redeem:";
private static final long LOCK_EXPIRE_MS = 10_000L; // 10秒过期
public boolean redeemCoupon(Long couponId, Long userId) {
String lockKey = COUPON_LOCK_PREFIX + couponId;
String lockValue = RedisDistributedLock.generateLockValue();
// 尝试加锁,最多重试3次,每次等待100ms
boolean locked = distributedLock.tryLockWithRetry(
lockKey, lockValue, LOCK_EXPIRE_MS, 3, 100L);
if (!locked) {
log.warn("获取分布式锁失败,couponId={}, userId={}", couponId, userId);
throw new BusinessException("系统繁忙,请稍后重试");
}
try {
// 双重检查:锁内再查一次DB状态
Coupon coupon = couponMapper.selectById(couponId);
if (coupon == null || coupon.getStatus() == CouponStatus.REDEEMED) {
return false;
}
// 执行核销
int rows = couponMapper.redeemCoupon(couponId, userId);
return rows > 0;
} finally {
// 确保锁一定被释放
boolean released = distributedLock.releaseLock(lockKey, lockValue);
if (!released) {
log.warn("释放分布式锁失败(可能已过期),lockKey={}", lockKey);
}
}
}
}Zookeeper 分布式锁实现
@Component
@Slf4j
public class ZookeeperDistributedLock {
@Autowired
private CuratorFramework curatorClient;
private static final String LOCK_BASE_PATH = "/distributed-locks";
/**
* 获取 InterProcessMutex(Curator 封装的可重入互斥锁)
*/
public InterProcessMutex getLock(String resourceName) {
String lockPath = LOCK_BASE_PATH + "/" + resourceName;
return new InterProcessMutex(curatorClient, lockPath);
}
/**
* 带超时的加锁
*/
public boolean tryAcquire(InterProcessMutex lock, long timeoutMs) {
try {
return lock.acquire(timeoutMs, TimeUnit.MILLISECONDS);
} catch (Exception e) {
log.error("ZK 加锁异常", e);
return false;
}
}
/**
* 释放锁
*/
public void release(InterProcessMutex lock) {
try {
if (lock.isAcquiredInThisProcess()) {
lock.release();
}
} catch (Exception e) {
log.error("ZK 释放锁异常", e);
}
}
}Curator 配置:
@Configuration
public class ZookeeperConfig {
@Value("${zookeeper.connect-string:localhost:2181}")
private String connectString;
@Value("${zookeeper.session-timeout-ms:5000}")
private int sessionTimeoutMs;
@Value("${zookeeper.connection-timeout-ms:3000}")
private int connectionTimeoutMs;
@Bean(destroyMethod = "close")
public CuratorFramework curatorFramework() {
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString(connectString)
.sessionTimeoutMs(sessionTimeoutMs)
.connectionTimeoutMs(connectionTimeoutMs)
.retryPolicy(retryPolicy)
.namespace("myapp") // 所有路径自动加前缀
.build();
client.start();
return client;
}
}数据库乐观锁实现
表结构设计:
CREATE TABLE product (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(200) NOT NULL,
stock INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_id_version (id, version)
);Mapper 层:
@Mapper
public interface ProductMapper {
@Select("SELECT id, name, stock, version FROM product WHERE id = #{id}")
Product selectById(Long id);
@Update("UPDATE product SET stock = stock - #{deductCount}, version = version + 1 " +
"WHERE id = #{id} AND version = #{version} AND stock >= #{deductCount}")
int deductStockWithVersion(@Param("id") Long id,
@Param("deductCount") int deductCount,
@Param("version") int version);
}Service 层(含重试逻辑):
@Service
@Slf4j
public class StockService {
@Autowired
private ProductMapper productMapper;
private static final int MAX_RETRY = 5;
/**
* 乐观锁扣减库存,自动重试
*/
public boolean deductStock(Long productId, int count) {
for (int retry = 0; retry < MAX_RETRY; retry++) {
Product product = productMapper.selectById(productId);
if (product == null || product.getStock() < count) {
log.warn("库存不足,productId={}, stock={}, count={}",
productId, product == null ? 0 : product.getStock(), count);
return false;
}
int rows = productMapper.deductStockWithVersion(
productId, count, product.getVersion());
if (rows > 0) {
log.info("库存扣减成功,productId={}, retry={}", productId, retry);
return true;
}
// 版本冲突,指数退避后重试
if (retry < MAX_RETRY - 1) {
long backoffMs = (long) Math.pow(2, retry) * 10;
try {
TimeUnit.MILLISECONDS.sleep(backoffMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
}
log.error("乐观锁重试{}次均失败,productId={}", MAX_RETRY, productId);
return false;
}
}四、生产调优与配置
Redis 锁调优要点
过期时间设置:这是最容易踩坑的地方。设太短,业务未完成锁就过期,另一个节点抢到锁导致并发问题;设太长,节点崩溃后其他节点等待时间过长。生产建议:评估业务P99耗时,取3倍值作为过期时间。比如业务P99是200ms,锁过期设600ms。
Redisson 的看门狗:Redisson 提供了 WatchDog 机制,持有锁的线程每隔 lockWatchdogTimeout/3 时间(默认10秒)自动续期。这解决了业务执行时间超过锁过期时间的问题。建议生产环境直接用 Redisson,不要自己裸写。
# Redisson 配置
spring:
redis:
redisson:
config: |
singleServerConfig:
address: "redis://127.0.0.1:6379"
connectionPoolSize: 64
connectionMinimumIdleSize: 10
lockWatchdogTimeout: 30000 # 看门狗超时30秒ZK 锁调优要点
Session 超时设置:Session 超时时间控制着"节点崩溃后多久释放锁"。设太小,网络抖动就会触发 Session 超时,导致误释放;设太大,节点崩溃后其他节点等待时间过长。生产建议 15-30 秒。
监听器的重注册:ZK 的 Watcher 是一次性的,触发后需要重新注册。Curator 封装了这个细节,但如果自己实现,一定要在回调里重新注册 Watcher,否则后续的节点变化就监听不到了。
数据库乐观锁调优要点
重试风暴控制:高并发下,大量线程同时重试会让数据库压力暴增。要加随机抖动和重试上限。建议结合令牌桶做一层限流,让重试请求有序化。
索引优化:WHERE id = ? AND version = ? 的联合索引非常关键,没有这个索引,高并发下版本号比对会做全表扫描。
五、踩坑实录
坑一:Redis 释放了别人的锁
这是我职业生涯中最低级也最刻骨铭心的错误,发生在2020年。当时代码是这样的:
// 错误写法!!
String value = redisTemplate.opsForValue().get(lockKey);
if (myLockValue.equals(value)) {
redisTemplate.delete(lockKey); // 这两步之间有时间窗口!
}在 GET 和 DELETE 之间,如果锁恰好过期,另一个节点加了锁,这里的 DELETE 就把别人的锁删掉了。
那次事故直接导致促销商品超卖了 2000 多件,紧急下线处理了 4 个小时。自那以后,我对"原子性"两个字的理解深入了一个层次。正确做法就是前面说的 Lua 脚本,GET + DEL 两步合一。
坑二:ZK 会话超时引发的锁假释放
2021年某次大促,我们用 ZK 做订单分布式锁,突然出现了大量重复下单。排查发现:支付回调处理时间有时会超过 20 秒(需要调用多个第三方接口),而我们的 ZK Session 超时设的是 15 秒。
处理逻辑是:支付回调获得 ZK 锁 -> 调用第三方 A(平均8秒)-> 调用第三方 B(平均7秒)-> 更新订单状态。在第三方 B 的调用期间,ZK Session 超时,临时节点被删除,锁自动释放,另一个节点拿到锁也开始处理同一笔订单,结果两个节点都更新了订单状态,产生重复数据。
解决方案是将 Session 超时调整为 60 秒,同时在业务代码里增加幂等性校验(基于数据库的唯一键约束兜底)。
坑三:数据库乐观锁的重试风暴
2019年做秒杀活动时,我们用乐观锁控制库存,代码逻辑是:失败立刻重试,最多重试10次。在 5000 QPS 的压测中,CPU 飙到 90%,数据库连接池全部耗尽。
问题出在:5000 个请求同时打进来,只有一个能更新成功,其余 4999 个立刻重试,形成第二波 4999 个请求,又只有一个成功,继续重试……这就是典型的重试风暴,越重试越卡死。
后来的修复是三个方向并举:加 Redis 分布式锁做前置拦截(只有拿到锁的才进数据库);重试改为指数退避+随机抖动;入口加令牌桶限流,秒杀请求控制在 500 QPS 进入核心逻辑。修复后压测数据库 CPU 稳定在 30% 以下。
坑四:Redis 主从切换的锁丢失
这个坑发生概率较低,但一旦发生影响极大。Redis 主从架构下,加锁是写到 Master,然后异步同步到 Slave。如果 Master 刚加锁成功就宕机,且这条写操作还没同步到 Slave,Slave 晋升为新 Master 后,锁就消失了,另一个客户端可以再次加锁成功。
对于要求严格互斥的场景(比如资金操作),我建议用 Redlock 算法(向多数 Redis 节点加锁)或者直接用 ZK 锁。在绝大多数业务场景下,Redis 单节点锁已经够用,但要在业务层加幂等性兜底,不要把分布式锁当成唯一的安全保障。
六、总结
三种方案的选型建议一张表说清楚:
| 维度 | Redis SetNX | ZK 临时节点 | DB 乐观锁 |
|---|---|---|---|
| 加锁性能 | 极高(μs级) | 中等(ms级) | 低(依赖DB) |
| 一致性强度 | 弱(主从同步延迟) | 强(ZAB协议) | 强(ACID) |
| 实现复杂度 | 中(需Lua脚本) | 低(Curator封装好) | 低 |
| 防死锁机制 | 过期时间 | Session超时 | 无死锁问题 |
| 适用场景 | 高并发、对一致性要求不极致 | 强一致性、并发量中等 | 低并发、无外部依赖 |
| 生产建议 | 直接用 Redisson | 直接用 Curator | 加重试上限+退避 |
我的选型原则很简单:大多数场景用 Redis,有强一致性要求用 ZK,不想引入中间件依赖用 DB 乐观锁。无论哪种方案,业务层的幂等性设计永远是最后一道防线,分布式锁从来不是银弹。
