分布式锁

→ 返回 Spring Boot 基础

分布式锁用于让多个 JVM 协作访问共享资源,解决本地 synchronized / ReentrantLock 无法跨进程的问题。Redis 锁属于带 TTL 的协作式租约,不应被描述成无条件强一致;底层故障模型、fencing token 与 Redlock 边界见 Redis 分布式锁原理。


方案对比

方案可靠性性能实现复杂度适用场景
Redis SET NX PX(手写)取决于拓扑高高学习原理、可容忍极小概率重复的短任务
Redisson RLock取决于 Redis 拓扑与资源侧保护高低Java 通用短临界区
Redisson RFencedLock可阻止陈旧持有者写入高中目标资源能校验 fencing token
ZooKeeper / etcd共识协调中高需要一致协调与会话语义
数据库乐观锁中低低低并发,已有数据库
数据库悲观锁高低低低并发,强一致

方案一:Redisson(生产推荐)

Redisson 封装了所有权校验、TTL、看门狗续期、可重入和等待通知等常用语义,能显著减少手写错误;它不能自动消除 Redis 异步复制、进程暂停或外部资源陈旧写问题。

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
</dependency>

版本应由项目 BOM/依赖管理统一锁定,并结合 Spring Boot/Redis 版本做兼容性验证,不要从示例复制一个长期不变的版本号。

spring:
  data:
    redis:
      host: localhost
      port: 6379

基本用法

@Service
public class OrderService {
 
    private final RedissonClient redissonClient;
 
