可直接落地的 Redis 分布式鎖實現:包含最小可用版、生產可用版(帶 Lua 原子解鎖、續期“看門狗”、自旋等待、可重入)、以及基于注解+AOP 的無侵入用法,最后還給出 Redisson 方案對比與踩坑清單。
一、設計目標與約束
- 獲取鎖:
SET key value NX PX ttl
(原子、帶過期) - 釋放鎖:Lua 校驗 value(token)后再
DEL
,避免誤刪他人鎖 - 等待策略:可設置總體等待時長 + 抖動退避,避免驚群
- 續期(看門狗):長耗時任務自動延長鎖過期,避免任務未完成鎖先過期
- 可重入:同一線程/請求二次進入同一鎖,計數 +1,退出時計數 -1
- 可觀測性:日志、指標(命中/失敗/續期次數等)
二、最小可用實現(入門示例)
// MinimalLockService.java
@Service
public class MinimalLockService {private final StringRedisTemplate redis;public MinimalLockService(StringRedisTemplate redis) {this.redis = redis;}/** 獲取鎖,返回 token(uuid),失敗返回 null */public String tryLock(String key, long ttlMs) {String token = UUID.randomUUID().toString();Boolean ok = redis.opsForValue().setIfAbsent(key, token, Duration.ofMillis(ttlMs));return Boolean.TRUE.equals(ok) ? token : null;}/** 釋放鎖(Lua):只有持有相同 token 才能刪除鎖 */public boolean unlock(String key, String token) {String script = """if redis.call('get', KEYS[1]) == ARGV[1] thenreturn redis.call('del', KEYS[1])elsereturn 0end""";Long res = redis.execute(new DefaultRedisScript<>(script, Long.class), List.of(key), token);return res != null && res > 0;}
}
適合“單次短任務、不等待”的場景;生產建議使用下文增強版。
三、生產可用鎖客戶端(可重入 + 等待 + 續期)
1)核心實現
// RedisDistributedLock.java
@Component
public class RedisDistributedLock implements InitializingBean, DisposableBean {private final StringRedisTemplate redis;private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);private final DefaultRedisScript<Long> unlockScript = new DefaultRedisScript<>();private final DefaultRedisScript<Long> renewScript = new DefaultRedisScript<>();// 線程內可重入計數:key -> (token, count)private final ThreadLocal<Map<String, ReentryState>> reentry = ThreadLocal.withInitial(HashMap::new);public RedisDistributedLock(StringRedisTemplate redis) {this.redis = redis;}@Override public void afterPropertiesSet() {unlockScript.setResultType(Long.class);unlockScript.setScriptText("""if redis.call('get', KEYS[1]) == ARGV[1] thenreturn redis.call('del', KEYS[1])elsereturn 0end""");renewScript.setResultType(Long.class);renewScript.setScriptText("""if redis.call('get', KEYS[1]) == ARGV[1] thenreturn redis.call('pexpire', KEYS[1], ARGV[2])elsereturn 0end""");}@Override public void destroy() { scheduler.shutdownNow(); }public static class LockHandle implements AutoCloseable {private final RedisDistributedLock client;private final String key;private final String token;private final long ttlMs;private final ScheduledFuture<?> watchdogTask;private boolean closed = false;private LockHandle(RedisDistributedLock c, String key, String token, long ttlMs, ScheduledFuture<?> task) {this.client = c; this.key = key; this.token = token; this.ttlMs = ttlMs; this.watchdogTask = task;}@Override public void close() {if (closed) return;closed = true;if (watchdogTask != null) watchdogTask.cancel(false);client.release(key, token);}public String key() { return key; }public String token() { return token; }}private record ReentryState(String token, AtomicInteger count) {}/** 嘗試在 waitMs 內獲取鎖;持有 ttlMs;支持可重入與退避等待;啟用自動續期(watchdog=true) */public Optional<LockHandle> acquire(String key, long ttlMs, long waitMs, boolean watchdog) {Map<String, ReentryState> map = reentry.get();// 可重入:當前線程已持有同一 keyif (map.containsKey(key)) {map.get(key).count().incrementAndGet();return Optional.of(new LockHandle(this, key, map.get(key).token(), ttlMs, null));}final String token = UUID.randomUUID().toString();final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(waitMs);while (true) {Boolean ok = redis.opsForValue().setIfAbsent(key, token, Duration.ofMillis(ttlMs));if (Boolean.TRUE.equals(ok)) {map.put(key, new ReentryState(token, new AtomicInteger(1)));ScheduledFuture<?> task = null;if (watchdog) {// 續期間隔:ttl 的 1/2(保守 <= 2/3 均可)long interval = Math.max(500, ttlMs / 2);task = scheduler.scheduleAtFixedRate(() -> renew(key, token, ttlMs),interval, interval, TimeUnit.MILLISECONDS);}return Optional.of(new LockHandle(this, key, token, ttlMs, task));}if (System.nanoTime() > deadline) break;// 抖動退避:50~150mstry { Thread.sleep(50 + ThreadLocalRandom.current().nextInt(100)); }catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; }}return Optional.empty();}private void renew(String key, String token, long ttlMs) {try {Long r = redis.execute(renewScript, List.of(key), token, String.valueOf(ttlMs));// 失敗說明鎖已不在或被他人占有,停止續期} catch (Exception ignore) {}}private void release(String key, String token) {Map<String, ReentryState> map = reentry.get();ReentryState state = map.get(key);if (state == null) return; // 非當前線程,無操作(冪等)if (state.count().decrementAndGet() > 0) return; // 仍有重入層級map.remove(key);try {redis.execute(unlockScript, List.of(key), token);} catch (Exception e) {// 記錄日志/指標}}
}
2)使用范例(try-with-resources 自動釋放)
@Service
public class OrderService {private final RedisDistributedLock lock;public OrderService(RedisDistributedLock lock) { this.lock = lock; }public void deductStock(String skuId) {String key = "lock:stock:" + skuId;Optional<RedisDistributedLock.LockHandle> h =lock.acquire(key, /*ttlMs*/ 10_000, /*waitMs*/ 3_000, /*watchdog*/ true);if (h.isEmpty()) {throw new IllegalStateException("系統繁忙,請稍后重試");}try (RedisDistributedLock.LockHandle ignored = h.get()) {// 業務邏輯:查詢庫存 -> 校驗 -> 扣減 -> 持久化// ...(這里可再次重入同鎖,例如調用內部方法)}}
}
四、注解 + AOP:無侵入加鎖(支持 SpEL 動態 key)
1)定義注解
// RedisLock.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {/** 鎖名(前綴) */String name();/** 業務 key 的 SpEL,例如 "#skuId" 或 "#req.userId + ':' + #req.orderId" */String key();/** 過期毫秒 */long ttlMs() default 10_000;/** 最長等待毫秒 */long waitMs() default 3_000;/** 是否自動續期 */boolean watchdog() default true;/** 獲取失敗是否拋異常;false 則直接跳過執行業務 */boolean failFast() default true;
}
2)AOP 切面
// RedisLockAspect.java
@Aspect
@Component
public class RedisLockAspect {private final RedisDistributedLock locker;private final SpelExpressionParser parser = new SpelExpressionParser();private final ParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();public RedisLockAspect(RedisDistributedLock locker) { this.locker = locker; }@Around("@annotation(anno)")public Object around(ProceedingJoinPoint pjp, RedisLock anno) throws Throwable {MethodSignature sig = (MethodSignature) pjp.getSignature();Method method = sig.getMethod();String spel = anno.key();EvaluationContext ctx = new StandardEvaluationContext();String[] paramNames = nameDiscoverer.getParameterNames(method);Object[] args = pjp.getArgs();if (paramNames != null) {for (int i = 0; i < paramNames.length; i++) {ctx.setVariable(paramNames[i], args[i]);}}String bizKey = parser.parseExpression(spel).getValue(ctx, String.class);String lockKey = "lock:" + anno.name() + ":" + bizKey;Optional<RedisDistributedLock.LockHandle> h =locker.acquire(lockKey, anno.ttlMs(), anno.waitMs(), anno.watchdog());if (h.isEmpty()) {if (anno.failFast()) {throw new IllegalStateException("并發過高,稍后再試");} else {return null; // 或者返回自定義“占用中”結果}}try (RedisDistributedLock.LockHandle ignored = h.get()) {return pjp.proceed();}}
}
3)業務使用
@Service
public class CheckoutService {@RedisLock(name = "pay", key = "#orderId", ttlMs = 15000, waitMs = 5000)public String pay(Long orderId) {// 冪等校驗、扣款、記賬、改狀態...return "OK";}
}
五、和 Redisson 的取舍
- 自己實現(本文方案)
輕量、可控、無第三方依賴;需要你自己維護續期、統計、容錯。 - Redisson
功能齊全(公平鎖、信號量、讀寫鎖、鎖續期看門狗、聯鎖/紅鎖等),配置簡單,實戰成熟。
👉 建議對鎖模型復雜、需要多數據結構協作的場景直接上 Redisson。
示例(Redisson):
<!-- pom -->
<dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.38.0</version>
</dependency>
@Autowired private RedissonClient redisson;public void doWork() {RLock lock = redisson.getLock("lock:demo");// 默認看門狗 30s,自動續期if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {try { /* 業務 */ }finally { lock.unlock(); }}
}
六、生產實踐與踩坑清單
- 務必用 Lua 校驗 token 再解鎖:防止誤刪他人鎖。
- TTL 要合理:不能太短(業務未完成鎖已過期),也不能太長(死鎖恢復慢)。一般結合看門狗更穩。
- 等待 + 退避:避免 CPU 自旋和驚群;可以配合“排隊提示”。
- 可重入只是“線程內”語義:跨線程/跨進程不可重入,需要更復雜的標識管理;盡量避免跨線程使用同一鎖。
- 冪等設計:即使拿到鎖也可能重復執行(重試、網絡抖動);寫操作要有冪等鍵。
- 多節點/主從復制延遲:強一致要求下盡量連接主節點;或降低讀從庫。
- 集群模式 key tag:使用
{}
包裹哈希標簽,確保同一鍵路由到同槽位(適用于 Redisson 等場景)。 - 監控指標:加鎖成功率、平均等待、續期失敗次數、異常堆棧等,配合告警。
- 故障演練:kill -9 模擬進程崩潰,驗證鎖自動過期與業務補償是否生效。
七、完整配置(參考)
<!-- pom.xml 關鍵依賴 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId>
</dependency>
# application.yml
spring:redis:host: localhostport: 6379# password: yourpasslettuce:pool:max-active: 8max-idle: 8min-idle: 0
// Redis 序列化(可選,鎖用不到復雜序列化,這里保證 key=String 即可)
@Configuration
public class RedisConfig {@Beanpublic StringRedisTemplate stringRedisTemplate(RedisConnectionFactory f) {return new StringRedisTemplate(f);}
}
八、如何驗證
- 并發壓測兩個請求同時調用
@RedisLock
方法,觀察只有一個進入臨界區;另一個要么等待成功、要么超時失敗。 - 人為延長業務耗時(
Thread.sleep
),觀察續期是否發生:在 Redis 中PTTL lock:...
始終大于 0。 - 殺掉進程:確認鎖會在 TTL 到期后自動釋放。