【Redis实战篇】秒杀系统:一人一单高并发实战(synchronized锁实战与事务失效问题)

🔥个人主页:北极的代码(欢迎来访)

🎬作者简介:java后端学习者

❄️个人专栏:苍穹外卖日记SSM框架深入JavaWeb

命运的结局尽可永在,不屈的挑战却不可须臾或缺!
前言:

我们前面对秒杀下单,库存超卖这一问题具体分析了一下,我们利用了锁机制进行解决,然而在实际中不仅仅只有一个库存超卖问题,下面我们继续探讨。

摘要:

本文针对电商秒杀系统中的"一人多单"问题展开分析,提出通过悲观锁实现用户限购的解决方案。

文章详细剖析了synchronized锁与数据库悲观锁的差异,并指出在Spring单例模式下使用synchronized的局限性

重点探讨了锁粒度优化方案,通过userId.toString().intern()确保相同用户使用同一把锁,同时解决了事务与锁顺序导致的并发问题。

最终方案结合AopContext.currentProxy()保证事务生效,并引入commons-pool2管理Redis连接池提升性能。该方案有效实现了高并发场景下的用户限购功能,同时兼顾系统性能与数据一致性。

实际业务分析:

在实际电商中,商家进行秒杀活动主要是为了进行促销,增加用户,然而我们上面的逻辑,并没有限制单个用户购买的数量,假如有100张优惠卷,而这100张优惠卷仅仅被一个人抢走了,那这样就违背了我们商家的初衷,同时还会给商家带来损失,严重的可能会扰乱市场,低买高卖等行为。

实现思路:

代码的初步实现:

java 复制代码
        //6.一人一单
        Long userId=UserHolder.getUser().getId();
        //6.1根据查询订单,是否购买过
        int count= query().eq("user_Id",userId).eq("voucher_Id",voucherId).count();
        //6.2判断是否存在
        if (count>0){
            return Result.fail("该用户已经购买");
        }


        //扣减库存
        Boolean success=iSeckillVoucherService.
                update()
                .setSql("stock=stock-1")
                .eq("voucher_id",voucherId)
                .gt("stock",0)
                .update();
        if (!success){
            return Result.fail("库存不足");
        }
问题分析:

然后我们测试,结果发现,并不是我们预期的结果,一个用户依然是下了多单,原因是什么呢

其实很简单,我们模拟的是多线程环境,也就是高并发环境,由此就会产生一系列的问题,跟我们上一章讲的库存超卖逻辑相同。多线程同时进行查询订单,返回的 都是0,那么之后就都能通过判断,都能下单,因此出现了一人多单 的问题。


问题解决:

我们前面应对这些问题是加锁,乐观锁,但是需要注意的是,乐观锁是在更新数据时使用的,而我们这些,是插入数据,要判断是否存在,而不是有没有修改过。因此这里用的是悲观锁。

synchronized 和数据库悲观锁的对比

对比项 synchronized 数据库悲观锁(for update
锁的是什么 Java 对象 数据库行记录
作用范围 单个 JVM 进程 数据库层面,多进程共享
实现方式 JVM 内置关键字 SQL 语句 select ... for update
适用场景 单体应用 分布式应用、多服务
性能 较轻量(有锁升级) 较重(涉及数据库 IO)

代码实现:

我们把从一人一单的判断,到最后的代码抽取成一个方法,这个方法就是处理一人一单限制,扣减库存,生成订单的业务。我们把事务加到这个抽取出来的方法,改成public,然后在这个方法上加上synchronized锁。

java 复制代码
      //创建订单的逻辑
        return createVoucherOrder(voucherId);

    }

     @Transactional
     public synchronized  Result createVoucherOrder(Long voucherId) {
        //6.一人一单
        Long userId=UserHolder.getUser().getId();
        //6.1根据查询订单,是否购买过
        int count= query().eq("user_Id",userId).eq("voucher_Id", voucherId).count();
        //6.2判断是否存在
        if (count>0){
            return Result.fail("该用户已经购买");
        }
        //扣减库存
        Boolean success=iSeckillVoucherService.
                update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)
                .update();
        if (!success){
            return Result.fail("库存不足");
        }
        //创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 5.1 生成全局唯一订单ID
        long orderId = redisIdWork.nextId("order");
        voucherOrder.setId(orderId);
        // 5.2 设置用户ID(从ThreadLocal获取当前登录用户)
        voucherOrder.setUserId(userId);
        // 5.3 设置优惠券ID
        voucherOrder.setVoucherId(voucherId);
        // 5.4 设置支付状态(未支付)
        voucherOrder.setStatus(0);
        // 5.5 设置创建时间
        voucherOrder.setCreateTime(LocalDateTime.now());
        // 保存订单
        save(voucherOrder);

        // ========== 6. 返回订单ID ==========
        return Result.ok(orderId);
    }
