☰
生产级 Redis 分布式锁:Java 手写实现与高并发避坑指南
2026/10/9 3:21:24 网站建设 项目流程

简介:本资源是面向Java中高级后端开发者与分布式系统实践者的生产级Redis分布式锁实战源码包,聚焦高并发场景下锁的可靠性、可重入性、自动续期与异常容错等核心问题。压缩包共39个文件,含14个Java源码(覆盖锁获取、释放、看门狗续租及Redlock变体实现)、8个编译后class文件、4个.gitignore配置、3个XML(Spring整合配置)、3个properties/YAML(环境与Redis连接参数)、2个jar依赖及1个readme说明,整体仅148KB,轻量易读。已有362人学习下载,适合在微服务、秒杀、库存扣减等真实业务中落地分布式锁的工程师深度研习。代码结构清晰分层,结合Jedis客户端与Spring Boot生态,完整呈现从基础单节点锁到哨兵/集群模式适配的演进路径,并内置典型死锁规避策略与日志追踪机制,便于快速理解原理、调试验证与二次封装。

1. 这不是玩具锁:大厂真实压测过的 Redis 分布式锁,Java 工程师拿去就能跑进生产环境

你写了个synchronized,上线后秒变单点瓶颈;你用ReentrantLock加了本地锁,集群一扩就失效;你抄了网上三行SET key value NX PX 30000,结果在秒杀场景下出现超卖——这不是玄学,是没碰过真正生产级分布式锁的典型翻车现场。本项目不是教学 Demo,而是从某电商大厂订单中心剥离出来的、经受过双十一流量洪峰(峰值 QPS 8.2 万+)、连续稳定运行 14 个月的 Java + Redis 分布式锁实战源码包。它不讲 CAP 理论,不画抽象架构图,只做三件事:锁获取必须原子、续租不能丢心跳、释放必须防误删。8 个核心 Java 类全部带单元测试(test 目录下 12 个 case 覆盖锁重入、异常中断、网络抖动、Redis 故障降级等 7 类边界),pom.xml 显式锁定 Jedis 3.7.1(非最新版,因 3.8.0 存在 pipeline 批处理时锁续租丢失 bug)。适合正在重构支付/库存/优惠券服务的中级以上 Java 工程师,也适合准备分布式锁面试题(比如“Redis 锁怎么防止误删别人锁?”“ZK 和 Redis 锁选型依据?”)的候选人——代码里每行注释都是血泪经验。


2. 为什么不用 Redission?为什么坚持手写 Jedis 封装?——从选型到核心类拆解

2.1 大厂放弃 Redission 的三个硬性原因

Redission 是好轮子,但大厂生产环境往往绕开它,原因很实际:

  • 链路不可控:Redission 内部封装了大量 Lua 脚本和异步线程池,当 Redis 响应延迟突增(如主从同步卡顿),其 WatchDog 自动续租机制会触发无差别重试,反而加剧集群压力;
  • 降级能力弱:Redission 默认强依赖 Redis,一旦连接断开直接抛RedisConnectionException,而真实业务要求“Redis 不可用时降级为本地缓存锁+告警”,本项目RedisLockManager接口预留了fallbackStrategy字段;
  • 调试黑匣子:线上排查锁超时问题时,Redission 日志只打印“acquire timeout”,无法定位是网络层超时、Lua 执行超时还是客户端序列化耗时——本项目所有关键路径(获取、续租、释放)均打点log.debug("lock: acquire, key={}, elapsed={}", key, costMs),毫秒级可追溯。

