🔥个人主页:北极的代码(欢迎来访)
🎬作者简介:java后端学习者
❄️个人专栏:苍穹外卖日记,SSM框架深入,JavaWeb
✨命运的结局尽可永在,不屈的挑战却不可须臾或缺!
前言:
我们前面对秒杀下单,库存超卖这一问题具体分析了一下,我们利用了锁机制进行解决,然而在实际中不仅仅只有一个库存超卖问题,下面我们继续探讨。
摘要:
本文针对电商秒杀系统中的"一人多单"问题展开分析,提出通过悲观锁实现用户限购的解决方案。
文章详细剖析了synchronized锁与数据库悲观锁的差异,并指出在Spring单例模式下使用synchronized的局限性。
重点探讨了锁粒度优化方案,通过userId.toString().intern()确保相同用户使用同一把锁,同时解决了事务与锁顺序导致的并发问题。
最终方案结合AopContext.currentProxy()保证事务生效,并引入commons-pool2管理Redis连接池提升性能。该方案有效实现了高并发场景下的用户限购功能,同时兼顾系统性能与数据一致性。
实际业务分析:
在实际电商中,商家进行秒杀活动主要是为了进行促销,增加用户,然而我们上面的逻辑,并没有限制单个用户购买的数量,假如有100张优惠卷,而这100张优惠卷仅仅被一个人抢走了,那这样就违背了我们商家的初衷,同时还会给商家带来损失,严重的可能会扰乱市场,低买高卖等行为。
实现思路:
![]()
代码的初步实现:
//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锁。
//创建订单的逻辑 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锁:
synchronized是Java 内置的锁机制,保证同一时刻只有一个线程能执行被锁住的代码。这个知识我们在java基础的时候已经学过了,但考虑时间有点久,大多数同学可能忘记了,包括博主自己也就仅仅记住了这个名字。当我们在这方法上加上这个synchronized锁时,就代表着:同一时刻只有一个线程能执行整个createVoucherOrder方法。
关于synchronized放在方法上的问题:
锁的对象 =
this(当前对象)在Spring中,
@Service默认是单例,所以:
整个应用只有一个
VoucherOrderServiceImpl对象所有用户、所有请求,拿到的都是同一把锁
this是 Spring 创建的VoucherOrderServiceImpl对象java // Spring 容器里只有一个这个对象(单例) @Autowired private VoucherOrderServiceImpl voucherOrderService; // 这就是那个唯一的对象因为 Spring 的 @Service 默认是单例,所以整个应用只有一个 VoucherOrderServiceImpl 对象
这意味着什么
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 Long userId = 100L; String s1 = userId.toString(); // 创建字符串对象 #1 String s2 = userId.toString(); // 创建字符串对象 #2(新对象!) System.out.println(s1 == s2); // false(不是同一个对象)明明内容都是"100",却是两个不同的对象
加上
intern()之后java Long userId = 100L; String s1 = userId.toString().intern(); // 从常量池取 String s2 = userId.toString().intern(); // 还是从常量池取同一个 System.out.println(s1 == s2); // true(同一个对象)✅为什么
toString()会创建新对象java // Long.toString() 源码(简化) public String toString() { // 每次都 new 一个字符串对象 return new String(......); }每次调用都
new,所以即使是相同内容,也是不同对象。在
synchronized中的影响没有
intern()的情况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 // 请求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不生效。
@Autowired private UserService userService; // 这个确实是代理对象 ✅ public void buy() { userService.createOrder(); // 通过代理调用 → 事务生效 ✅ } public void buy2() { this.createOrder(); // 通过真实对象调用 → 事务失效 ❌ }关键区别:
userService(注入的)→ 代理对象 → 事务生效this(自己)→ 真实对象 → 事务失效
图解
text
Spring容器: ┌─────────────────────────────────────────┐ │ @Autowired │ │ private UserService userService │ │ ↓ │ │ ┌─────────────────────────────────┐ │ │ │ 代理对象(增强版) │ │ │ │ ✅ 能开启事务 │ │ │ │ ✅ 能提交/回滚 │ │ │ └─────────────────────────────────┘ │ │ ↓ 包含 │ │ ┌─────────────────────────────────┐ │ │ │ 真实对象(你写的代码) │ │ │ │ ❌ 没有事务能力 │ │ │ └─────────────────────────────────┘ │ └─────────────────────────────────────────┘ this 指向 → 真实对象 ❌ userService 指向 → 代理对象 ✅
解决方法:
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 生成的增强版 |
结语:如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!