关于synchronized锁:

synchronizedJava 内置的锁机制保证同一时刻只有一个线程能执行被锁住的代码。这个知识我们在java基础的时候已经学过了,但考虑时间有点久,大多数同学可能忘记了,包括博主自己也就仅仅记住了这个名字。

当我们在这方法上加上这个synchronized锁时,就代表着**: 同一时刻只有一个线程能执行整个 createVoucherOrder 方法。**


关于synchronized放在方法上的问题:

锁的对象 = this(当前对象)

在Spring中,@Service 默认是单例,所以:

  • 整个应用只有一个 VoucherOrderServiceImpl 对象

  • 所有用户、所有请求,拿到的都是同一把锁

this 是 Spring 创建的 VoucherOrderServiceImpl 对象

java 复制代码
java

// Spring 容器里只有一个这个对象(单例)
@Autowired
private VoucherOrderServiceImpl voucherOrderService;  // 这就是那个唯一的对象

因为 Spring 的 @Service 默认是单例,所以整个应用只有一个 VoucherOrderServiceImpl 对象

这意味着什么

java 复制代码
java

// 在 Spring 项目中
public synchronized Result seckillVoucher() { }

// 等价于
synchronized (唯一的一个VoucherOrderServiceImpl对象) { }

// 所有用户、所有请求,拿到的都是同一把锁!
// 所以全部排队,性能极差 ❌
// 线程1:调用 serviceA.method1()
// 线程2:调用 serviceA.method2()

// 这两个线程会互斥吗?
// 会!因为两个方法锁的都是同一个对象 serviceA
// 线程1拿到锁,线程2必须等 ❌ 排队

因此我们把锁加在用户上:

一个用户只能下一单,同一个用户加一把锁,这样同一个用户在下单时,就不会有其他线程进行抢夺的问题了,实现了一人一单。

在这里:我们仅仅是拿到用户的id的值toString,

复制代码
synchronized(userId.toString())

.intern() 保证: 不管 userId 多大,相同内容的字符串一定是同一个对象!

直接用 Long userId 不行,因为 new 出来的 Long 对象不是同一个!

java

复制代码
Long userId1 = 1L;  // 这是从常量池拿的(-128~127范围内)
Long userId2 = 1L;  // 也是从常量池拿,是同一个对象 ✅

Long userId1 = 128L;  // 超出范围,new 的新对象
Long userId2 = 128L;  // 也是 new 的新对象,不是同一个 ❌

问题: Long 类型只在 -128 到 127 范围内有缓存,超出范围每次都是新对象!

没有 intern(),相同内容的字符串可能是不同的对象 ,导致synchronized失效。

java 复制代码
java

Long userId = 100L;

String s1 = userId.toString();  // 创建字符串对象 #1
String s2 = userId.toString();  // 创建字符串对象 #2(新对象!)

System.out.println(s1 == s2);  // false(不是同一个对象)

明明内容都是"100",却是两个不同的对象

加上 intern() 之后

java 复制代码
java

Long userId = 100L;

