Spring Boot + Redis 分布式锁:一个订单重复提交引发的六次迭代

本文以"订单重复提交"为线索,从最简单的 SETNX 出发,每遇到一个问题就解决一个问题,层层递进到生产级方案。适合有一定 Spring Boot 基础的 Java 开发者。

一、什么是分布式锁?

1. 从单机锁说起

在单机应用中,我们处理并发非常简单,用 Java 自带的锁就行:

java 复制代码
public synchronized void createOrder(String orderId) {
    // 同一时刻只有一个线程能进入
    orderMapper.insert(orderId);
}

或者用 ReentrantLock

java 复制代码
private final ReentrantLock lock = new ReentrantLock();

public void createOrder(String orderId) {
    lock.lock();
    try {
        orderMapper.insert(orderId);
    } finally {
        lock.unlock();
    }
}

这些锁之所以有效,是因为所有线程都在同一个 JVM 里,共享同一块内存,锁的状态大家都能看到。

2. 单机锁为什么失效?

现在把应用部署到 3 台机器上,前面挂一个 Nginx 做负载均衡:

text 复制代码
        ┌──────────┐
        │  Nginx   │
        └────┬─────┘
             │
     ┌───────┼───────┐
     ▼       ▼       ▼
  实例 A   实例 B   实例 C
 (JVM 1)  (JVM 2)  (JVM 3)

用户的两次请求,一次打到实例 A,一次打到实例 B。这时候:

  • 实例 A 的 synchronized 只锁住 A 的线程
  • 实例 B 的 synchronized 只锁住 B 的线程

两个 JVM 的锁互相不认识,等于没加锁。这就是单机锁的局限。

3. 分布式锁要解决什么?

分布式锁的核心目标很简单:

在分布式系统中,让多个进程/线程对同一资源的访问变得互斥。

具体来说,它需要满足以下几个条件:

特性 含义
互斥 任意时刻,只有一个客户端能持有锁
防死锁 持有锁的客户端崩溃后,锁能自动释放
安全释放 只有加锁的客户端才能释放锁,不能误删别人的
高可用 锁服务本身不能是单点,Redis 挂了锁还能用
可重入(可选) 同一客户端可以重复获取同一把锁

4. 常见的分布式锁实现方案

业界主流的方案有三种:

方案 原理 优点 缺点
基于数据库 唯一索引 / SELECT ... FOR UPDATE 实现简单 性能差,易死锁
基于 Redis SET NX PX + Lua 性能高,部署简单 主从切换可能丢锁
基于 ZooKeeper / etcd 临时顺序节点 / Lease 强一致,可靠性高 性能略低,运维复杂

为什么 Redis 最流行?

  • 大部分系统本来就用 Redis 做缓存,加锁不需要额外组件
  • 性能极高,单机轻松支撑十万级 QPS
  • API 简单,几行代码就能上手

但简单不等于容易。正是因为上手太容易,很多人写出了有问题的锁而不自知。接下来的六次迭代,就是带你把这个坑一个个填平。

二、引子:一个真实的 Bug

某个下午,运营反馈:用户点击"提交订单"后,后台出现了两条一模一样的订单记录。

排查发现:用户手抖点了两次,两次请求几乎同时到达,都查了"订单不存在",然后都执行了插入。

这不是数据库唯一索引能完全解决的------有些业务逻辑复杂,无法简单加唯一约束。我们需要的是:同一时刻,只允许一个线程执行这段逻辑

下面我们在 Spring Boot 项目中,一步步实现它。

三、第一版:SETNX,能加锁,但会死锁

Redis 的 SETNX(SET if Not eXists)天生就是为互斥设计的:

java 复制代码
public void createOrder(String orderId) {
    String lockKey = "lock:order:" + orderId;
    Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
    if (!Boolean.TRUE.equals(success)) {
        throw new RuntimeException("请勿重复提交");
    }
    try {
        // 业务逻辑
        orderMapper.insert(orderId);
    } finally {
        redisTemplate.delete(lockKey);
    }
}

看起来没问题。但如果业务执行中服务突然宕机,finally 没执行,这个 key 就永远留在 Redis 里------其他用户的请求全部被卡死

这就是第一版的死穴:锁没有过期时间

四、第二版:SETNX + EXPIRE,解决死锁,但引入新问题

加个过期时间就行了:

java 复制代码
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
if (Boolean.TRUE.equals(success)) {
    redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS);  // 新增
}

宕机也不怕了,30 秒后锁自动释放。

但这里有个隐患:setIfAbsentexpire 是两条命令,不是原子的。如果在两条命令之间服务宕机,锁同样永远不会释放。

概率很低,但生产环境不能赌概率。

五、第三版:SET NX PX,原子加锁

Redis 2.6.12 之后,SET 命令支持了 NXPX 参数,可以一条命令搞定:

bash 复制代码
SET lock:order:123 1 NX PX 30000

Spring Data Redis 提供了对应封装:

java 复制代码
Boolean success = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(30));

一条命令,原子操作,既能互斥又能过期。到这里锁的"加"已经没问题了。

但释放呢?看下面的场景:

