news 2026/4/24 22:18:08

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【Redis实战篇】秒杀系统:一人一单高并发实战(synchronized锁实战与事务失效问题)

🔥个人主页:北极的代码(欢迎来访)
🎬作者简介: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锁:

synchronizedJava 内置的锁机制保证同一时刻只有一个线程能执行被锁住的代码。这个知识我们在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 = trueAopContext.currentProxy()
关于代理对象:
实现者类型说明
VoucherOrderServiceImpl真实对象你写的业务逻辑
$Proxy123(代理对象)代理对象Spring 生成的增强版

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/24 22:15:34

03华夏之光永存:黄大年茶思屋榜文解法「15期3题」 PIM互调参数高精度计算-非线性函数高精度数学展开专项解法

华夏之光永存&#xff1a;黄大年茶思屋榜文解法「15期3题」 PIM互调参数高精度计算-非线性函数高精度数学展开专项解法 一、摘要 本题为无线射频无源器件设计与干扰抑制领域的核心工程难题&#xff0c;聚焦双频/时域周期信号激励下非线性函数PIM产物系数的高精度计算瓶颈。本文…

作者头像 李华
网站建设 2026/4/24 22:15:34

解密WandEnhancer:突破WeMod限制实现专业功能本地化增强

解密WandEnhancer&#xff1a;突破WeMod限制实现专业功能本地化增强 【免费下载链接】Wand-Enhancer Advanced UX and interoperability extension for Wand (WeMod) app 项目地址: https://gitcode.com/gh_mirrors/we/Wand-Enhancer 在游戏辅助工具领域&#xff0c;WeM…

作者头像 李华
网站建设 2026/4/24 22:12:27

英雄联盟智能助手League Akari:5分钟打造你的专属游戏体验

英雄联盟智能助手League Akari&#xff1a;5分钟打造你的专属游戏体验 【免费下载链接】League-Toolkit An all-in-one toolkit for LeagueClient. Gathering power &#x1f680;. 项目地址: https://gitcode.com/gh_mirrors/le/League-Toolkit League Akari是一款基于…

作者头像 李华