温馨提示:建议在PC端浏览~
优惠券秒杀
-
全局唯一ID
-
每个店铺都可以发布优惠券,当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:
- id的规律性太明显
- 受单表数据量的限制
-
全局ID生成器
- 全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
- 唯一性
- 高可用
- 高性能
- 递增性
- 安全性
- 由这些特性,我们可以联想到Redis的String类型,其中非普通字符串类型的数据可以通过INCRBY做自增操作(当然,实现全局唯一ID不止Redis这一种方案)。但为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:

- ID的组成部分:
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
- 全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
-
全局唯一ID生成策略(后三者在企业实际开发中使用较多)
- UUID(使用较少,不满足自增,返回值是字符串类型)
- Redis自增
- snowflake算法(雪花算法)
- 数据库自增(不是简单的插入数据时ID自增,而是单独维护一张自增表,多个表的数据共用这张自增表,从而保证全局ID的唯一性)
-
Redis自增ID策略
- 每天一个key,方便统计订单量
- ID构造是时间戳+计数器
-
Redis自增ID策略实现示例
-
@Component
public class RedisIDWorker {
private static final long BASE_TIMESTAMP = 1767225600L;//基本时间戳,从2026.1.1 00:00:00开始
private static final long COUNT_BITS = 32;//序列号位数
private StringRedisTemplate stringRedisTemplate;
public RedisIDWorker(StringRedisTemplate stringRedisTemplate){
this.stringRedisTemplate = stringRedisTemplate;
}public long nextId(String prefix){
LocalDateTime now = LocalDateTime.now();
//获取时间戳
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond – BASE_TIMESTAMP;
//获取当前日期(年月日)
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//生成序列号(自增长),若没有这个key,则会自动创建,并从0开始自增
long increment = stringRedisTemplate.opsForValue().increment("icr:" + prefix + ":" + date);
return timestamp << COUNT_BITS | increment;
}
} -
测试代码如下:
-
@Resource
private RedisIDWorker redisIDWorker;ExecutorService executor = Executors.newFixedThreadPool(500);
@Test
public void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);//闭锁,计数器,计数器为0时,所有线程开始执行(Java8新特性)Runnable task = () -> {
for(int i=0; i<100; i++){
long id = redisIDWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();//减1
};long begin = System.currentTimeMillis();
for(int i=0; i<300; i++){
executor.execute(task);
}
latch.await();//等待闭锁为0
long end = System.currentTimeMillis();
System.out.println("time = " + (end – begin));
}
-
@Resource
-
@Component
-
-
实现优惠券秒杀下单
- 每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:
-
- 表关系如下:
- tb_voucher:优惠券的基本信息,优惠金额、使用规则等。
- tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息。
- 优惠券秒杀的下单功能流程图
-
- 下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单。
- 库存是否充足,不足则无法下单。
- 下单时需要判断两点:
-
- 每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:
-
超卖问题
-
-
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
-
悲观锁
- 认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁。
-
乐观锁
- 认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。
- 如果没有修改则认为是安全的,自己才更新数据。
- 如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。
- 认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。
-
-
乐观锁(更新数据时使用)
- 乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
-
版本号法(初始库存和版本号都为1)
-
-
CAS法(初始库存为1)
-
- 补充:但是这种方法存在请求成功率低的问题。例如在库存修改之前,有多个线程查询了最初的库存值,然后其中某一个线程修改了库存,剩下的线程在更新时发现库存值发生了变化,所以都会返回错误信息。(解决办法:此场景针对库存而言,不需要保证库存与之前查到的完全一致,只需要保证库存大于0即可)
-
-
- 乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
-
小结
- 超卖这样的线程安全问题,解决方案有哪些?
-
1、悲观锁:添加同步锁,让线程串行执行
- 优点:简单粗暴
- 缺点:性能一般
-
2、乐观锁:不加锁,在更新时判断是否有其它线程在修改
- 优点:性能好
- 缺点:存在成功率低的问题
-
- 超卖这样的线程安全问题,解决方案有哪些?
-
-
—人—单
-
需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单。
-
流程图
-
-
关键代码示例
-
// pom.xml:
<!—aspectj—>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>// HmDianPingApplication:(在启动类上添加下面的注解)
@EnableAspectJAutoProxy(exposeProxy = true)// 暴露代理对象// VoucherOrderServiceImpl:
Long userId = UserHolder.getUser().getId();
// 一人一单
// 必须在createOrder方法完成之后才能释放锁,这样才能保证事务已经提交,新增的订单才会被插入数据库中
// userId.toString()虽然会将userId转换成字符串,但是转换成字符串时每次都会创建新的对象(即使内容一样),这样就不能确保同一个用户上的是同一把锁
// .intern()会创建一个字符串常量池,如果字符串常量池中已经存在该字符串,那么就会返回该字符串,否则就会创建一个新的字符串,并放入字符串常量池中,这样就能确保锁的是同一个用户
synchronized (userId.toString().intern()){
//获取当前代理对象
IVoucherOrderService currentProxy = (IVoucherOrderService) AopContext.currentProxy();
// 如果直接调用createOrder方法,相当于this.createOrder(),即调用的是目标对象的createOrder方法,而不是代理对象的,但是由于事务是spring拿着代理对象做的,因此事务会失效
// 因此需要自己手动地获取代理对象调用createOrder方法,事务才能生效
return currentProxy.createOrder(voucherId);
}@Transactional
public Result createOrder(Long voucherId) {
//一人一单
Long userId = UserHolder.getUser().getId();
// 判断该用户是否已经下过单
Integer orderCount = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if(orderCount > 0){
return Result.fail("该用户已经下过单");
}
// 扣减库存,乐观锁,在更新库存时,判断库存是否大于0
boolean success = seckillVoucherService.update().setSql("stock = stock – 1").eq("voucher_id", voucherId).gt("stock", 0).update();
if(!success){
log.info("库存不足");
return Result.fail("库存不足");
}
//生成订单号
long orderId = redisIDWorker.nextId("order");
log.info("成功扣减库存,订单号为:{}", orderId);
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
// 保存订单
save(voucherOrder);
// 返回订单号
return Result.ok(orderId);
}- 关键点
-
1、加什么锁?
- 由于乐观锁用于更新数据,而当前需求是插入数据,所以无法使用乐观锁,最终选择使用悲观锁。
-
2、锁加在哪?
- 要实现一人一单,就要保证在事务提交(数据库已经插入新订单数据)之后才能释放锁,所以最终选择将操作数据库的那部分代码单独抽出成一个方法(createOrder),在这个方法上加上事务注解,最后在上层调用这个方法的地方加锁,这样就可以保证事务提交之后才释放锁。
-
3、锁的对象是谁?
- 参考上面VoucherOrderServiceImpl中synchronized代码块上方的注释。
-
4、事务失效
- 参考上面VoucherOrderServiceImpl中synchronized代码块中的注释。
-
- 关键点
-
// pom.xml:
-
一人一单的并发安全问题
-
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
-
模拟集群模式:
-
1、我们将服务启动两份(Idea提供的功能),端口分别为8081和8082:
-
- 步骤说明:使用ctrl+d复制一份启动项,在编辑配置中加入虚拟机选项,并设置端口号:
-
-
-
2、然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:
-
-
更改nginx的配置文件后需要在命令行窗口中执行以下指令来重新加载配置文件:
- nginx.exe -s reload
-
最后重启一下nginx。
-
-
3、现在,用户请求会在这两个节点上负载均衡,再次测试下是否存在线程安全问题。
-
-
-
一人一单的并发安全问题分析
- 当项目以集群的方式部署在多台服务器上时,每台服务器都是一个单独的JVM,每个JVM内部都拥有自己的锁监视器,所以当同一个用户的两次相同请求被发送到不同的两台服务器时,synchronized锁失效了,这两次请求都能成功获取锁,从而出现一人多单的并发安全问题。
- 解决一人一单并发安全问题,需要引入分布式锁,关于分布式锁请参考另一篇文章:https://blog.csdn.net/YL20040426/article/details/161523601?spm=1001.2014.3001.5501
- 当项目以集群的方式部署在多台服务器上时,每台服务器都是一个单独的JVM,每个JVM内部都拥有自己的锁监视器,所以当同一个用户的两次相同请求被发送到不同的两台服务器时,synchronized锁失效了,这两次请求都能成功获取锁,从而出现一人多单的并发安全问题。
-
-
Redis优化秒杀
-
优化思路分析
-
-
思考:既然要在Redis中判断是否有购买资格(判断秒杀库存、校验一人一单),那么Redis中肯定是要存储对应的库存信息和用户信息的,那么我们选择什么数据结构来存储这些信息呢?
- 对于库存信息,只是一个单个的数值,我们可以直接使用String结构来存储,也方便预减库存;而对于用户信息,我们可以使用set结构来存储用户id,每次校验时只要判断当前用户id是否存在就可以知道这个用户是否购买过当前这个秒杀券。如下图:
- 对于库存信息,只是一个单个的数值,我们可以直接使用String结构来存储,也方便预减库存;而对于用户信息,我们可以使用set结构来存储用户id,每次校验时只要判断当前用户id是否存在就可以知道这个用户是否购买过当前这个秒杀券。如下图:
-
优化后的秒杀业务流程图
-
-
改进秒杀业务,提高并发性能
-
需求
- 1、新增秒杀优惠券的同时,将优惠券信息保存到Redis中。
- 2、基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功。
- 3、如果抢购成功,将优惠券id和用户id封装后存入阻塞队列。
- 4、开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能。
-
代码实现
-
seckill.lua:
-
— 准备ARGV
local voucherId = ARGV[1]
local userId = ARGV[2]— 准备KEYS(Lua脚本中用..做拼接)
local stockKey = "seckill:stock:" .. voucherId
local orderKey = "seckill:order:" .. voucherId— 判断库存
if(tonumber(redis.call('get', stockKey)) <= 0) then
— 库存不足,返回1
return 1
end
— 判断用户是否重复下单
if(redis.call('sismember', orderKey, userId) == 1) then
— 用户重复下单,返回2
return 2
end
— 用户有购买资格,扣减库存,加入集合,返回0
redis.call('incrby', stockKey, –1)
redis.call('sadd', orderKey, userId)
return 0
-
— 准备ARGV
-
VoucherOrderServiceImpl.java:
-
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIDWorker redisIDWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static{
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}private IVoucherOrderService currentProxy;
// 阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
// 线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();@PostConstruct// 标记在某个方法上,表示该方法在Bean的依赖注入完成后自动执行,且只会执行一次
private void init(){
// 开启线程任务,不断从阻塞队列获取信息,实现异步下单
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 线程任务
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while(true){
try {
// 获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.poll();
if(voucherOrder != null){
// 处理订单
handleVoucherOrder(voucherOrder);
}
} catch (Exception e) {
log.info("处理订单异常!");
}
}
}
}/**
* 秒杀券下单
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
// 查询秒杀券信息
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 判断是否在秒杀时间段内
if(LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())){
return Result.fail("秒杀未开始");
}
if(LocalDateTime.now().isAfter(seckillVoucher.getEndTime())){
return Result.fail("秒杀已结束");
}//获取当前用户id
Long userId = UserHolder.getUser().getId();
// 执行Lua脚本,获取结果
Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString());
int res = result.intValue();
// 判断Lua脚本执行结果,0-成功 1-库存不足 2-重复下单
if(res != 0){
return Result.fail(res == 1 ? "库存不足!" : "不允许重复下单!");
}
long orderId = redisIDWorker.nextId("order");// 获取当前代理对象
currentProxy = (IVoucherOrderService) AopContext.currentProxy();// 封装优惠券id、用户id,订单id,并加入阻塞队列
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
// 将订单加入阻塞队列
orderTasks.add(voucherOrder);// 返回订单id
return Result.ok(orderId);
}public void handleVoucherOrder(VoucherOrder voucherOrder){
// 创建锁
RLock lock = redissonClient.getLock("lock:order:" + voucherOrder.getUserId());
// 尝试获取锁
boolean isLock = lock.tryLock();
if(!isLock){
//获取锁失败,说明该用户已经成功下单过,当前请求属于非法请求,直接返回错误信息
log.info("不允许重复下单!");
return;
}
try {
currentProxy.createOrder(voucherOrder);
} catch (IllegalStateException e) {
log.info("处理订单异常!");
} finally {
//释放锁
lock.unlock();
}
}@Transactional
public void createOrder(VoucherOrder voucherOrder) {
//获取用户id
Long userId = voucherOrder.getUserId();
//获取优惠券id
Long voucherId = voucherOrder.getVoucherId();
//一人一单
// 判断该用户是否已经下过单
Integer orderCount = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if(orderCount > 0){
log.info("该用户已经下过单!");
return;
}
// 扣减库存,乐观锁,在更新库存时,判断库存是否大于0
boolean success = seckillVoucherService.update().setSql("stock = stock – 1").eq("voucher_id", voucherId).gt("stock", 0).update();
if(!success){
log.info("库存不足");
return;
}// 保存订单
log.info("成功扣减库存,订单号为:{}", voucherOrder.getId());
save(voucherOrder);
}
}- @PostConstruct
-
核心作用:标记在某个方法上,表示该方法在 Bean 的依赖注入完成后自动执行,且只会执行一次。常用于初始化操作(如加载缓存、初始化数据、检查必要配置等)。
-
执行时机
- 1、构造方法执行完毕。
- 2、依赖注入完成(@Autowired、@Resource 等属性已赋值)。
- 3、Bean 完全初始化前(在 @PostConstruct 之后,InitializingBean.afterPropertiesSet() 或自定义 init-method 之前执行)。
-
注意事项
- 方法必须无参、无返回值(void)。
- 方法不能抛出已检查异常(运行时异常可以,但会导致 Bean 初始化失败)。
- 一个类中可以有多个 @PostConstruct 方法,但执行顺序不确定,不推荐。
- 如果 Bean 是原型(prototype)作用域,每次获取都会调用(很少这样用)。
- 需要确保项目中包含 javax.annotation-api 依赖(JDK 9+ 需要手动添加)。
-
- @PostConstruct
-
@Slf4j
-
-
小结
-
秒杀业务的优化思路是什么?
- 1、先利用Redis完成库存余量、一人一单判断,完成抢单业务。
- 2、再将下单业务放入阻塞队列,利用独立线程异步下单。
-
基于阻塞队列的异步秒杀存在哪些问题?
-
内存限制问题
- 现在我们使用的是JDK里面的阻塞队列,而这个阻塞队列使用的是JVM的内存,若不加以限制,在高并发情况下,可能有无数订单对象需要创建并放入阻塞队列,在将来可能出现内存溢出问题。所以我们在创建阻塞队列时设置了队列的长度,但若阻塞队列存满了,而此时还有新的订单需要放入阻塞队列的话就放不进去了,所以就存在内存限制问题。
-
数据安全问题
- 第一,我们现在都是基于内存中存储这些订单数据,若服务宕机,内存中的数据都会丢失,就会导致用户那边显示成功下单了但却查不到订单信息的情况;
- 第二,若子线程从阻塞队列中取出一个订单去处理,但是在处理的过程中出现了问题,没能完成订单的处理,但由于此订单已经从阻塞队列中移除,最终这个订单数据就丢失了。
-
解决内存限制问题和数据安全问题的方法:使用消息队列。(课程中讲解的是Redis消息队列,由于不常使用,故直接跳过,之后去学习RabbitMQ 或 Kafaka 等其他消息中间件)
-
-
-
-