线程 A 获得锁,业务执行了 35 秒,锁在 30 秒时自动过期。

线程 B 获得锁开始执行。

线程 A 执行完毕,走 finally,delete(lockKey)------把 B 的锁删了

第三版的死穴:可能误删别人的锁

六、第四版:UUID + Lua,安全释放

要避免误删,就得给锁加个"身份标识"。加锁时 value 存一个 UUID,释放前先校验:

java 复制代码
public String tryLock(String key, long expireSeconds) {
    String requestId = UUID.randomUUID().toString();
    Boolean success = redisTemplate.opsForValue()
            .setIfAbsent(key, requestId, Duration.ofSeconds(expireSeconds));
    return Boolean.TRUE.equals(success) ? requestId : null;
}

释放时用 Lua 脚本保证"校验 + 删除"的原子性:

lua 复制代码
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

Java 侧:

java 复制代码
private static final String UNLOCK_LUA =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "return redis.call('del', KEYS[1]) else return 0 end";

public boolean unlock(String key, String requestId) {
    Long result = redisTemplate.execute(
            new DefaultRedisScript<>(UNLOCK_LUA, Long.class),
            Collections.singletonList(key),
            requestId);
    return result != null && result > 0;
}

使用:

java 复制代码
String lockKey = "lock:order:" + orderId;
String requestId = redisLock.tryLock(lockKey, 30);
if (requestId == null) {
    throw new RuntimeException("请勿重复提交");
}
try {
    orderMapper.insert(orderId);
} finally {
    redisLock.unlock(lockKey, requestId);
}

到这里,锁的"加"和"放"都安全了。但还有第四个坑:

如果业务执行时间超过了 30 秒,锁自动过期,线程 B 拿到锁开始执行,此时两个线程同时在跑,互斥失效。

第四版的死穴:业务超时导致锁提前释放。

要解决它,就得在业务执行期间给锁"续命"。手写续期非常麻烦(后台线程、定时任务、异常处理......),这时候就该 Redisson 出场了。

七、第五版:Redisson,看门狗自动续期

Redisson 是 Redis 官方推荐的 Java 客户端,它把上面所有问题都封装好了,还额外提供了:

  • 看门狗(Watch Dog) :业务没执行完,自动续期
  • 可重入:同一线程可以重复获取同一把锁
  • 重试等待tryLock 支持等待时间,内部自旋
  • Lua 脚本:底层保证原子性

Spring Boot 集成

依赖:

xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.0</version>
</dependency>

如果 application.yml 里已有 spring.redis 配置,Starter 会自动装配,无需额外代码。想自定义的话:

java 复制代码
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://127.0.0.1:6379")
              .setPassword(null)
              .setDatabase(0);
        return Redisson.create(config);
    }
}

使用 RLock

java 复制代码
@Service
public class OrderService {

    @Autowired
    private RedissonClient redissonClient;

