Redis实战: “一人一单”功能的实现逻辑与从单体到集群的并发问题

21 阅读6分钟

一、 业务背景

在“优惠券秒杀”场景中,为了防止用户恶意刷单、保障活动公平性,业务规则强制要求:同一个用户 ID 对同一张优惠券只能下单一次

在低并发场景下,这是一个简单的“查询校验 -> 扣减库存 -> 创建订单”的串行逻辑。但在高并发场景下,如果两个线程同时进入“查询”阶段,都会判定当前用户未下单,从而同时执行后续的创建订单逻辑,导致数据库中产生同一用户的多条订单,违背了业务规则。

本文将复盘该功能在单体架构下的实现细节,以及随着架构升级为集群部署后,并发问题是如何再次出现并最终通过分布式锁解决的。

二、 单体架构下的解决方案

在项目初期,服务采用单节点部署(单个 JVM)。为了解决并发安全问题,我利用 Java 原生的 synchronized 关键字实现了互斥锁。

虽然代码量不大,但为了在保证数据一致性的同时兼顾系统性能,我重点解决了以下四个问题:

1. 锁粒度的优化:从方法锁到对象锁

初步方案:直接在 Service 的方法上添加 synchronized 关键字。

存在问题:这样做会将锁的范围扩大到整个方法,锁的对象是 this(当前 Service 实例)。这意味着所有用户(无论 ID 是否相同)在抢购时都需要排队,将并发操作变成了完全的串行操作,系统吞吐量极低。

优化实现:缩小锁的范围,只锁当前用户的 ID。

我将锁改为 synchronized(userId) 代码块。这样,只有 userId 相同的并发请求才会互斥排队,不同用户的请求可以并行处理,极大提升了并发性能。

2. 锁对象的唯一性:String.intern() 的应用

技术难点:userId 是 Long 类型,将其转换为 String 作为锁对象时,Java 每次都会在堆内存中创建一个新的 String 对象。即便两个请求的 userId 值都是 1001,但在内存地址上它们是两个不同的对象。synchronized 判定为不同的锁,导致互斥失效。

解决方案:使用 userId.toString().intern()。

.intern() 方法会强制去 Java 的字符串常量池中查找。如果池中已存在该值的字符串,则返回池中的对象引用。这确保了只要 ID 值相同,获取到的锁对象内存地址就一定是相同的,从而保证了锁的有效性。

3. 事务与锁的边界问题(关键点)

潜在 Bug:如果将 synchronized 代码块写在带有 @Transactional 注解的事务方法内部,会存在数据不一致风险。

时序分析:

  1. 线程 A 执行完业务逻辑,释放锁。

  2. 此时 Spring 的事务尚未提交(事务提交通常在方法结束后的 AOP 切面中完成),数据库中暂无数据。

  3. 就在“锁释放”与“事务提交”之间的时间差内,线程 B 获取到锁,查询数据库。

  4. 由于 A 未提交,B 查到的依然是“未购买”,B 继续下单,导致并发问题。

    解决方案:扩大锁的范围,将其包裹在事务方法之外。

    在调用事务方法之前先加锁,确保只有当事务完全提交之后,锁才会被释放,后续线程才能进入。

4. Spring 事务失效的修复(AOP 代理)

引入问题:将锁移到事务方法外部后,变成了“普通方法调用事务方法”。由于这是在同一个类内部直接调用同类的另一个方法(this.xxx()),请求没有经过 Spring 的 AOP 代理类,被调用方法的 @Transactional 也就失效了,因为它是靠代理生效的

解决方案:

引入 AspectJ 依赖,通过 AopContext.currentProxy() 获取当前的代理对象,利用代理对象调用事务方法,确保事务切面生效。

最终代码逻辑抽象

// 入口方法(无事务)
public void seckillVoucher(Long userId) {
    // 锁住特定用户,使用 intern 确保锁对象唯一
    synchronized(userId.toString().intern()) {
        // 获取代理对象,防止事务失效
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        // 通过代理对象调用事务方法
        proxy.createVoucherOrder(userId);
    }
}

// 事务方法
@Transactional
public void createVoucherOrder(Long userId) {
    // 1. 查询订单是否存在
    // 2. 扣减库存
    // 3. 创建订单
}

三、 集群架构下的失效分析

随着业务量增长,我们将服务升级为集群架构(利用 Nginx 负载均衡,部署了两个节点:8081 和 8082)。

在集群模式下进行测试时,发现上述 synchronized 方案彻底失效。同一个用户依然可以在两台服务器上重复下单。

失效的根本原因:JVM 内存隔离

  • 节点 8081:是一个独立的 Java 进程,拥有独立的堆内存空间。它的 synchronized 锁维护在自己的对象头(Object Header)和监视器(Monitor)中。
  • 节点 8082:是另一个独立的 Java 进程,拥有另一块堆内存。
  • 互不可见:当 Nginx 将同一个用户的两个请求分别分发到 8081 和 8082 时,两个节点都检查自己的内存,发现“没有锁”,于是同时执行业务。

结论:Java 原生的锁机制(synchronized/Lock)只能解决单进程内的并发问题,无法解决跨进程(多节点)的并发问题。

四、 最终演进:分布式锁

为了解决集群环境下的并发安全问题,我们需要将锁的控制权从 JVM 内存中移出,存储在一个所有服务节点都能访问的公共存储组件中。

本项目引入了 Redis 来实现分布式锁。

1. 核心原理

利用 Redis 的 SETNX (SET if Not eXists) 命令。该命令具有原子性:只有当 Key 不存在时才能写入成功。

  • 获取锁:所有节点在执行业务前,先尝试向 Redis 写入一个特定的 Key(如 lock:order:userId)。
    • 写入成功(返回 1):视为获取锁成功,执行业务。
    • 写入失败(返回 0):视为获取锁失败,说明其他节点正在处理该用户的请求,当前请求由于互斥被拦截。
  • 释放锁:业务执行完毕后,删除该 Key。

2. 架构转变

  • Before (单体):线程抢夺的是 JVM 堆内存 中的对象监视器。
  • After (集群):线程抢夺的是 Redis 中的唯一 Key。

通过这种方式,我们实现了跨 JVM 进程的互斥控制,无论架构扩展到多少个节点,同一用户的操作都必须经过 Redis 的统一排队,解决了集群下的“一人一单”问题。

五、 总结

该功能的实现有以下技术点:

  1. 并发性能优化:理解锁粒度对吞吐量的影响,避免盲目加锁。
  2. JVM 内存机制:通过 intern() 解决了对象引用导致的锁失效问题。
  3. Spring 源码原理:深入理解了 Spring 事务的生效边界(Transaction)与动态代理(AOP)的调用机制。
  4. 分布式架构思维:清晰认识到本地锁在集群环境下的局限性,并能够根据业务场景引入 Redis 分布式锁解决跨进程并发问题。