    public void createOrder(String userId) {
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        try {
            // 尝试加锁,最多等待 3 秒,持有 30 秒后自动释放
            boolean acquired = lock.tryLock(3, 30, TimeUnit.SECONDS);
            if (!acquired) {
                throw new BizException("操作频繁,请稍后重试");
            }
            doCreateOrder(userId);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BizException("获取锁被中断", e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

看门狗机制

不指定 leaseTime 时,Redisson 启用看门狗:lockWatchdogTimeout 默认 30 秒,客户端会在锁仍属于当前持有者时周期续期,直到正常解锁或客户端失联:

lock.lock();                        // 无超时,看门狗自动续期
try {
    doLongRunningTask();
} finally {
    lock.unlock();
}

指定 leaseTime 的锁通常不启用看门狗,到期自动释放。无论哪种方式,JVM 长暂停、容器冻结、网络中断都可能让续期失败;关键写入仍需数据库约束、幂等或 fencing token。

其他锁类型

// 可重入锁(同一线程可多次加锁)
RLock lock = redissonClient.getLock("reentrant-lock");
 
// 公平锁(FIFO 顺序)
RLock fairLock = redissonClient.getFairLock("fair-lock");
 
// MultiLock:把多把锁作为一组协调;仍需统一资源顺序、超时和失败释放
RLock lock1 = redissonClient.getLock("lock:account:1");
RLock lock2 = redissonClient.getLock("lock:account:2");
RLock multiLock = redissonClient.getMultiLock(lock1, lock2);
 
// 读写锁
RReadWriteLock rwLock = redissonClient.getReadWriteLock("rw-lock");
rwLock.readLock().lock();   // 读操作加读锁(允许并发读)
rwLock.writeLock().lock();  // 写操作加写锁(独占)

Fenced Lock(防陈旧持有者)

客户端暂停超过 TTL 后,即使恢复时仍“以为”自己持锁,也必须被资源端拒绝。RFencedLock 为每次成功获取返回递增 token:

RFencedLock lock = redissonClient.getFencedLock("lock:inventory:" + skuId);
Long token = lock.lockAndGetToken();
try {
    int updated = inventoryMapper.updateWithFence(skuId, quantity, token);
    if (updated != 1) {
        throw new StaleLockHolderException();
    }
} finally {
    lock.unlock();
}
UPDATE inventory
SET quantity = #{quantity}, last_fence = #{token}
WHERE sku_id = #{skuId}
  AND last_fence < #{token};

只有数据库/存储真正比较并拒绝旧 token,fencing 才生效。


方案二:Redis SETNX 手写

适合理解原理或依赖轻量化场景,需手动处理续期、误删等问题:

@Component
public class RedisDistributedLock {
 
    private final StringRedisTemplate redisTemplate;
    private static final String LOCK_PREFIX = "lock:";
 
    public boolean tryLock(String key, String value, long expireSeconds) {
        Boolean result = redisTemplate.opsForValue()
            .setIfAbsent(LOCK_PREFIX + key, value,
                         expireSeconds, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(result);
    }
 
    // 释放时校验 value,防止误删其他线程的锁(Lua 脚本保证原子性)
    public boolean releaseLock(String key, String value) {
        String script = """
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
            """;
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            List.of(LOCK_PREFIX + key),
            value
        );
        return Long.valueOf(1).equals(result);
    }
}
// 使用
String lockValue = UUID.randomUUID().toString();
boolean locked = lock.tryLock("order:" + userId, lockValue, 30);
if (!locked) throw new BizException("请稍后重试");
try {
    doCreateOrder(userId);
} finally {
    lock.releaseLock("order:" + userId, lockValue);
}

手写方案还需处理原子续期、等待退避、Redis failover、线程所有权和可观测性。上面的 Lua 只解决“释放别人锁”的 TOCTOU 竞态,并没有让整个方案变成强一致锁。


方案三:注解驱动(AOP 封装)

通过自定义注解 + AOP 切面,将锁逻辑与业务代码解耦:

// 1. 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String key();                       // 支持 SpEL,如 "#userId"
    long waitTime() default 3;
    long leaseTime() default -1;        // -1:使用 watchdog;正数:固定租约
    TimeUnit unit() default TimeUnit.SECONDS;
    String message() default "操作频繁,请稍后重试";
}
 
// 2. AOP 切面
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 让锁切面包住默认的事务切面
public class DistributedLockAspect {
 
    private final RedissonClient redissonClient;
    private final ExpressionParser parser = new SpelExpressionParser();
 
    @Around("@annotation(dl)")
    public Object around(ProceedingJoinPoint pjp,
                         DistributedLock dl) throws Throwable {
        String key = resolveKey(pjp, dl.key());
        RLock lock = redissonClient.getLock("lock:" + key);
        boolean acquired = dl.leaseTime() < 0
            ? lock.tryLock(dl.waitTime(), dl.unit())
            : lock.tryLock(dl.waitTime(), dl.leaseTime(), dl.unit());
        if (!acquired) throw new BizException(dl.message());
        try {
            return pjp.proceed();
        } finally {
            if (lock.isHeldByCurrentThread()) lock.unlock();
        }
    }
 
    private String resolveKey(ProceedingJoinPoint pjp, String keyExpr) {
        if (!keyExpr.contains("#")) return keyExpr;
        MethodSignature sig = (MethodSignature) pjp.getSignature();
        EvaluationContext ctx = new MethodBasedEvaluationContext(
            null, sig.getMethod(), pjp.getArgs(),
            new DefaultParameterNameDiscoverer());
        return parser.parseExpression(keyExpr).getValue(ctx, String.class);
    }
}
 
// 3. 使用
@DistributedLock(key = "'order:' + #userId", waitTime = 3)
public void createOrder(String userId) {
    doCreateOrder(userId);
}

锁切面应在事务切面外层,使顺序成为:获得锁 → 开启事务 → 提交事务 → 释放锁。如果项目有其他高优先级切面,应通过集成测试验证实际 advisor 顺序;更显式的方案是在持锁代码中使用 TransactionTemplate。


定时任务场景

防止多实例重复执行定时任务,详见 定时任务:

@Scheduled(cron = "0 0 3 * * ?")
public void nightlyCleanup() {
    RLock lock = redissonClient.getLock("task:nightly-cleanup");
    if (!lock.tryLock()) return;             // 其他实例已在执行
    try {
        doCleanup();
    } finally {
        lock.unlock();
    }
}

接口幂等性

分布式锁可抑制同一时刻的并发请求,但不能阻止锁释放后的重试。接口幂等还需要 requestId 唯一索引或幂等记录,详见 接口幂等性:

@DistributedLock(key = "'idempotent:' + #req.requestId", leaseTime = 10)
public OrderResponse createOrder(CreateOrderRequest req) {
    // 锁内执行,相同 requestId 的并发请求只有一个能进入
    if (orderRepo.existsByRequestId(req.getRequestId())) {
        return orderRepo.findByRequestId(req.getRequestId());
    }
    return doCreate(req);
}

常见问题

问题原因解决方案
锁过期业务未完成未续期Redisson 看门狗自动续期
释放了别人的锁未校验持有者value 带线程标识,原子 Lua 脚本释放
Redis 主从切换锁丢失主节点崩溃前未同步副本同步检查降低风险;关键写使用 fencing/DB 约束
旧客户端恢复后继续写GC/容器暂停超过 TTLRFencedLock + 资源端拒绝旧 token
死锁业务异常未释放finally 块释放;设置合理 leaseTime
锁粒度太粗整资源加锁细化 key(如按用户 ID / 订单 ID)
多资源互相等待加锁顺序不一致资源 ID 排序、有限 waitTime、失败释放与随机退避

相关链接