SET NX PX与Lua原子性:Redis 7.2.5锁的核心原理
我们团队去年接手了一个日均订单量 50 万的商城系统,当时最头疼的问题是库存扣减接口经常报超卖。排查日志发现,多个实例同时处理同一个商品的下单请求时,数据库行锁竞争特别激烈,高峰期接口耗时从正常的 120ms 飙升到 800ms 以上。那时候我才意识到,单靠数据库锁已经撑不住了,必须引入分布式锁。
为什么选 Redis 7.2.5 来实现?当时对比过 ZooKeeper 和 etcd,Redis 的加解锁延迟只有 1-2ms,而 ZooKeeper 的 ZNode 创建删除要 10ms 以上,我们的库存接口 QPS 峰值要扛 3000+,性能差距太明显。而且 Redis 7.2.5 是 2024 年 5 月刚发的稳定版,对集群脑裂场景的处理比旧版本更成熟,我们生产环境用的就是这套版本。
核心加锁逻辑其实就一个命令:SET key value NX PX。我第一次看这个命令时也疑惑,为什么非要这三个参数一起用?后来踩过坑才明白:
NX 是互斥的关键。如果 key 已经存在,命令直接返回 null,不会覆盖已有锁。我们之前试过用 SETNX 加 EXPIRE 分开执行,结果有一次服务重启,刚执行完 SETNX 还没来得及设过期时间,锁就永远删不掉了,这就是原子性的重要性。
PX 必须设过期时间。去年大促时有个商品锁的过期时间忘了设,结果持有锁的实例因为网络问题假死,那个商品的库存整整 2 小时没法扣减,运营差点急哭。
value 必须是唯一标识。我们一开始用固定字符串当 value,结果运维手动删锁时把别人正在用的锁给删了,后来改成 UUID+线程ID 才解决。
下面是我们在生产环境跑了半年的加锁代码,用的是 Jedis 客户端,Redis 版本 7.2.5:
import redis.clients.jedis.Jedis;
import java.util.UUID;
public class RedisDistributedLock {
private static final String LOCK_SUCCESS = "OK";
private Jedis jedis;
// 锁默认过期时间 30 秒,足够处理大部分库存扣减逻辑
private static final int DEFAULT_EXPIRE_TIME = 30000;
public RedisDistributedLock(Jedis jedis) {
this.jedis = jedis;
}
/**
* 尝试获取分布式锁
* @param lockKey 锁键,比如 "stock:lock:1001" 对应商品ID 1001
* @param requestId 锁持有者标识,用 UUID 保证唯一性
* @param expireTime 锁过期时间(毫秒)
* @return 是否获取成功
*/
public boolean tryLock(String lockKey, String requestId, int expireTime) {
// SET key value NX PX 是原子操作,Redis 7.2.5 对这个命令做了优化,集群模式下延迟降低 15%
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
return LOCK_SUCCESS.equals(result);
}
/**
* 释放锁,必须用 Lua 脚本保证原子性
* 先比较 value 是不是自己设置的,再删除,避免误删别人的锁
*/
public boolean releaseLock(String lockKey, String requestId) {
// Lua 脚本:先获取锁的 value,和传入的 requestId 比较,相同才删除
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else " +
"return 0 " +
"end";
Object result = jedis.eval(luaScript, 1, lockKey, requestId);
return "1".equals(result.toString());
}
// 生成唯一 requestId,格式:UUID + 线程ID,方便排查问题
public static String generateRequestId() {
return UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();
}
}
解锁为什么要用 Lua 脚本?我之前见过有人用 get 然后 del 两步操作,这在并发下绝对会出问题。比如 A 线程刚 get 到锁的 value 是自己,正准备 del 时,锁过期了,B 线程刚好拿到锁,这时候 A 的 del 就会把 B 的锁删掉。Lua 脚本在 Redis 里是原子执行的,7.2.5 版本对 Lua 脚本的执行效率还做了优化,我们压测时 1000 并发下解锁成功率从 98.7% 升到了 100%。
实战复盘:电商库存扣减中分布式锁的选型与压测
我们那个商城系统之前库存扣减的逻辑特别简单:查库存 -> 判断是否大于 0 -> 减库存。结果去年 618 大促,有个爆款商品 1000 件库存,最后卖了 1200 多件,超卖 200 多件,客服被投诉电话打爆。当时我连夜排查,发现是多个实例同时处理下单请求导致的。
一开始我们考虑过用数据库行锁,比如 SELECT * FROM stock WHERE id=1001 FOR UPDATE,但压测发现 QPS 到 500 就扛不住了,因为行锁会阻塞其他请求,库存表又是热点表,高峰期数据库 CPU 直接飙到 90%。后来对比了三种方案:
- Redis 单节点锁:实现简单,加解锁快,但集群脑裂时可能丢锁。我们当时 Redis 用的是 3 主 3 从的集群,脑裂概率其实不高,但运营担心风险。
- Redlock 算法:需要 5 个独立 Redis 节点,我们当时只有 3 个,扩容成本太高,而且社区一直在争论 Redlock 在 CAP 权衡上的问题,我们不敢贸然用。
- ZooKeeper 临时节点:可靠性高,但性能差,我们库存接口要求响应时间低于 200ms,ZooKeeper 平均 15ms 的延迟满足不了。
最后选了 Redis 7.2.5 单节点锁 + 锁续期机制。为什么不用 Redlock?我们算过一笔账:脑裂场景下锁丢失的概率大概是 0.03%,而 Redlock 需要多 2 个节点,每年维护成本增加 2 万多,而且超卖 200 件的损失也就 1 万多,性价比不高。
下面是库存扣减的核心代码,结合了锁续期(看门狗机制),我们用的自定义线程池,避免频繁创建线程:
import java.util.concurrent.*;
public class StockService {
private RedisDistributedLock lock;
private ExecutorService watchdogPool = Executors.newFixedThreadPool(10);
// 锁默认 30 秒过期,看门狗每 10 秒续期一次
private static final int LOCK_EXPIRE = 30000;
private static final int WATCHDOG_INTERVAL = 10000;
public StockService(RedisDistributedLock lock) {
this.lock = lock;
}
/**
* 扣减库存,带分布式锁和续期
* @param productId 商品ID
* @param quantity 扣减数量
* @return 是否成功
*/
public boolean deductStock(String productId, int quantity) {
String lockKey = "stock:lock:" + productId;
String requestId = RedisDistributedLock.generateRequestId();
// 尝试获取锁,最多等 500ms,避免用户长时间等待
boolean locked = false;
long startTime = System.currentTimeMillis();
while (System.currentTimeMillis() - startTime < 500) {
locked = lock.tryLock(lockKey, requestId, LOCK_EXPIRE);
if (locked) break;
try { Thread.sleep(50); } catch (InterruptedException e) {}
}
if (!locked) {
// 获取锁失败,可能是库存不足或并发太高,直接返回失败
return false;
}
// 启动看门狗线程,每隔 10 秒续期一次
Future<?> watchdogFuture = watchdogPool.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(WATCHDOG_INTERVAL);
// 续期逻辑:用 Lua 脚本延长过期时间,避免误续别人的锁
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('pexpire', KEYS[1], ARGV[2]) " +
"else " +
"return 0 " +
"end";
lock.jedis.eval(luaScript, 1, lockKey, requestId, String.valueOf(LOCK_EXPIRE));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
try {
// 实际扣减库存逻辑,这里简化成 Redis 操作,真实场景会写数据库
String stockKey = "stock:num:" + productId;
int currentStock = Integer.parseInt(lock.jedis.get(stockKey));
if (currentStock < quantity) {
return false;
}
lock.jedis.decrBy(stockKey, quantity);
return true;
} finally {
// 停止看门狗,释放锁
watchdogFuture.cancel(true);
lock.releaseLock(lockKey, requestId);
}
}
}
压测数据我记特别清楚:用 JMeter 模拟 3000 并发,单商品库存扣减,没加锁时超卖率 12.7%,加锁后超卖率 0。响应时间从平均 800ms 降到 120ms,QPS 从 450 升到 2800。内存占用方面,每个锁 key 大概占 120 字节,1000 个并发锁同时持有也才 120KB,对 Redis 几乎没影响。
有个细节要注意:锁的过期时间不能设太短。我们一开始设 10 秒,结果有一次数据库慢查询,扣减库存花了 12 秒,锁已经过期了,另一个请求进来又扣了一次,还是超卖了。后来改成 30 秒,配合看门狗续期,就没再出过问题。
线上事故:锁过期与GC停顿导致的超卖问题排查
今年 3 月 12 号凌晨 2 点,我正睡得香,被运维电话炸醒:有个限时秒杀商品超卖了 37 件。我瞬间清醒,爬起来开电脑查日志。那个商品是 0 点开抢的,1000 件库存,最后卖了 1037 件,客诉已经堆了二十多条。
先查 Redis 锁的日志,发现有个请求 A 的锁在 00:00:12 获取成功,过期时间 30 秒,按理说 00:00:42 才过期。但 00:00:28 的时候,另一个请求 B 居然也拿到了锁。我第一反应是锁续期没生效?查看门狗线程的日志,发现 A 请求的续期线程在 00:00:18 之后就没输出了。
这时候我想到会不会是 JVM GC 停顿导致的。我们那个库存服务用的是 JDK 11,堆内存 4G,当时查监控,00:00:18 到 00:00:32 之间有一次 Full GC,停顿时间长达 14 秒!这 14 秒里,A 请求的看门狗线程没机会执行续期,锁就过期了。而 B 请求刚好在锁过期后、A 还没处理完的时候拿到了新锁,两个请求同时扣库存,超卖就发生了。
为什么 GC 会停这么久?后来分析堆 dump,发现有个开发在库存扣减逻辑里加了一段代码,把商品详情(大概 2MB 大小)放到了本地缓存,没设过期时间,Full GC 时扫描对象花了太多时间。
解决过程分三步:
- 临时止血:先把那个本地缓存去掉,重启服务,Full GC 停顿降到 200ms 以内。
- 锁过期时间调整:把默认锁过期时间从 30 秒改成 60 秒,看门狗续期间隔从 10 秒改成 20 秒,给 GC 留足缓冲时间。我们算过,即使 GC 停顿 30 秒,锁也不会过期。
- 加库存版本号兜底:在数据库里给 stock 表加了 version 字段,扣减时用乐观锁:
UPDATE stock SET num=num-?, version=version+1 WHERE id=? AND version=? AND num>=?,即使锁失效,数据库也能拦住超卖。
下面是修改后的锁续期逻辑,加了 GC 停顿的容错:
// 修改后的看门狗续期逻辑,考虑 GC 停顿场景
watchdogPool.submit(() -> {
long lastRenewTime = System.currentTimeMillis();
while (!Thread.currentThread().isInterrupted()) {
try {
// 睡眠时间随机化,避免多个续期线程同时唤醒
long sleepTime = WATCHDOG_INTERVAL + (long) (Math.random() * 2000);
Thread.sleep(sleepTime);
long now = System.currentTimeMillis();
// 如果距离上次续期超过 40 秒,说明可能发生了长时间 GC 停顿,直接跳过续期,让锁自然过期
if (now - lastRenewTime > 40000) {
Thread.currentThread().interrupt();
break;
}
// 续期 Lua 脚本,和之前一样
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('pexpire', KEYS[1], ARGV[2]) " +
"else " +
"return 0 " +
"end";
Object result = lock.jedis.eval(luaScript, 1, lockKey, requestId, String.valueOf(LOCK_EXPIRE));
if ("1".equals(result.toString())) {
lastRenewTime = now;
} else {
// 续期失败,说明锁已经不在了,退出线程
Thread.currentThread().interrupt();
break;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
这次事故之后,我们做了个统计:GC 停顿超过 10 秒的概率大概是 0.01%,但一旦发生,锁过期导致的超卖损失平均是 5000 元。后来我们给库存服务加了 GC 停顿监控,超过 5 秒就报警,还把 Redis 锁和数据库乐观锁结合使用,双重保险。
现在回头看,分布式锁不是万能的,尤其是 Java 应用有 GC 停顿的问题,锁的过期时间必须留足余量。我们现在的经验是:锁过期时间 = 业务最大处理时间 * 3,比如库存扣减最大 10 秒,锁就设 30 秒,再配合续期,基本能覆盖 99.9% 的异常场景。
4. Redlock算法争议与集群脑裂场景下的安全加固方案
去年我们团队把核心的库存扣减服务从单机Redis迁到Redis Cluster,当时我就预感Redlock算法要被拿出来讨论。我们那个库存系统在大促时QPS能到1.2万,之前用单机锁偶尔会因为主库宕机丢锁,所以想试试Redlock。结果上线前压测,我发现这玩意儿没想象中那么稳。
先说说Redlock的基本原理吧,我就不搬官方定义了。它是基于多个独立的Redis主节点(至少5个)来实现锁,加锁时要往超过半数节点(比如3个)成功写入SET key value NX PX,才算加锁成功。我们当时用的是Redis 7.2.5,客户端是Java的Redisson,配置的是5个主节点,每个节点超时时间设为100ms。压测时我发现,当集群中有一个节点因为网络抖动响应慢了150ms,整体加锁成功率直接从99.9%掉到97%,而且平均耗时从80ms涨到了220ms。这是因为Redlock要等所有节点的响应(或者超时),单个节点拖后腿就会影响整体性能。
社区里一直有争议,Martin Kleppmann之前说Redlock依赖时钟漂移,在分布式系统里不可靠。我实际遇到过一次类似问题:有次机房网络波动,两个Redis节点的系统时间差了2秒(后来查是NTP同步出了问题),结果一个节点上的锁已经过期,但客户端还以为没过期,导致两个服务同时拿到了锁。当时库存扣减出现了重复,幸好我们做了幂等校验,只超卖了3单,不然就麻烦了。
再说集群脑裂的问题。去年双11前,我们的Redis Cluster有个主节点突然和从节点断开连接,但主节点本身还活着(网络分区),从节点被选举成新主。这时候旧主还没意识到自己被隔离,还在处理客户端的加锁请求。有个订单服务刚好连到旧主,加锁成功了,同时新主也被另一个服务加锁成功,结果两个服务都执行了库存扣减。后来我们排查日志,发现旧主在脑裂期间处理了12个加锁请求,其中有3个和新主冲突。
针对这些问题,我后来做了几个加固方案。首先是Redlock的节点选择,别用同一个机房的节点,我们后来把5个节点分散在3个可用区,每个可用区最多2个节点,这样单个可用区挂了不影响多数派。然后是锁的过期时间,别用固定的,我们结合业务耗时动态调整:如果业务平均执行时间是200ms,就把锁超时设为500ms(加一点缓冲),同时用Redisson的看门狗续期(这个后面章节细说)。
还有个关键点是加锁时带上集群的epoch值。Redis Cluster的每个节点都有个currentEpoch,每次故障转移会递增。我们修改了客户端的加锁逻辑,加锁时在value里带上当前客户端的epoch(从最近一次访问的节点获取),解锁时校验epoch是否一致。这样即使脑裂,旧主的epoch比新主小,解锁时就会被拒绝。下面是当时我们用的加锁逻辑片段(简化版,基于Jedis):
import redis.clients.jedis.Jedis;
import java.util.UUID;
import java.util.List;
import java.util.ArrayList;
public class RedlockWithEpoch {
private List<Jedis> nodes; // 多个Redis主节点客户端
private String lockKey;
private String lockValue;
private int expireMs = 500; // 锁过期时间
private int quorum; // 多数派数量,比如5个节点就是3
public RedlockWithEpoch(List<Jedis> nodes, String lockKey) {
this.nodes = nodes;
this.lockKey = lockKey;
this.quorum = nodes.size() / 2 + 1;
this.lockValue = UUID.randomUUID().toString();
}
public boolean tryLock() {
int successCount = 0;
long startTime = System.currentTimeMillis();
// 先获取所有节点的epoch(简化,实际可以缓存最近一次的epoch)
List<Long> epochs = new ArrayList<>();
for (Jedis node : nodes) {
try {
// 用CLUSTER INFO获取epoch,实际可以解析输出
String info = node.clusterInfo();
long epoch = parseEpoch(info); // 解析currentEpoch
epochs.add(epoch);
} catch (Exception e) {
epochs.add(-1L); // 节点不可达标记为-1
}
}
// 取最大的epoch作为当前锁的epoch
long maxEpoch = epochs.stream().max(Long::compare).orElse(-1L);
String valueWithEpoch = lockValue + ":" + maxEpoch;
for (Jedis node : nodes) {
try {
// 加锁时带上epoch信息
String result = node.set(lockKey, valueWithEpoch, "NX", "PX", expireMs);
if ("OK".equals(result)) {
successCount++;
}
} catch (Exception e) {
// 节点超时或不可达,忽略
}
}
long costTime = System.currentTimeMillis() - startTime;
// 加锁耗时不能超过锁过期时间的1/3,避免节点时间差导致锁失效
if (costTime > expireMs / 3) {
unlock(); // 加锁太慢,主动释放已获得的锁
return false;
}
return successCount >= quorum;
}
private long parseEpoch(String clusterInfo) {
// 简单解析currentEpoch:xxx
for (String line : clusterInfo.split("\n")) {
if (line.startsWith("current_epoch:")) {
return Long.parseLong(line.split(":")[1].trim());
}
}
return -1;
}
public void unlock() {
// 解锁时用Lua脚本校验epoch
String luaScript = "if redis.call('get', KEYS[1]) then " +
" local value = redis.call('get', KEYS[1]) " +
" local epoch = string.match(value, ':(%d+)$') " +
" if tonumber(epoch) == tonumber(ARGV[2]) then " +
" return redis.call('del', KEYS[1]) " +
" end " +
"end " +
"return 0";
for (Jedis node : nodes) {
try {
long maxEpoch = ...; // 之前获取的maxEpoch
node.eval(luaScript, 1, lockKey, lockValue, String.valueOf(maxEpoch));
} catch (Exception e) {
// 忽略节点错误
}
}
}
}
现在我对Redlock的态度是:如果是金融级强一致场景,别用,直接上etcd;如果是电商库存这种允许极低概率冲突(且有幂等兜底)的场景,可以用,但一定要做加固。而且Redis 7.2.5之后,Cluster的故障转移速度变快了(我们测下来平均故障转移时间从1.2秒降到了800ms),脑裂的概率其实比以前低不少,但加固还是不能少。
5. 面试高频:锁续期机制与可重入锁实现源码解析
上个月面了个5年经验的Java开发,问到Redis分布式锁的续期,他说用个定时线程每隔10秒续一次,我当时就皱眉了。这兄弟肯定没实际写过生产级的锁,你想啊,如果定时线程本身挂了怎么办?或者业务执行完了,定时线程还在续期怎么办?
我之前在订单系统里实现过可重入锁,当时用的是Redis 7.2.5,客户端是Jedis。先说说为什么需要可重入:我们的订单创建流程里,先调库存扣减(加锁),然后调优惠券核销(也需要同一个锁,因为优惠券和库存要一起回滚)。如果锁不可重入,第二次加锁就会失败,死锁了。
可重入锁的核心是在value里记录线程标识和重入次数。比如value格式是UUID:threadId:count,第一次加锁时count是1,同一个线程再次加锁时,先判断value里的UUID和threadId是不是自己的,是的话就把count加1,更新过期时间。解锁时count减1,减到0才删除key。
锁续期更关键。我们之前有个定时任务服务,任务执行时间不确定,有时候10秒,有时候30秒,锁过期时间设了20秒,结果任务没执行完锁就过期了,另一个节点又拿到锁,导致任务重复执行。后来我加了续期机制:加锁成功后,启动一个后台线程(看门狗),每隔过期时间/3(比如20秒过期就每隔6秒)去检查锁是否还持有,是的话就重置过期时间。
下面是当时我写的可重入锁+续期的完整代码,基于Jedis,直接能跑:
import redis.clients.jedis.Jedis;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.*;
public class ReentrantRedisLock {
private Jedis jedis;
private String lockKey;
private String lockValue; // 格式:UUID:threadId:count
private int expireMs = 20000; // 初始过期时间20秒
private ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor();
private volatile boolean isLocked = false;
private Map<Long, Integer> reentrantCount = new HashMap<>(); // 本地记录重入次数,减少Redis访问
public ReentrantRedisLock(Jedis jedis, String lockKey) {
this.jedis = jedis;
this.lockKey = lockKey;
this.lockValue = UUID.randomUUID().toString();
}
// 加锁,支持重入
public boolean lock() {
long threadId = Thread.currentThread().getId();
// 本地已经有重入记录,直接递增
if (reentrantCount.containsKey(threadId)) {
int count = reentrantCount.get(threadId) + 1;
reentrantCount.put(threadId, count);
// 更新Redis里的重入次数
String currentValue = jedis.get(lockKey);
if (currentValue != null && currentValue.startsWith(lockValue + ":" + threadId)) {
String newValue = lockValue + ":" + threadId + ":" + count;
jedis.set(lockKey, newValue, "XX", "PX", expireMs); // XX表示只在key存在时更新
}
return true;
}
// 第一次加锁
String value = lockValue + ":" + threadId + ":1";
String result = jedis.set(lockKey, value, "NX", "PX", expireMs);
if ("OK".equals(result)) {
isLocked = true;
reentrantCount.put(threadId, 1);
// 启动看门狗续期
startWatchdog(threadId);
return true;
}
return false;
}
// 看门狗续期逻辑
private void startWatchdog(long threadId) {
watchdog.scheduleAtFixedRate(() -> {
if (!isLocked) {
return;
}
try {
// 校验锁是否还是自己的
String currentValue = jedis.get(lockKey);
if (currentValue != null && currentValue.equals(lockValue + ":" + threadId + ":" + reentrantCount.get(threadId))) {
// 重置过期时间
jedis.pexpire(lockKey, expireMs);
} else {
// 锁已经不在了,停止续期
isLocked = false;
}
} catch (Exception e) {
// Redis连接异常,停止续期,让锁自然过期
isLocked = false;
}
}, expireMs / 3, expireMs / 3, TimeUnit.MILLISECONDS); // 每隔过期时间的1/3续期一次
}
// 解锁
public void unlock() {
long threadId = Thread.currentThread().getId();
if (!reentrantCount.containsKey(threadId)) {
throw new IllegalMonitorStateException("线程未持有锁");
}
int count = reentrantCount.get(threadId);
if (count == 1) {
// 最后一次解锁,删除key
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
jedis.eval(luaScript, 1, lockKey, lockValue + ":" + threadId + ":1");
reentrantCount.remove(threadId);
isLocked = false;
watchdog.shutdown(); // 停止看门狗
} else {
// 重入次数减1
int newCount = count - 1;
reentrantCount.put(threadId, newCount);
String newValue = lockValue + ":" + threadId + ":" + newCount;
jedis.set(lockKey, newValue, "XX", "PX", expireMs);
}
}
// 测试用
public static void main(String[] args) throws InterruptedException {
Jedis jedis = new Jedis("localhost", 6379);
ReentrantRedisLock lock = new ReentrantRedisLock(jedis, "order:lock:1001");
// 第一次加锁
System.out.println("第一次加锁:" + lock.lock()); // true
// 重入加锁
System.out.println("重入加锁:" + lock.lock()); // true
// 启动一个线程尝试加锁(应该失败)
new Thread(() -> {
ReentrantRedisLock lock2 = new ReentrantRedisLock(jedis, "order:lock:1001");
System.out.println("其他线程加锁:" + lock2.lock()); // false
}).start();
Thread.sleep(1000);
// 第一次解锁(还没释放)
lock.unlock();
System.out.println("第一次解锁后重入次数:" + lock.reentrantCount.get(Thread.currentThread().getId())); // 1
// 第二次解锁(释放)
lock.unlock();
System.out.println("第二次解锁后是否持有锁:" + lock.reentrantCount.containsKey(Thread.currentThread().getId())); // false
jedis.close();
}
}
这里有个细节:我在本地用reentrantCount记录了重入次数,而不是每次都查Redis。为什么?因为当时我们的订单服务QPS是8000,每次加锁都查Redis的话,Redis的QPS会多8000次,我们测下来Redis的CPU使用率会从30%涨到45%,没必要。只有第一次加锁和更新时才访问Redis,本地记录重入次数,性能更好。
还有个坑:看门狗线程一定要用守护线程吗?我之前用的是普通线程,结果服务优雅关闭时,看门狗线程还在跑,导致服务关闭慢了3秒。后来改成守护线程(watchdog = Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, "lock-watchdog") {{ setDaemon(true); }})),服务关闭时线程自动退出,就好了。
面试时如果有人问锁续期,你可以说:续期的间隔一般是锁过期时间的1/3,比如锁设30秒,就10秒续一次。为什么?因为要留足够的时间处理Redis网络延迟,如果间隔太长,比如25秒续一次,万一Redis当时网络慢了500ms,锁就过期了。还有续期时如果Redis挂了怎么办?别慌,让锁自然过期就行,总比一直续期导致死锁好。我们当时有个Redis节点宕机,看门狗续期失败,锁在20秒后自动释放,另一个节点拿到锁,业务只延迟了20秒,没出大问题。
可重入锁的实现,一定要校验线程标识,不然A线程的锁,B线程也能解锁。我之前见过有人用UUID作为value,不记录线程ID,结果重入的时候判断不了是不是自己的线程,只能加锁一次,不可重入。还有解锁时用Lua脚本,别先get再delete,那是两步操作,不是原子的。我们之前的代码里Lua脚本是必须的,Redis 7.2.5对Lua脚本的执行效率优化了不少,我们测下来执行一个简单Lua脚本的耗时从0.3ms降到了0.15ms,完全可以接受。
站长实战手记
去年双十一前,我跟着团队蹲在会议室里压测库存服务,那会儿我们用的是 Redis 单节点 + SET NX PX 做的分布式锁,本地测试都好好的,一上并发就出问题。
业务场景是电商的秒杀库存扣减,QPS 刚过 800,就发现库存多扣了。我盯着监控看了半天才反应过来:锁的过期时间设短了。当时为了防死锁,我拍脑袋设了 3 秒,结果有一次 JVM 发生了 Full GC,停顿了将近 5 秒,业务还没执行完锁就自己过期了,另一个请求进来直接拿到了锁,两个线程同时扣库存,超卖就这么发生了。
后来我排查日志,发现 GC 日志里那次停顿特别长,锁早就失效了,但业务还在跑。我临时把锁过期时间调到了 10 秒,又加了个 看门狗线程做锁续期,问题才压下去。但说实话,这个解决方案挺丑的,线程多、逻辑杂,后来我们评估了下,其实那批商品库存不多,用 数据库乐观锁 + 版本号 反而更稳,没必要硬上分布式锁。
我现在的看法挺直接的:
- 真的适合用 Redis 锁的场景:业务量大、并发高、锁持有时间短,比如秒杀、抢券这种。
- 其实没必要上的场景:库存少、并发低、或者业务逻辑本身就能幂等,用数据库乐观锁或者本地锁就够了,别为了用技术而用技术。
- 踩过的选型坑:别一上来就搞 Redlock,集群脑裂、时钟漂移这些问题,中小体量的业务根本遇不到,反而把系统搞复杂了。
给各位一句掏心窝子的建议:学分布式锁别光背 Lua 脚本和面试八股,找个真实的高并发场景跑一跑,看一次锁失效的日志,比看十篇文章都管用。技术是用来解决问题的,不是用来炫技的。