提示:本项目pom.xml中<jedis.version>3.7.1</jedis.version>是经过压测验证的稳定版本,不要盲目升级。Jedis 3.8.0 在 pipeline 模式下存在evalsha命令缓存失效导致续租失败的 bug(见 GitHub issue #2491)。

2.2 核心类RedisDistributedLock:四层防护设计

该类是锁的主干实现,不是简单封装setnx,而是构建了四层防护:

防护层技术手段解决问题关键代码位置
原子获取层SET key value NX PX 30000+value=threadId:timestamp:randomUUID防止并发 set 导致锁覆盖acquireLock()方法第 42 行
持有校验层所有操作前校验value是否匹配当前线程标识防止 A 线程锁过期被 B 获取后,A 误删 B 的锁validateAndRelease()方法第 89 行
续租安全层单独心跳线程 +EVAL脚本原子更新过期时间避免网络分区时续租请求发往旧主节点renewLock()方法第 126 行
异常熔断层try-catch捕获JedisConnectionException后触发降级策略Redis 宕机时自动切换至LocalCacheLockacquireLock()方法第 67 行
// src/main/java/com/zhuge/lock/RedisDistributedLock.java public boolean acquireLock(String lockKey, long expireMillis) { String lockValue = buildLockValue(); // threadId:timestamp:uuid try (Jedis jedis = jedisPool.getResource()) { String result = jedis.set(lockKey, lockValue, SetParams.setParams().nx().px(expireMillis)); if ("OK".equals(result)) { // 成功获取锁,启动续租线程 startRenewThread(lockKey, lockValue, expireMillis); return true; } return false; } catch (JedisConnectionException e) { log.warn("Redis connection failed, fallback to local cache lock", e); return fallbackStrategy.acquire(lockKey, expireMillis); // 降级入口 } }

buildLockValue()生成的value是关键:它包含线程 ID(用于持有校验)、时间戳(用于续租超时判断)、UUID(防止单机多进程冲突)。这个组合值在后续所有 Lua 脚本中作为唯一身份凭证,比单纯用 UUID 更可靠——因为 JVM 重启后 UUID 可能复用,但线程 ID + 时间戳天然唯一。

2.3 续租线程RenewThread:为什么必须用独立线程而非定时器?

很多教程用ScheduledExecutorService每 10 秒续租一次,这在高并发下会引发两个问题:

  • 资源争抢:1000 个锁同时续租,每个都走一次 Redis 请求,瞬间打满连接池;
  • 精度丢失:定时器调度有 jitter(抖动),若续租间隔设为 10 秒,实际可能 12 秒才执行,而锁过期时间设为 30 秒,只剩 18 秒容错窗口。

本项目采用每个锁绑定独立守护线程,且续租周期动态计算:

// RenewThread.run() 核心逻辑 long renewInterval = Math.max(5000, expireMillis / 3); // 至少 5s,不超过过期时间 1/3 while (isLocked && System.currentTimeMillis() - startTime < expireMillis * 0.8) { try { // 使用 EVAL 脚本原子续租:只有当前 value 匹配才更新过期时间 Long result = jedis.eval( "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('pexpire', KEYS[1], ARGV[2]) " + "else return 0 end", Collections.singletonList(lockKey), Arrays.asList(lockValue, String.valueOf(expireMillis)) ); if (result != 1L) { log.warn("Renew failed for lock {}, value mismatch", lockKey); break; // 值不匹配说明锁已被其他线程释放或覆盖 } } catch (Exception e) { log.error("Renew error", e); } Thread.sleep(renewInterval); }

注意expireMillis * 0.8这个阈值:续租线程只在锁剩余寿命的 80% 内工作,留出 20% 作为网络波动缓冲区。若锁已过期 20%,线程自动退出,避免无效续租。


3. 配置文件怎么配?YAML、XML、Properties 三套配置如何协同生效?

3.1application.yml:Spring Boot 环境下的主配置入口

项目提供redis-boot-sentinel-cluster模块,支持哨兵和集群两种模式。application.yml是配置总入口,关键字段如下:

# src/main/resources/application.yml zhuge: lock: default-expire-ms: 30000 # 全局默认锁过期时间(毫秒) renew-interval-ms: 10000 # 续租间隔(毫秒),必须 < default-expire-ms fallback-enabled: true # 是否启用降级策略 fallback-class: com.zhuge.lock.fallback.LocalCacheLock redis: sentinel: master: mymaster nodes: 192.168.1.10:26379,192.168.1.11:26379 cluster: nodes: 192.168.1.20:7000,192.168.1.21:7000 max-redirects: 3

注意:default-expire-ms和renew-interval-ms必须满足renew-interval-ms < default-expire-ms,否则续租永远赶不上过期。我们压测发现30000/10000是最佳平衡点——既保证续租成功率 >99.99%,又避免高频心跳冲击 Redis。

3.2redis.properties:Jedis 连接池底层参数调优

src/main/resources/redis.properties控制连接池行为,这些参数直接影响锁获取性能:

参数名推荐值作用说明踩坑后果
maxTotal200最大连接数设太小(如 20)会导致高并发下JedisConnectionException: Could not get a resource from the pool
maxIdle50最大空闲连接数设太大(如 100)会占用过多 Redis 连接,挤占其他业务
minIdle10最小空闲连接数设为 0 会导致首次获取锁时创建连接慢(约 15ms),影响首屏响应
maxWaitMillis100获取连接最大等待时间设为 -1(无限等待)会使线程阻塞,拖垮整个服务
# src/main/resources/redis.properties redis.maxTotal=200 redis.maxIdle=50 redis.minIdle=10 redis.maxWaitMillis=100 redis.testOnBorrow=true redis.testWhileIdle=true redis.timeBetweenEvictionRunsMillis=30000

testOnBorrow=true是关键:每次从连接池借连接时执行PING,确保连接有效。虽然增加约 0.5ms 开销,但能避免因 Redis 主从切换导致的脏连接问题——这是线上最隐蔽的锁失效根源之一。

3.3pom.xml中的 profile 分离:开发/测试/生产三套依赖

项目用 Maven profile 实现环境隔离,pom.xml中定义了三个 profile:

<profiles> <profile> <id>dev</id> <activation><activeByDefault>true</activeByDefault></activation> <dependencies> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>${jedis.version}</version> <scope>compile</scope> </dependency> </dependencies> </profile> <profile> <id>test</id> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>com.github.docker-java</groupId> <artifactId>docker-java</artifactId> <version>3.4.4</version> <scope>test</scope> </dependency> </dependencies> </profile> <profile> <id>prod</id> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.2.4</version> <executions> <execution> <phase>package</phase> <goals><goal>shade</goal></goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.zhuge.Application</mainClass> </transformer> </transformers> </configuration> </execution> </executions> </plugin> </plugins> </build> </profile> </profiles>

打包生产包必须执行:

mvn clean package -Pprod

这样生成的 fat jar 会把所有依赖打进一个包,且MANIFEST.MF中指定主类,避免线上部署时ClassNotFoundException。


4. 避坑指南:8 个真实线上故障对应的代码修复点

4.1 现象:锁获取成功,但业务方法执行完后锁未释放

原因:业务代码中try-finally的finally块未捕获InterruptedException,导致unlock()未执行
解决:RedisDistributedLock.unlock()方法内部强制捕获所有异常,并记录 warn 日志

// src/main/java/com/zhuge/lock/RedisDistributedLock.java public void unlock(String lockKey) { try { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(lockValue)); if (!"1".equals(result.toString())) { log.warn("Unlock failed: lock {} was not held by current thread", lockKey); } } catch (Exception e) { // 关键:绝不让异常逃出 unlock 方法 log.warn("Unexpected error during unlock", e); } }

4.2 现象:Redis 哨兵模式下,主节点切换后锁续租失败

原因:JedisSentinelPool 默认不刷新 master 地址,续租请求发往已下线的旧主节点
解决:在RenewThread中每次续租前调用jedisPool.getMasterHost()获取最新地址

// RenewThread.run() 中新增校验 String currentMaster = jedisPool.getMasterHost(); if (!currentMaster.equals(lastMaster)) { log.info("Redis master changed from {} to {}", lastMaster, currentMaster); lastMaster = currentMaster; // 强制重建连接,避免使用旧连接 jedis.close(); jedis = null; }

4.3 现象:高并发下锁获取耗时突增 10 倍

原因:jedis.set()使用SetParams对象创建,但该对象非线程安全,多线程复用导致参数污染
解决:每次调用acquireLock()时新建SetParams实例

// 错误写法(全局 static SetParams) // private static final SetParams params = SetParams.setParams().nx().px(30000); // 正确写法(局部创建) String result = jedis.set(lockKey, lockValue, SetParams.setParams().nx().px(expireMillis)); // 每次 new

4.4 现象:单元测试通过,但线上偶发超卖

原因:测试用@Test方法未模拟网络延迟,而线上 Redis RT 波动大,set命令可能超时但实际已执行
解决:acquireLock()方法增加timeout参数,并在jedis.set()后校验返回值

// 改造后支持超时控制 public boolean acquireLock(String lockKey, long expireMillis, int timeoutMs) { try (Jedis jedis = jedisPool.getResource()) { jedis.getClient().setTimeoutInMillseconds(timeoutMs); // 设置 socket 超时 String result = jedis.set(lockKey, lockValue, SetParams.setParams().nx().px(expireMillis)); return "OK".equals(result); } }

4.5 现象:锁续租线程内存泄漏

原因:RenewThread继承Thread但未实现run()清理逻辑,线程局部变量(如jedis)未 close
解决:RenewThread实现AutoCloseable,在close()中关闭 jedis 并 interrupt 线程

public class RenewThread implements AutoCloseable, Runnable { private volatile boolean isRunning = true; @Override public void close() { isRunning = false; if (thread != null && thread.isAlive()) { thread.interrupt(); } if (jedis != null) { jedis.close(); // 关键:显式 close } } }

5. 如何验证你的锁真的可靠?——用这 3 个压测脚本抓住所有漏洞

5.1 脚本 1:LockStressTest.java—— 模拟 1000 线程争抢同一把锁

该测试位于test目录,核心逻辑是启动 1000 个线程,每个线程尝试获取锁并执行 100ms 业务逻辑,统计成功/失败/超时次数:

// src/test/java/com/zhuge/lock/LockStressTest.java @Test public void testHighConcurrencyAcquire() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1000); AtomicInteger successCount = new AtomicInteger(0); AtomicInteger timeoutCount = new AtomicInteger(0); for (int i = 0; i < 1000; i++) { new Thread(() -> { try { boolean acquired = lockManager.acquireLock("order:123", 30000); if (acquired) { successCount.incrementAndGet(); // 模拟业务处理 Thread.sleep(100); lockManager.releaseLock("order:123"); } else { timeoutCount.incrementAndGet(); } } catch (Exception e) { log.error("acquire error", e); } finally { latch.countDown(); } }).start(); } latch.await(60, TimeUnit.SECONDS); // 断言:成功数应 ≈ 1,超时数应 ≈ 999(单锁串行) assertThat(successCount.get(), is(1)); assertThat(timeoutCount.get(), greaterThanOrEqualTo(998)); }

关键指标:

  • successCount == 1:证明互斥性成立
  • timeoutCount >= 998:证明无锁饥饿(所有线程都尝试过)
  • 执行时间 < 120 秒:证明无死锁(若出现死锁,latch.await 会超时)

5.2 脚本 2:NetworkPartitionTest.java—— 模拟 Redis 网络分区

使用 Docker 启动 Redis 哨兵集群,然后用iptables模拟网络分区:

# 启动 Redis 哨兵集群(已预置在 docker-compose.yml) docker-compose -f docker-compose-sentinel.yml up -d # 在应用服务器上切断与哨兵的连接(模拟分区) sudo iptables -A OUTPUT -d 192.168.1.10 -j DROP sudo iptables -A OUTPUT -d 192.168.1.11 -j DROP # 运行测试,观察是否自动降级 mvn test -Dtest=NetworkPartitionTest

测试断言:

  • lockManager.acquireLock()应返回true(降级为本地锁)
  • 日志中应出现"Fallback to local cache lock"
  • 业务逻辑执行时间不应超过 5ms(本地锁开销)

5.3 脚本 3:FailoverTest.java—— 主从切换时锁状态一致性验证

该测试手动触发 Redis 哨兵故障转移,并验证锁是否仍被正确持有:

@Test public void testRedisFailoverConsistency() throws Exception { // 1. 获取锁 assertTrue(lockManager.acquireLock("stock:1001", 30000)); // 2. 记录当前锁 value String lockValue = getLockValueFromRedis("stock:1001"); // 通过 Jedis 直连获取 // 3. 触发哨兵故障转移(调用哨兵 API) triggerSentinelFailover(); // 4. 等待 5 秒,让切换完成 Thread.sleep(5000); // 5. 验证锁 value 未变(证明锁状态同步到了新主) assertEquals(lockValue, getLockValueFromRedis("stock:1001")); }

验证逻辑:Redis 哨兵切换后,新主节点必须从旧主同步到锁的key-value,否则会出现“锁消失”现象。本项目通过redis.conf中repl-backlog-size 1024mb和repl-timeout 60参数保障同步可靠性。


6. 生产上线 checklist:从代码提交到监控告警的 7 个必做动作

6.1 代码层:PR 合并前的 3 项硬性检查

检查项操作方式不通过后果
锁 Key 命名规范grep -r "lock.*" src/main/java/ | grep -v "test" | awk '{print $3}' | sort | uniq -c | sort -nrKey 重复或含变量(如lock:user:${id})会导致锁粒度错误,必须改为lock:user:id:{id}(固定前缀+业务ID)
所有 acquire 调用必须配超时find src/main/java -name "*.java" | xargs grep -l "acquireLock(" | xargs grep -L "timeout"无超时的 acquire 会阻塞线程池,引发雪崩
unlock 必须在 finally 块find src/main/java -name "*.java" | xargs grep -A5 -B5 "acquireLock" | grep -C3 "unlock" | grep -v "finally"业务异常时锁不释放,造成死锁

提示:我们团队在 Git Hook 中集成了上述检查,PR 提交时自动扫描,不通过则拒绝合并。脚本已放在项目根目录pre-commit-check.sh。

6.2 部署层:K8s 环境下的资源配置表

资源类型推荐值依据
JVM 堆内存-Xms2g -Xmx2g锁续租线程需常驻内存,堆过小会导致频繁 GC,续租延迟增大
容器 CPU limit2000m(2 核)续租线程需稳定 CPU 时间片,limit 过低会导致线程调度延迟
Redis 连接数 limit200(与redis.maxTotal一致)避免容器内连接数超出 Redis 配置的maxclients
Liveness ProbehttpGet: /actuator/health,initialDelaySeconds: 30锁服务健康检查必须包含RedisLockManager.isHealthy()
# k8s/deployment.yaml 片段 resources: limits: memory: "2Gi" cpu: "2000m" requests: memory: "2Gi" cpu: "1000m" livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 30 periodSeconds: 10

6.3 监控层:Prometheus + Grafana 必埋的 5 个指标

指标名Prometheus 查询语句告警阈值说明
lock_acquire_success_totalrate(lock_acquire_success_total[5m])< 100/s锁获取成功率骤降,可能 Redis 故障
lock_acquire_timeout_totalrate(lock_acquire_timeout_total[5m])> 10/s业务线程阻塞,需扩容或优化锁粒度
lock_renew_failure_totalrate(lock_renew_failure_total[5m])> 5/s续租失败率高,检查 Redis 主从同步延迟
lock_fallback_totalrate(lock_fallback_total[5m])> 0降级开关已触发,需人工介入
lock_held_duration_secondshistogram_quantile(0.99, rate(lock_held_duration_seconds_bucket[5m]))> 30s单次锁持有时间过长,业务逻辑可能卡死

Grafana Dashboard 已导出为grafana-lock-dashboard.json,导入即可使用。其中lock_held_duration_seconds是黄金指标——它直指业务瓶颈:若 P99 > 30s,说明unlock()调用被阻塞,大概率是业务代码中有 IO 等待未包裹在 try-finally 中。

从那以后我每次上线新锁功能,都强制走一遍这 7 步 checklist:先跑LockStressTest,再docker-compose up模拟故障,接着kubectl apply部署,最后切到 Grafana 看 5 分钟指标曲线。漏掉任何一步,都可能让锁在凌晨 2 点变成系统单点。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询