个人主页北极的代码欢迎来访作者简介java后端学习者❄️个人专栏苍穹外卖日记SSM框架深入JavaWeb✨命运的结局尽可永在不屈的挑战却不可须臾或缺前言我们前面对秒杀下单库存超卖这一问题具体分析了一下我们利用了锁机制进行解决然而在实际中不仅仅只有一个库存超卖问题下面我们继续探讨。摘要本文针对电商秒杀系统中的一人多单问题展开分析提出通过悲观锁实现用户限购的解决方案。文章详细剖析了synchronized锁与数据库悲观锁的差异并指出在Spring单例模式下使用synchronized的局限性。重点探讨了锁粒度优化方案通过userId.toString().intern()确保相同用户使用同一把锁同时解决了事务与锁顺序导致的并发问题。最终方案结合AopContext.currentProxy()保证事务生效并引入commons-pool2管理Redis连接池提升性能。该方案有效实现了高并发场景下的用户限购功能同时兼顾系统性能与数据一致性。实际业务分析在实际电商中商家进行秒杀活动主要是为了进行促销增加用户然而我们上面的逻辑并没有限制单个用户购买的数量假如有100张优惠卷而这100张优惠卷仅仅被一个人抢走了那这样就违背了我们商家的初衷同时还会给商家带来损失严重的可能会扰乱市场低买高卖等行为。实现思路代码的初步实现//6.一人一单 Long userIdUserHolder.getUser().getId(); //6.1根据查询订单是否购买过 int count query().eq(user_Id,userId).eq(voucher_Id,voucherId).count(); //6.2判断是否存在 if (count0){ return Result.fail(该用户已经购买); } //扣减库存 Boolean successiSeckillVoucherService. update() .setSql(stockstock-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 userIdUserHolder.getUser().getId(); //6.1根据查询订单是否购买过 int count query().eq(user_Id,userId).eq(voucher_Id, voucherId).count(); //6.2判断是否存在 if (count0){ return Result.fail(该用户已经购买); } //扣减库存 Boolean successiSeckillVoucherService. update() .setSql(stockstock-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的值toStringsynchronized(userId.toString()).intern()保证不管 userId 多大相同内容的字符串一定是同一个对象直接用Long userId不行因为 new 出来的 Long 对象不是同一个javaLong 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 // 用户Aid100同时发来两个请求 // 请求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自己→ 真实对象 → 事务失效图解textSpring容器 ┌─────────────────────────────────────────┐ │ Autowired │ │ private UserService userService │ │ ↓ │ │ ┌─────────────────────────────────┐ │ │ │ 代理对象增强版 │ │ │ │ ✅ 能开启事务 │ │ │ │ ✅ 能提交/回滚 │ │ │ └─────────────────────────────────┘ │ │ ↓ 包含 │ │ ┌─────────────────────────────────┐ │ │ │ 真实对象你写的代码 │ │ │ │ ❌ 没有事务能力 │ │ │ └─────────────────────────────────┘ │ └─────────────────────────────────────────┘ this 指向 → 真实对象 ❌ userService 指向 → 代理对象 ✅解决方法Long userIdUserHolder.getUser().getId(); synchronized(userId.toString().intern()) { IVoucherOrderService proxy (IVoucherOrderService) AopContext.currentProxy(); return proxy. createVoucherOrder(voucherId); }添加依赖dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency这是Apache Commons Pool2依赖是一个对象池库。一、它是什么帮你管理和复用对象避免频繁创建和销毁对象。二、现实生活例子没有对象池每次新建text你去图书馆看书 每次去 → 买一本新书 → 看完 → 扔掉 下次去 → 又买一本新书 → 看完 → 扔掉 问题浪费钱、浪费时间有对象池复用text你去图书馆看书 办一张借书卡 → 从书架借书 → 看完 → 还回去 下次去 → 又从书架借同一本书 优点省钱、省时间、高效commons-pool2就是管理这个书架的。三、在黑马点评中用来用来管理 Redis 连接池yamlspring: 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、DruidRedis连接池Lettuce commons-pool2HTTP连接池HttpClient线程池Java 自带 ThreadPoolExecutor自定义对象池自己实现的池然后在启动类上是Spring AOP 的开关配置作用是开启代理对象暴露功能让你能在代码中通过AopContext.currentProxy()获取当前类的代理对象。什么时候需要这个配置场景是否需要外部通过Autowired调用❌ 不需要内部通过this调用又想事务生效✅需要使用AopContext.currentProxy()✅ 必须方案配置代码方案1注入自己不需要额外配置Autowired private IVoucherOrderService self;方案2currentProxy需要exposeProxy trueAopContext.currentProxy()关于代理对象实现者类型说明VoucherOrderServiceImpl真实对象你写的业务逻辑$Proxy123代理对象代理对象Spring 生成的增强版结语如果对你有帮助请点赞关注收藏你的支持就是我最大的鼓励