    public void createOrder(String orderId) {
        RLock lock = redissonClient.getLock("lock:order:" + orderId);
        boolean locked = false;
        try {
            // 最多等 10 秒,锁默认 30 秒(看门狗会自动续期)
            locked = lock.tryLock(10, TimeUnit.SECONDS);
            if (!locked) {
                throw new RuntimeException("请勿重复提交");
            }
            orderMapper.insert(orderId);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

注意上面用的是 tryLock(waitTime, unit) 这个方法------只传等待时间,不传租期 。Redisson 会启用看门狗:默认锁 30 秒,每 10 秒续期一次,直到你主动 unlock。业务跑多久都不怕。

如果你传了租期 tryLock(10, 30, TimeUnit.SECONDS),看门狗不会启动,到点就释放。这一点非常容易踩坑。

到这里,锁本身已经足够健壮。但你会发现一个问题:每个方法都要写一遍 try-finally,太啰嗦了

八、第六版:自定义注解 + AOP,业务无侵入

我们定义一个 @RedisLock 注解,用 AOP 统一处理加锁和解锁。

1. 定义注解

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {

    /** 锁的 key,支持 SpEL 表达式 */
    String key();

    /** 等待锁的时间,默认 0 表示不等待 */
    long waitTime() default 0;

    TimeUnit timeUnit() default TimeUnit.SECONDS;
}

2. 编写切面

java 复制代码
@Aspect
@Component
public class RedisLockAspect {

    @Autowired
    private RedissonClient redissonClient;

    private static final ExpressionParser PARSER = new SpelExpressionParser();
    private static final ParameterNameDiscoverer NAME_DISCOVERER =
            new DefaultParameterNameDiscoverer();

    @Around("@annotation(redisLock)")
    public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) throws Throwable {
        String key = parseKey(redisLock.key(), joinPoint);
        RLock lock = redissonClient.getLock(key);

        boolean locked = false;
        try {
            locked = lock.tryLock(redisLock.waitTime(), redisLock.timeUnit());
            if (!locked) {
                throw new RuntimeException("获取分布式锁失败:" + key);
            }
            return joinPoint.proceed();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取锁被中断", e);
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    private String parseKey(String keySpEl, ProceedingJoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Object[] args = joinPoint.getArgs();
        String[] paramNames = NAME_DISCOVERER.getParameterNames(method);

        EvaluationContext context = new StandardEvaluationContext();
        if (paramNames != null) {
            for (int i = 0; i < paramNames.length; i++) {
                context.setVariable(paramNames[i], args[i]);
            }
        }
        return PARSER.parseExpression(keySpEl).getValue(context, String.class);
    }
}

3. 业务代码变成一行注解

java 复制代码
@Service
public class OrderService {

    @RedisLock(key = "'lock:order:' + #orderId", waitTime = 5)
    public void createOrder(String orderId) {
        orderMapper.insert(orderId);
    }
}

业务代码完全不用关心锁的存在,干净利落。

九、还有哪些坑?

到这里,我们的锁已经从"玩具"进化到了"生产级"。但还有几个问题必须知道。

1. 主从切换导致锁丢失

Redis 主从复制是异步的。极端情况下:

线程 A 在主节点加锁成功 → 主节点还没同步就宕机 → 从节点升主 → 线程 B 也能加锁成功。

两个线程同时持有"同一把锁",互斥失效。

这是 Redis 分布式锁的天然缺陷,无法通过客户端代码根治。解决方向:

  • Redlock:部署 5 个独立主节点,超过半数加锁成功才算成功。但实现复杂,且对时钟敏感,争议较大。
  • ZooKeeper / etcd:基于共识算法,强一致,代价是性能略低。
  • Fencing Token:每次加锁返回单调递增的 token,下游资源校验 token 大小,拒绝旧 token 的写入。

如果业务对一致性要求极高(比如金融扣款),请优先考虑 ZooKeeper / etcd。

2. 锁与事务的顺序

Spring 中 @Transactional 和分布式锁一起用时,顺序很关键:

java 复制代码
// 错误示范
@Transactional
public void wrong(String orderId) {
    // 锁在事务内部,释放锁时事务可能还没提交
    // 其他线程拿到锁,读到的是旧数据
}

正确做法是先加锁,再开事务;先提交事务,再释放锁。推荐在 Controller 或门面层加锁,事务方法内部调用:

java 复制代码
public void facade(String orderId) {
    lock.lock();          // 1. 加锁
    try {
        txService.doBusiness(orderId);  // 2. 事务方法内部提交
    } finally {
        lock.unlock();    // 3. 事务提交后再释放
    }
}

3. 锁粒度

锁的 key 越细,并发度越高。按订单 ID 加锁(lock:order:123)比全局锁(lock:order)好得多。但也要注意:

  • 粒度太细 → Redis key 数量爆炸,内存压力大
  • 粒度太粗 → 并发度低,锁竞争激烈

根据业务场景权衡,通常按业务主键加锁就够。

十、总结

回顾一下我们的六次迭代:

版本 方案 解决的问题 遗留问题
SETNX 互斥 宕机死锁
SETNX + EXPIRE 死锁 非原子
SET NX PX 原子性 误删别人的锁
UUID + Lua 安全释放 业务超时锁失效
Redisson 看门狗续期、可重入 代码重复
注解 + AOP 业务无侵入 主从切换丢锁

核心结论

  1. 加锁用 SET NX PX,不要用 SETNX + EXPIRE
  2. 释放用 Lua 校验 UUID,不要直接 DEL
  3. 生产环境直接上 Redisson,别自己造轮子。
  4. Spring Boot 中配合自定义注解 + AOP,业务代码零侵入。
  5. 注意锁与事务的顺序,先加锁后开事务,先提交事务后释放锁。
  6. 接受 Redis 主从切换可能丢锁的事实,强一致场景选 ZooKeeper / etcd。

分布式锁没有银弹,理解每一版的缺陷比记住最终答案更重要。希望这篇文章能帮你在实际项目中做出正确的选择。


如果本文对你有帮助,欢迎点赞、收藏、关注。有问题欢迎在评论区交流。

相关推荐
步行cgn1 小时前
依赖注入详解:Spring IoC 的实现方式
后端
xiaoqiMikko1 小时前
pom 里没有、代码里没调过,但 WebClient 默认用的就是 netty-resolver-dns
java·spring boot
wuminyu1 小时前
Kafka的写入延迟与磁盘IO抖动原理分析
java·linux·c语言·jvm·c++
aramae1 小时前
模拟实现strcpy(字符串拷贝)(C语言)
java·c语言·开发语言·算法
IT毕设实战小研1 小时前
基于大数据的跨国外派人员适应满意度与留存影响因素可视化分析
android·java·大数据·python·django·课程设计
letisgo51 小时前
JAVA 高级进阶10篇《消息队列实战:RocketMQ/Kafka选型与“不丢不重有序“三连解》
java·面试·kafka·消息队列·rocketmq
2401_850481171 小时前
UVA101
java
平头哥AI2 小时前
Day 21 _ error 是个普通值_errors.New 与 fmt.Errorf 造出来,沿调用栈抛到 main 接住
后端·学习·golang·go
Lyyaoo.2 小时前
【回溯】【中等】全排列
java·数据结构·算法