秒杀系统
代码已上传至gitee上,地址:https://gitee.com/lin-jinghao/dazuodianping
全局ID生成器
全局唯一ID生成策略:
- UUID
- Redis自增
- snowflake算法
- 数据库自增
这里使用Redis自增的数值,并拼接一些其它信息
Redis自增ID策略:
- 每天一个key,方便统计订单量
- ID构造是 时间戳 + 计数器
ID的组成部分:
- 符号位:1bit,永远为0,表示为正数
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit,秒内的计数器,每秒支持2*32次方个不同的ID
@Component
public class RedisIdWorker {private static final long BEGIN_TIMESTAMP=1640995200L;private static final int COUNT_BITS=32;@Resourceprivate StringRedisTemplate stringRedisTemplate;public long nextId(String keyPrefix){// 1.生成时间戳LocalDateTime now=LocalDateTime.now();long nowSecond=now.toEpochSecond(ZoneOffset.UTC);long timestamp=nowSecond-BEGIN_TIMESTAMP;// 2.生成序列号// 获取到当前日期String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));long count =stringRedisTemplate.opsForValue().increment("icr:"+keyPrefix+":"+date);// 3.拼接并返回return timestamp <<COUNT_BITS | count;}
}
实现秒杀下单
下单时需要判断两点:
@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate SeckillVoucherMapper seckillVoucherMapper;@Resourceprivate RedisIdWorker redisIdWorker;// 秒杀优惠券订单@Transactionalpublic Result seckillVoucherOrder(Long voucherId) {
// 1.根据id查询优惠券SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀未开始return Result.fail("秒杀尚未开始");}
// 3.判断秒杀是否结束if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已经结束return Result.fail("秒杀已经结束");}
// 4.判断库存是否充足if (seckillVoucher.getStock() < 1){
// 库存不足return Result.fail("库存不足");}
// 5.扣减库存UpdateWrapper<SeckillVoucher> updateWrapper = new UpdateWrapper<>();updateWrapper.set("stock",seckillVoucher.getStock() - 1);int update = seckillVoucherMapper.update(null, updateWrapper);
// 6.创建订单VoucherOrder voucherOrder = new VoucherOrder();
// 订单idlong orderId = redisIdWorker.uniqueId("order");voucherOrder.setId(orderId);
// 用户idvoucherOrder.setVoucherId(UserHolder.getUser().getId());
// 代金券idvoucherOrder.setUserId(voucherId);save(voucherOrder);
// 7.返回订单idreturn Result.ok(orderId);}
结果如下:
库存超卖问题(多线程并发问题)分析
就是在高并发的场景下,可能会有多个线程同时进行查询,当商品数量仅剩1个时,多个线程同时查询,都判断为1,都会进行下单。
悲观锁和乐观锁保证库存
使用乐观锁解决库存超卖(多线程并发安全)
采用CAS法解决多线程并发安全问题:
@Transactionalpublic Result seckillVoucher(Long voucherId) {
// 1.根据id查询优惠券SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀未开始return Result.fail("秒杀尚未开始");}
// 3.判断秒杀是否结束if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已经结束return Result.fail("秒杀已经结束");}
// 4.判断库存是否充足if (seckillVoucher.getStock() < 1){
// 库存不足return Result.fail("库存不足");}
// 5.扣减库存boolean update = seckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).gt("stock", 0).update(); //设置库存大于0if (!update){return Result.fail("库存不足!");}// 6.创建订单VoucherOrder voucherOrder = new VoucherOrder();
// 订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);
// 用户idvoucherOrder.setUserId(UserHolder.getUser().getId());
// 代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);
// 7.返回订单idreturn Result.ok(orderId);}
- jmeter测试:
使用悲观锁实现一人一单功能
- 注意:在下面代码对createVoucherOrder要进行AOP代理,不能直接用this进行调用,否则会产生spring的事务失效现象
<!-- 基于aop代理工厂面向切面编程所需依赖--><dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId></dependency>
暴露代理对象
// 秒杀优惠券订单public Result seckillVoucher(Long voucherId) {
// 1.根据id查询优惠券SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀未开始return Result.fail("秒杀尚未开始");}
// 3.判断秒杀是否结束if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已经结束return Result.fail("秒杀已经结束");}
// 4.判断库存是否充足if (seckillVoucher.getStock() < 1) {
// 库存不足return Result.fail("库存不足");}Long userId = UserHolder.getUser().getId();
// 确保当用户id一样时,锁就会一样synchronized (userId.toString().intern()) {
// createVoucherOrder不具有事务功能,需要获得当前对象的代理对象IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);}}@Transactionalpublic Result createVoucherOrder(Long voucherId) {Long userId = UserHolder.getUser().getId();//查询用户是否已经购买过了int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();if (count > 0) {return Result.fail("您已经购买过了!");}// 6.扣减库存boolean update = seckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).gt("stock", 0).update();if (!update) {return Result.fail("库存不足!");}// 7.创建订单VoucherOrder voucherOrder = new VoucherOrder();
// 订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);
// 用户idvoucherOrder.setUserId(userId);
// 代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);
// 8.返回订单idreturn Result.ok(orderId);}
集群下线程并发安全问题
- 开启两个tomcat服务器
- 修改nginx配置文件
- postman测试(debug调试):同时发送两个请求
放行之后数据库中有两条数据:
- 出现以上问题的原因是因为多个JVM都是属于自己的锁监视器,每个JVM中的线程运行时,都会根据自己的锁监视器进行多线程之间的调用。而不会和其他JVM中的锁监视器有关系。所以集群部署的方式下,使用synchronized锁并不能解决多线程并发安全问题。
使用分布式锁优化一人一单问题
使用悲观锁解决一人一单问题时时采用synchronize(同步锁)的方式来实现,但是在集群部署的模式下并不能解决多线程并发的安全性问题。所以可以采用Redis中的setnx在集群当中充当锁监视器,实现在多个服务器当中只有一个锁。
- 创建锁监视器
public class SimpleRedisLock implements ILock{private StringRedisTemplate stringRedisTemplate;private String name;public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {this.stringRedisTemplate = stringRedisTemplate;this.name = name;}private static final String ID_PREFIX= UUID.randomUUID().toString(true)+"-";private static final String KEY_PREFIX="lock:";private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;static {UNLOCK_SCRIPT=new DefaultRedisScript<>();UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));UNLOCK_SCRIPT.setResultType(Long.class);}@Overridepublic boolean tryLock(long timeoutSec) {String threadId=ID_PREFIX+Thread.currentThread().getId();Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success);}/*@Overridepublic void unlock() {stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(KEY_PREFIX+name),ID_PREFIX+Thread.currentThread().getId());}*/public void unlock() {String threadId=ID_PREFIX+Thread.currentThread().getId();String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);if(threadId.equals(id)) {stringRedisTemplate.delete(KEY_PREFIX + name);}}
}
- 调用分布式锁,实现一人一单功能优化,在集群部署下不会出现多线程并发的安全性问题。
public Result seckillVoucher(Long voucherId) {
// 1.根据id查询优惠券SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀未开始return Result.fail("秒杀尚未开始");}
// 3.判断秒杀是否结束if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已经结束return Result.fail("秒杀已经结束");}
// 4.判断库存是否充足if (seckillVoucher.getStock() < 1) {
// 库存不足return Result.fail("库存不足");}Long userId = UserHolder.getUser().getId();
// 创建分布式锁对象SimpleRedisLock distriLock = new SimpleRedisLock( stringRedisTemplate,"order:" + userId);boolean isLock = distriLock.tryLock(1200L);
// 判断是否获取锁成功if (!isLock) {
// 获取锁失败return Result.fail("不允许重复下单");}
// 获取锁成功
// createVoucherOrder不具有事务功能,需要获得当前对象的代理对象try {IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);} finally {distriLock.unlock();}}// 扣减库存、创建订单@Transactionalpublic Result createVoucherOrder(Long voucherId) {Long userId = UserHolder.getUser().getId();//查询用户是否已经购买过了int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();if (count > 0) {return Result.fail("您已经购买过了!");}// 6.扣减库存boolean update = seckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).gt("stock", 0).update();if (!update) {return Result.fail("库存不足!");}// 7.创建订单VoucherOrder voucherOrder = new VoucherOrder();
// 订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);
// 用户idvoucherOrder.setUserId(userId);
// 代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);
// 8.返回订单idreturn Result.ok(orderId);}
分布式锁误删优化
为了防止因为线程阻塞而导致的分布式锁误删问题,在线程获取分布式锁的时候,向缓存中添加分布式锁的标识。当线程要释放锁的时候,查询缓存中的分布式锁的标识是否和自己的相同,相同的话就释放锁,不同的话就不做操作。
- 使用Lua脚本实现分布式锁的原子性
if(redis.call('get',KEYS[1])==ARGV[1]) thenreturn redis.call('del',KEYS[1])
end
return 0
public class SimpleRedisLock implements ILock{private StringRedisTemplate stringRedisTemplate;private String name;public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {this.stringRedisTemplate = stringRedisTemplate;this.name = name;}private static final String ID_PREFIX= UUID.randomUUID().toString(true)+"-";private static final String KEY_PREFIX="lock:";private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;static {UNLOCK_SCRIPT=new DefaultRedisScript<>();UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));UNLOCK_SCRIPT.setResultType(Long.class);}@Overridepublic boolean tryLock(long timeoutSec) {String threadId=ID_PREFIX+Thread.currentThread().getId();Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success);}@Overridepublic void unlock() {stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(KEY_PREFIX+name),ID_PREFIX+Thread.currentThread().getId());}/*public void unlock() {String threadId=ID_PREFIX+Thread.currentThread().getId();String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);if(threadId.equals(id)) {stringRedisTemplate.delete(KEY_PREFIX + name);}}*/
}
使用Redisson实现分布式锁
Redisson是一个在Redis的基础上实现的Java驻内存数据网格。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中就包含了各种分布式锁的实现。
public Result seckillVoucher(Long voucherId) {
// 1.根据id查询优惠券SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀未开始return Result.fail("秒杀尚未开始");}
// 3.判断秒杀是否结束if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已经结束return Result.fail("秒杀已经结束");}
// 4.判断库存是否充足if (seckillVoucher.getStock() < 1) {
// 库存不足return Result.fail("库存不足");}Long userId = UserHolder.getUser().getId();RLock lock = redissonClient.getLock("lock:order:" + userId);boolean isLock = lock.tryLock();
// 判断是否获取锁成功if (!isLock) {
// 获取锁失败return Result.fail("不允许重复下单");}
// 获取锁成功,创建订单try {return createVoucherOrder(voucherId);} finally {lock.unlock();}}// 扣减库存、创建订单@Transactionalpublic Result createVoucherOrder(Long voucherId) {Long userId = UserHolder.getUser().getId();//查询用户是否已经购买过了int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();if (count > 0) {return Result.fail("您已经购买过了!");}// 6.扣减库存boolean update = seckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).gt("stock", 0).update();if (!update) {return Result.fail("库存不足!");}// 7.创建订单VoucherOrder voucherOrder = new VoucherOrder();
// 订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);
// 用户idvoucherOrder.setUserId(userId);
// 代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);
// 8.返回订单idreturn Result.ok(orderId);}
秒杀优化(异步秒杀)
问题描述:在之前的秒杀业务中,客户端向Nginx代理服务器发送请求,Nginx做负载代理到Tomcat服务器,整个业务流程中,查询优惠券、查询订单、减库存、创建订单都是操作数据库来完成的。对数据库做太多的读写操作的话整个业务耗时就会很长,并发能力就会很差。
采用异步操作进行优化:
- 将校验用户购买资格的业务流程放到Redis缓存当中,当客户端发送请求时就会在缓存当中判断用户的购买资格,如果没有购买资格就直接返回错误。
- 如果有购买资格就保存优惠券、用户、订单id到阻塞队列,然后后台数据库异步读取队列中的信息,完成下单。
为了保证判断用户是否有购买资格的业务的原子性,需要使用Lua脚本执行业务。如果用户没有购买资格,就直接返回异常。如果有购买资格,完成将优惠券、用户、订单id写入阻塞队列,等待数据库完成异步下单操作。
需求:
- 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
- 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
- 如果抢购成功,将优惠券id和用户id封装后存入消息队列
- 开启线程任务,不断从消息队列中获取信息,实现异步下单功能
在创建秒杀券的同时将秒杀券的库存存入缓存当中
@SpringBootTest
class HmDianPingApplicationTests {@Resourceprivate ShopServiceImpl shopService;@Resourceprivate StringRedisTemplate stringRedisTemplate;@Testvoid testSaveShop(){shopService.saveShop2Redis(1L,10L);}@Resourceprivate VoucherServiceImpl voucherService;@Testvoid add(){Voucher voucher = new Voucher();voucher.setShopId(1L);voucher.setTitle("200元代金券");voucher.setSubTitle("周一至周五均可使用").setRules("全场通用\\n无需预约\\n可无限叠加\\n不兑现、不找零\\n仅限堂食").setPayValue(8000L).setActualValue(10000L).setType(1).setStock(100).setBeginTime(LocalDateTime.of(2022,10,10,0,0,0)).setEndTime(LocalDateTime.of(2022,11,29,0,0,0));voucherService.addSeckillVoucher(voucher);}@Testvoid loadShopDats(){//1.查询店铺信息List<Shop> list = shopService.list();//2.把店铺分组,按照typeId分组,typeId一致放到一个集合Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));//3.分批完成写入Redisfor (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {Long typeId= entry.getKey();String key=SHOP_GEO_KEY+typeId;//3.2.获取同类型的店铺的集合List<Shop> value = entry.getValue();List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());//3.3.写入redisfor (Shop shop : value) {locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(),new Point(shop.getX(),shop.getY())));}stringRedisTemplate.opsForGeo().add(key,locations);}}
}@Override@Transactionalpublic void addSeckillVoucher(Voucher voucher) {// 保存优惠券save(voucher);// 保存秒杀信息SeckillVoucher seckillVoucher = new SeckillVoucher();seckillVoucher.setVoucherId(voucher.getId());seckillVoucher.setStock(voucher.getStock());seckillVoucher.setBeginTime(voucher.getBeginTime());seckillVoucher.setEndTime(voucher.getEndTime());seckillVoucherService.save(seckillVoucher);//秒杀到库存stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());}
基于Lua脚本完成用户下单资格验证
--1.参数列表
--1.1.优惠券id
local voucherId =ARGV[1]
--1.2。用户id
local userId = ARGV[2]
--1.3.订单id
local orderId=ARGV[3]--2.数据key
--2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
--2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
--3.脚本业务
--3.1.判断库存是否充足get stockKey
if( tonumber (redis.call('get' , stockKey)) <= 0) then--3.2库存不足,返回1return 1
end
--3.2.判断用户是否下单 sismember orderKey userId
if(redis.call('sismember', orderKey,userId) == 1) then--3.3.存在。说明是重复下单。返回2return 2
end
--3.4.扣库存 incrby stockKey -1
redis.call('incrby' , stockKey , -1)
--3.5.下单保存用户sadd orderKey userId
redis.call('sadd', orderKey,userId )
--3.6 发送消息到队列当中XADD stream.orders * k1 v1 k2 v2..
redis.call('xadd', 'stream.orders' , '*' , 'userId', userId ,'voucherId' ,voucherId,'id', orderId)
return 0
如果抢购成功,将优惠券id和用户id封装后存入消息队列
- RabbitMq设置:环境设置直接通过docker进行
- RabbitMq整合:
1.导入pow文件和配置application.yml文件
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId></dependency>
spring:# RabbitMQrabbitmq:host: 192.168.232.5username: guestpassword: guestvirtual-host: /port: 5672listener:simple:# 消费者最小数量concurrency: 10# 消费者最大数量max-concurrency: 10# 限制消费者每次处理消息的数量prefetch: 1template:retry:# 发布重试enabled: true
2.配置RabbitConfig类:创建交换机和队列(通过路由key)
@Configuration
public class RabbitMqConfig {//seckillprivate static final String QUEUE = "seckillQueue";private static final String EXCHANGE = "seckillExchange";@Beanpublic Queue queue(){return new Queue(QUEUE);}@Beanpublic TopicExchange topicExchange(){return new TopicExchange(EXCHANGE);}@Beanpublic Binding binding(){return BindingBuilder.bind(queue()).to(topicExchange()).with("seckill.#");}
}
3.在业务中加入RabbitMq逻辑
/*** 秒杀优惠券订单(消息队列异步——RabbitMq)* @param voucherId* @return*/public Result seckillVoucher(Long voucherId) {Long userId = UserHolder.getUser().getId();//执行lua脚本Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,Collections.emptyList(),voucherId.toString(),userId.toString());//判断是否为0int r = result.intValue();if(r != 0){//不为0,代表没有购买资格return Result.fail(r == 1? "库存不足" : "不能重复下单");}//2.2 为0,有购买资格,把下单信息保存到消息队列long orderId = redisIdWorker.nextId("order");//订单信息VoucherOrder voucherOrder = new VoucherOrder();voucherOrder.setId(orderId);voucherOrder.setUserId(userId);voucherOrder.setVoucherId(voucherId);//添加到消息队列中mqSender.sendSeckillMessage(JsonUtil.object2JsonStr(voucherOrder));return Result.ok(orderId);}
开启线程任务,不断从消息队列中获取信息,实现异步下单功能
@Service
@Slf4j
public class MqSender {@Autowiredprivate RabbitTemplate rabbitTemplate;//发送秒杀信息public void sendSeckillMessage(String msg){log.info("发送消息:" + msg);rabbitTemplate.convertAndSend("seckillExchange", "seckill.message", msg);}
}
@Service
@Slf4j
public class MQReceiver {@Autowiredprivate VoucherOrderServiceImpl voucherOrderService;@Autowiredprivate RedisTemplate redisTemplate;@RabbitListener(queues = "seckillQueue")public void receive(String msg){log.info("接收消息:" + msg);VoucherOrder voucherOrder = JsonUtil.jsonStr2Object(msg, VoucherOrder.class);voucherOrderService.createVoucherOrder(voucherOrder);}
}