String s1 = userId.toString().intern();  // 从常量池取
String s2 = userId.toString().intern();  // 还是从常量池取同一个

System.out.println(s1 == s2);  // true(同一个对象)✅

为什么 toString() 会创建新对象

java 复制代码
java

// Long.toString() 源码(简化)
public String toString() {
    // 每次都 new 一个字符串对象
    return new String(......);
}

每次调用都 new,所以即使是相同内容,也是不同对象。


synchronized 中的影响

没有 intern() 的情况

java 复制代码
java

// 用户A(id=100)同时发来两个请求

// 请求1
String key = userId.toString();  // 对象A
synchronized (key) {  // 锁对象A
    // 创建订单
}

// 请求2(同时执行)
String key = userId.toString();  // 对象B(新对象!)
synchronized (key) {  // 锁对象B(和对象A是两把不同的锁)
    // 也能同时进来 ❌
}

结果: 两把不同的锁 → 请求1和请求2可以同时执行 → 一人两单!

intern() 的情况

java 复制代码
java

// 请求1
String key = userId.toString().intern();  // 从常量池拿对象O
synchronized (key) {  // 锁对象O
    // 创建订单
}

// 请求2
String key = userId.toString().intern();  // 还是拿对象O
synchronized (key) {  // 锁的还是对象O(同一把锁)
    // 必须等请求1执行完 ✅
}

结果: 同一把锁 → 请求2必须等请求1 → 一人一单生效!

图解对比

没有 intern()

text

复制代码
内存:
┌─────────────┐   ┌─────────────┐
│  字符串对象A  │   │  字符串对象B  │
│  内容"100"   │   │  内容"100"   │
└─────────────┘   └─────────────┘
       ↑                  ↑
    请求1拿这把锁       请求2拿这把锁
       
两把不同的锁 → 可以同时执行 ❌

intern()

text

复制代码
字符串常量池:
┌─────────────────────────┐
│     字符串对象 "100"      │  ← 只有一个
└─────────────────────────┘
       ↑              ↑
    请求1拿这把锁   请求2也拿这把锁
       
同一把锁 → 只能排队执行 ✅

进一步优化实现

我们这里是在方法内部加的锁,然后执行的时候,先开启事务,然后再执行锁机制,之后我们先释放锁,然后才提交事务。

这时就会出现一个问题,我们在释放锁之后,意味着其他的线程也会进来,由于事务还未提交,查询订单的时候看不到我们已经修改的数据,不知道这个用户购买过没有,因此其他线程进来之后,还是会继续购买,一人一单问题仍然存在。

因此我们就反过来:

我们把锁加到函数的外面,事务被包裹在里面。然后这里还是存在一个问题:我们调用的这个方法的事务并不会生效,为什么呢,createVoucherOrder 是通过 this 直接调用的,不是通过 Spring 代理对象调用,所以 @Transactional 不生效。

java 复制代码
@Autowired
private UserService userService;  // 这个确实是代理对象 ✅

public void buy() {
    userService.createOrder();  // 通过代理调用 → 事务生效 ✅
}

public void buy2() {
    this.createOrder();  // 通过真实对象调用 → 事务失效 ❌
}

关键区别:

  • userService(注入的)→ 代理对象 → 事务生效

  • this(自己)→ 真实对象 → 事务失效


图解

text

复制代码
Spring容器:
┌─────────────────────────────────────────┐
│  @Autowired                             │
│  private UserService userService         │
│      ↓                                   │
│  ┌─────────────────────────────────┐    │
│  │      代理对象(增强版)           │    │
│  │  ✅ 能开启事务                   │    │
│  │  ✅ 能提交/回滚                  │    │
│  └─────────────────────────────────┘    │
│              ↓ 包含                      │
│  ┌─────────────────────────────────┐    │
│  │      真实对象(你写的代码)        │    │
│  │  ❌ 没有事务能力                 │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘

this 指向 → 真实对象 ❌
userService 指向 → 代理对象 ✅
解决方法:
java 复制代码
    Long userId=UserHolder.getUser().getId();
        synchronized(userId.toString().intern()) {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy. createVoucherOrder(voucherId);
        }

添加依赖:

复制代码
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

这是 Apache Commons Pool2 依赖,是一个对象池库。


一、它是什么

帮你管理和复用对象,避免频繁创建和销毁对象。

二、现实生活例子

没有对象池(每次新建)

text

复制代码
你去图书馆看书:
每次去 → 买一本新书 → 看完 → 扔掉
下次去 → 又买一本新书 → 看完 → 扔掉

问题:浪费钱、浪费时间

有对象池(复用)

text

复制代码
你去图书馆看书:
办一张借书卡 → 从书架借书 → 看完 → 还回去
下次去 → 又从书架借同一本书

优点:省钱、省时间、高效

commons-pool2 就是管理这个"书架"的。

三、在黑马点评中用来

用来管理 Redis 连接池

yaml

复制代码
spring:
  redis:
    host: localhost
    port: 6379
    lettuce:
      pool:
        max-active: 8      # 最大连接数
        max-idle: 8        # 最大空闲连接
        min-idle: 0        # 最小空闲连接

当配置了 Redis 连接池后,commons-pool2 就是底层实现。

四、图解:有池 vs 无池

没有连接池(每次新建)

text

复制代码
请求1 → 创建连接 → 用 → 关闭连接
请求2 → 创建连接 → 用 → 关闭连接
请求3 → 创建连接 → 用 → 关闭连接

每次都要:TCP三次握手 + 认证 + 断开
性能差 ❌

有连接池(复用)

text

复制代码
启动时 → 创建一批连接放进池里

请求1 → 从池里借一个 → 用完归还
请求2 → 从池里借一个 → 用完归还
请求3 → 从池里借一个 → 用完归还

连接一直存在,不用反复创建
性能好 ✅
五、常见使用场景
场景 说明
数据库连接池 HikariCP、Druid
Redis连接池 Lettuce + commons-pool2
HTTP连接池 HttpClient
线程池 Java 自带 ThreadPoolExecutor
自定义对象池 自己实现的池

然后在启动类上:

Spring AOP 的开关配置 ,作用是开启代理对象暴露功能,让你能在代码中通过 AopContext.currentProxy() 获取当前类的代理对象。

什么时候需要这个配置
场景 是否需要
外部通过 @Autowired 调用 ❌ 不需要
内部通过 this 调用,又想事务生效 需要
使用 AopContext.currentProxy() ✅ 必须
方案 配置 代码
方案1:注入自己 不需要额外配置 @Autowired private IVoucherOrderService self;
方案2:currentProxy 需要 exposeProxy = true AopContext.currentProxy()
关于代理对象:
实现者 类型 说明
VoucherOrderServiceImpl 真实对象 你写的业务逻辑
$Proxy123(代理对象) 代理对象 Spring 生成的增强版

结语:如果对你有帮助,请**点赞,关注,收藏,**你的支持就是我最大的鼓励!

相关推荐
weixin_424999362 小时前
Redis怎样利用Lua脚本批量抓取多类型数据
jvm·数据库·python
yeyuningzi2 小时前
如何解决海量数据库许可过期导致的无法启动问题
数据库·海量数据库
大大杰哥2 小时前
Spring AI 开发笔记:ChatClient 的创建、配置与工具函数注册
人工智能·笔记·spring
0xDevNull2 小时前
Spring中统一异常处理详细教程
java·开发语言·后端
2301_817672262 小时前
Golang怎么写TODO待办应用_Golang TODO应用教程【深入】
jvm·数据库·python
one_love_zfl2 小时前
java面试-spring篇
java·spring·面试
2301_817672262 小时前
PHP源码开发用一体机合适吗_集成硬件局限性说明【操作】
jvm·数据库·python
炘爚2 小时前
深入解析C++多态:虚函数与动态联编
开发语言·c++·多态·虚函数