同一笔订单被扣了两次款:我写的分布式锁形同虚设
十月下旬,财务对账发现 7 笔订单重复扣款。查下来是用户点了两次支付按钮,两个请求几乎同时打到两台不同的应用Ubuntu 服务器上,都通过了库存和状态校验,各扣了一次。
我当时的第一反应是"加个锁不就行了",然后写出了这一版:
public boolean pay(Long orderId) {
String lockKey = "pay:lock:" + orderId;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
if (locked != null && locked) {
redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS); // 单独设过期
try {
return doPay(orderId);
} finally {
redisTemplate.delete(lockKey);
}
}
return false;
}
看着挺像回事。上线之后重复扣款确实没了,但出现了新问题,而且更严重。
问题一:setnx 和 expire 是两条命令
Redis 单条命令是原子的,但两条命令组合起来不是。下面这种情况会发生:
T1: 线程 A 执行 setnx 成功
T2: 应用进程被 kill -9(或 Redis 连接断开、机器宕机)
T3: expire 永远没执行 → 这把锁永不过期
后果是 pay:lock:12345 这个 key 永久存在,这笔订单再也付不了款。我们那次是发布应用时 kill 掉进程触发的,第二天客服接到投诉才发现。
正确做法是用一条命令搞定。Redis 从 2.6.12 开始扩展了 SET 的参数:
SET lock_key request_id NX PX 30000
参数含义:
NX:只有 key 不存在时才设置(等价于 setnx);PX 30000:过期时间 30000 毫秒;- 整条命令原子执行,不存在中间态。
Jedis 和 Spring Data Redis 2.x 都支持:
// Jedis
String result = jedis.set(lockKey, requestId, "NX", "PX", 30000);
if ("OK".equals(result)) {
// 拿到锁
}
// Spring Data Redis 2.0(StringRedisTemplate)
Boolean ok = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
注意 Spring Data Redis 1.x 的 setIfAbsent(K, V) 没有带超时参数的重载,必须分开调 expire。我们项目一开始用的是 Boot 1.5 带的老版本,这也是我踩这个坑的直接原因。后来升到 Boot 2.0 + Spring Data Redis 2.0.8 才有这个重载。
问题二:误删别人的锁
第二个坑更隐蔽。看这个时序:
T1: 线程 A 拿到锁,过期时间 30 秒
T2: 线程 A 业务逻辑跑了 35 秒(比如调用第三方支付接口超时)
T3: 第 30 秒时锁自动过期,线程 B 拿到锁
T4: 第 35 秒线程 A 执行完,finally 里 delete(lockKey)
T5: A 把 B 的锁删了!线程 C 立刻又能拿到锁
这就是误删他人锁。锁失效之后,同时有两个线程在临界区里,重复扣款又回来了。
解决思路是:删锁之前先确认这把锁是不是自己的。所以 value 不能存 "1",要存一个全局唯一标识:
String requestId = UUID.randomUUID().toString();
// 或者更实用的:线程 ID + 进程标识
String requestId = ManagementFactory.getRuntimeMXBean().getName() + ":" + Thread.currentThread().getId();
然后删除时先 get 比对再 del。但这两步又不是原子的:
// 错误写法:get 和 del 之间有窗口
if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey); // 这里锁可能已经过期并被别人持有
}
必须用 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";
public void unlock(String lockKey, String requestId) {
redisTemplate.execute(new DefaultRedisScript<Long>(UNLOCK_SCRIPT, Long.class),
Collections.singletonList(lockKey), requestId);
}
Redis 执行 Lua 脚本是单线程的,整个脚本要么全执行要么不执行,中间不会被其他命令插入。这是做分布式锁的标准姿势。
问题三:业务没跑完,锁就过期了
上面那个 35 秒的场景还有个更麻烦的地方:就算加了唯一标识不会误删,A 的锁在第 30 秒失效了,B 照样能进来。锁的超时时间到底该设多少?
设短了业务跑不完,设长了宕机后要等很久才能恢复。我们的做法是加一个续期线程(watchdog):拿到锁之后起一个后台线程,每隔 过期时间 / 3 去检查锁是否还持有,是的话就延长过期时间。
private final ScheduledExecutorService renewPool = Executors.newScheduledThreadPool(1);
public boolean tryLockWithRenew(String key, String requestId, long expireMs) {
Boolean ok = stringRedisTemplate.opsForValue()
.setIfAbsent(key, requestId, expireMs, TimeUnit.MILLISECONDS);
if (!Boolean.TRUE.equals(ok)) {
return false;
}
ScheduledFuture<?> future = renewPool.scheduleAtFixedRate(() -> {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('pexpire', KEYS[1], ARGV[2]) " +
"else return 0 end";
redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
Collections.singletonList(key), requestId, String.valueOf(expireMs));
}, expireMs / 3, expireMs / 3, TimeUnit.MILLISECONDS);
renewTasks.put(requestId, future);
return true;
}
public void unlock(String key, String requestId) {
ScheduledFuture<?> f = renewTasks.remove(requestId);
if (f != null) {
f.cancel(true); // 先停掉续期线程
}
// 再执行解锁 Lua
}
自己实现这套东西有几个坑我都踩过:续期线程抛异常后 scheduleAtFixedRate 会静默停止后续调度,必须在任务体里 try-catch;续期任务没 cancel 会导致线程池里堆满僵尸任务;应用停机时要确保所有续期任务都停掉。
最终方案:直接用 Redisson
师傅看完我这套代码说了句:"Redisson 都帮你做了,别自己造。"
Redisson 2.x 的用法非常简单:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
Config config = new Config();
config.useSingleServer()
.setAddress("redis://10.0.1.20:6379")
.setDatabase(0);
RedissonClient redisson = Redisson.create(config);
// 使用
RLock lock = redisson.getLock("pay:lock:" + orderId);
try {
if (lock.tryLock(5, 30, TimeUnit.SECONDS)) {
return doPay(orderId);
}
return false;
} finally {
lock.unlock();
}
它的看门狗(watchdog)机制就是我上面手写的那个续期逻辑,而且做得更完善:
- 不传
leaseTime调用lock()时,默认锁超时 30 秒(lockWatchdogTimeout,可配置); - 后台线程每
lockWatchdogTimeout / 3= 10 秒检查一次,还持有就重置为 30 秒; - 锁的实现是 hash 结构而不是 string,field 是线程标识,value 是重入次数,所以天然支持可重入;
- 解锁时如果重入次数大于 1 就只减 1,等于 0 时才真正删除。
我抓了下 Redisson 加锁的实际命令:
$ redis-cli monitor
1539823471.128940 [0 10.0.1.31:51234] "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil; end; ..." "1" "pay:lock:10086" "30000" "b983f7c1-...:thread-12"
确认是 hash + Lua,重入计数存在 field 的 value 里。
还有一个绕不过去的问题:主从切换
这个坑是我在做容灾演练时发现的。我们的 Redis 是一主一从 + 哨兵。场景:
T1: 客户端 A 在 master 上拿到锁
T2: master 还没把这条 SET 同步给 slave,master 挂了
T3: 哨兵把 slave 提升为新 master
T4: 客户端 B 在新 master 上拿到了同一把锁 → 两把锁同时存在
Redis 的主从复制是异步的,这个窗口客观存在。官方的解法是 Redlock 算法:部署 5 个独立的 Redis 节点(不是主从,是 5 个 master),客户端依次向 5 个节点申请锁,拿到超过半数(≥3 个)且总耗时小于锁有效时间才算成功。
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3, lock4, lock5);
try {
redLock.tryLock(5, 30, TimeUnit.SECONDS);
// ...
} finally {
redLock.unlock();
}
但 Redlock 有争议(Martin Kleppmann 那篇著名的文章就是反对它的,主要质疑点在于 GC 停顿和时钟漂移会导致锁提前失效)。我们最后没有上 Redlock,理由是:支付这种强一致场景改用数据库唯一索引 + 状态机来做幂等,锁只是辅助;而库存扣减用 Redis 的 decrby 原子操作配合数据库乐观锁,不依赖互斥锁。
具体做法是加一张去重表:
CREATE TABLE pay_record (
id bigint NOT NULL AUTO_INCREMENT,
order_id bigint NOT NULL,
request_id varchar(64) NOT NULL,
status tinyint NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order (order_id) -- 关键
) ENGINE=InnoDB;
重复请求插入时会撞唯一索引,捕获 DuplicateKeyException 直接返回"处理中"。数据库层面的约束比 Redis 锁可靠得多。
最终效果和踩坑清单
改成 Redisson + 去重表之后,跑了三周,重复扣款 0 笔。压测数据:单机 500 并发下,同一订单的重复请求全部被挡掉,加锁平均耗时 1.8ms(Redis 内网 RTT 约 0.6ms)。
清单记一下:
- 加锁必须用
SET key value NX PX timeout一条命令,不能拆成 setnx + expire; - value 存唯一标识,解锁用 Lua 脚本先比对再删;
- 超时时间要覆盖业务最大耗时,或者用 Redisson 的看门狗自动续期;
- 锁的粒度要细,
pay:lock:{orderId}而不是pay:lock,否则所有订单串行; - 不要指望 Redis 锁解决一致性问题,兜底一定放在数据库约束上;
finally里解锁前要判断当前线程是否真的持有锁,否则会抛IllegalMonitorStateException。
最后一条我也踩过:业务抛异常时 Redisson 的 unlock() 会因为锁已经过期而报错,反而把真正的业务异常覆盖了。