别再只用setIfAbsent了!Redis分布式锁的坑,从超卖案例到正确使用Lua脚本

张开发
2026/5/2 1:42:10 15 分钟阅读

分享文章

别再只用setIfAbsent了!Redis分布式锁的坑,从超卖案例到正确使用Lua脚本
从超卖事故到原子化实践Redis分布式锁的深度解构与Lua脚本实战电商大促期间某平台iPhone秒杀活动上线5分钟后后台突然出现2000台手机被同一用户重复下单的异常数据——这是典型的超卖事故。技术团队紧急排查后发现问题根源在于分布式锁实现中存在setIfAbsent与expire的非原子操作间隙。当大量请求瞬间涌入时线程A执行setIfAbsent成功后尚未设置过期时间便发生Full GC暂停此时其他线程因检测不到有效锁而重复获取资源最终导致库存校验失效。1. 分布式锁的本质缺陷与典型误区1.1 为什么简单的setIfAbsent会失效在Redis单命令原子性的表象下隐藏着组合命令的非原子风险。常见错误实现模式如下// 反模式非原子性锁获取 Boolean locked redisTemplate.opsForValue().setIfAbsent(product_123, 1); if (locked) { redisTemplate.expire(product_123, 30, TimeUnit.SECONDS); // 此处可能出现进程挂起 try { // 业务处理 } finally { redisTemplate.delete(product_123); } }这种实现存在三个致命缺陷竞态条件set与expire之间的时间差可能导致死锁误删风险未校验锁持有者身份可能删除其他线程的锁续期缺失未考虑业务执行超时导致锁提前释放1.2 分布式锁的黄金标准一个健壮的分布式锁需要满足四个核心要求特性说明常见实现缺陷互斥性同一时刻只有一个客户端能持有锁setnx竞争未处理防死锁持有者崩溃后锁能自动释放缺少过期时间设置唯一标识锁必须包含持有者标识使用固定值作为value原子操作获取锁和设置过期时间必须原子完成分开执行setnx和expire2. Lua脚本实现原子化操作2.1 加锁脚本的完整实现以下脚本将获取锁和设置过期时间合并为原子操作-- KEYS[1]: 锁键名 -- ARGV[1]: 锁值唯一标识 -- ARGV[2]: 过期时间毫秒 local key KEYS[1] local value ARGV[1] local ttl tonumber(ARGV[2]) if redis.call(set, key, value, NX, PX, ttl) then return 1 else return 0 endJava调用示例String lockScript local key KEYS[1]...; // 完整脚本见上文 RedisScriptLong script new DefaultRedisScript(lockScript, Long.class); String lockKey order_lock_20240615; String requestId UUID.randomUUID().toString(); boolean locked redisTemplate.execute(script, Collections.singletonList(lockKey), requestId, 30000) 1L;2.2 解锁的安全机制解锁时需要验证锁归属避免误删其他客户端的锁-- KEYS[1]: 锁键名 -- ARGV[1]: 预期锁值 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end关键提示requestId建议使用客户端IP线程ID时间戳组合避免UUID重复概率3. 高并发场景下的锁优化策略3.1 锁等待的优雅实现当锁被占用时直接返回失败会影响用户体验。合理的重试机制应该设置最大等待时间如200ms采用指数退避策略添加随机抖动避免惊群效应public boolean tryLock(String key, String value, long expireMs, long waitMs, int maxRetries) { long start System.currentTimeMillis(); int retryCount 0; Random random new Random(); do { if (acquireLock(key, value, expireMs)) { return true; } // 指数退避随机抖动 long sleepMs Math.min( 100 * (1 retryCount) random.nextInt(50), waitMs ); Thread.sleep(sleepMs); retryCount; } while (System.currentTimeMillis() - start waitMs retryCount maxRetries); return false; }3.2 锁续期的最佳实践对于可能长时间执行的任务需要实现看门狗机制private ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); public void startWatchDog(String key, String value, long expireMs) { scheduler.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(script, Long.class), Collections.singletonList(key), value, String.valueOf(expireMs) ); }, expireMs / 3, expireMs / 3, TimeUnit.MILLISECONDS); }4. 生产环境中的容错设计4.1 Redis集群下的特殊考量在Redis Cluster环境中需要注意确保所有锁操作都在同一slot可使用hash tag网络分区时的处理策略主从切换时的锁状态同步// 使用hash tag确保键落在同一slot String lockKey {order_lock}_20240615; // Redlock算法的简化实现生产环境建议使用Redisson public boolean clusterLock(ListRedisNode nodes, String key, String value, long expireMs) { int successCount 0; for (RedisNode node : nodes) { try { if (tryLockOnNode(node, key, value, expireMs)) { successCount; } } catch (Exception e) { // 记录日志但继续尝试其他节点 } } return successCount nodes.size() / 2; }4.2 监控与告警体系完善的锁监控应包含以下指标锁等待时间分布锁占用时长百分位锁竞争失败率锁过期事件计数# Prometheus监控示例 redis_distributed_lock_wait_seconds_bucket{nameorder_lock,le0.1} 142 redis_distributed_lock_hold_seconds{nameorder_lock} 2.7 redis_distributed_lock_failures_total{reasoncontention} 56在Kubernetes环境中曾经遇到过一个典型案例某个Pod由于CPU限制导致GC频繁使得锁续期线程被延迟执行最终触发了锁过期。通过调整JVM参数和Pod资源限制同时将锁默认过期时间从30秒延长到60秒问题得到彻底解决。这提醒我们分布式锁的正确性不仅取决于代码实现还与运行时环境密切相关。

更多文章