前言
在上一篇,我们通过一个简单的案例,分享了怎么利用redis设计并实现一个秒杀抢购的功能,关于秒杀功能中,需要注意的比较关键的有两个问题
- 高并发场景下,怎么确保不会超卖
- 高并发场景下,如何确保一人一单
具体在设计的时候,需要结合实际的项目和业务场景,比如1000个人抢购50件商品的时候,抢购是一方面,抢购完毕之后还要下单,这个下单的业务是和抢购放在一起呢还是单独处理,需结合具体场景以及系统的架构进行分析
下面用一个更通俗的业务场景来比较全面的
业务场景描述
某些商家为了吸引客户进店消费,推出线上发放优惠券活动,抢到优惠券的用户消费时可以抵扣一定金额,
某商家在周五下午5点到6点之间发放50张优惠券,优惠券的使用有效期为一个月,而参与抢购的用户多达1000人
在支付宝或美团等平台上,商家可以提前将优惠券的信息通过后台系统进行录入(有的商家有自己的点餐APP,可以通过后退系统设置)
上面的这个业务,相信不少在支付宝推出的不定期抢购优惠券活动时都参与过,为方便演示,我们还是梳理下整体的业务流程方便理解
通过流程图,大致需要这样两步:
- 添加活动信息,这个实际中由商家通过后台页面操作,本例提供一个接口实现
- 在第一步操作完成后,抢购的时间,代金券的有效期,甚至代金券可以抵扣的金额等信息就确定了,就开始抢购的过程
- 抢到代金券的用户锁定了名额,同时会增加一条抢购的活动订单
1、创建基本的表结构
上述流程中涉及到的表,活动代金券表,抢购订单表,用户表
活动代金券表
CREATE TABLE `skill_vochers` (
`id` int(12) NOT NULL AUTO_INCREMENT,
`vocher_id` int(12) DEFAULT NULL,
`amount` int(12) DEFAULT NULL,
`start_time` datetime DEFAULT NULL,
`end_time` datetime DEFAULT NULL,
`is_valid` int(11) DEFAULT NULL,
`create_date` datetime DEFAULT NULL,
`update_date` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
抢购订单表
CREATE TABLE `vochers_orders` (
`id` int(12) NOT NULL AUTO_INCREMENT,
`order_no` varchar(255) DEFAULT NULL,
`vocher_id` int(12) DEFAULT NULL,
`user_id` int(12) DEFAULT NULL,
`status` int(12) DEFAULT NULL,
`skill_order_id` int(12) DEFAULT NULL,
`order_type` int(12) DEFAULT NULL,
`is_valid` int(11) DEFAULT NULL,
`create_date` datetime DEFAULT NULL,
`update_date` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=207 DEFAULT CHARSET=utf8;
用户表
CREATE TABLE `t_user` (
`id` int(12) NOT NULL,
`username` varchar(255) DEFAULT NULL,
`password` varchar(255) DEFAULT NULL,
`nickname` varchar(255) DEFAULT NULL,
`phone` varchar(64) DEFAULT NULL,
`email` varchar(64) DEFAULT NULL,
`avatar_url` varchar(255) DEFAULT NULL,
`create_date` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`update_date` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
2、搭建一个springboot的demo工程
做好前置的配置工作(maven依赖,实体映射,yml配置文件等)后,这里直接使用上一篇的工程,继续添加内容即可
按照流程图上面所说,首先,需要一个添加抢购活动的接口
@Resource
private SkillVocherService skillVocherService;
@PostMapping("/addSkillVocher")
public String addSkillVocher(@RequestBody SkillVocher skillVocher){
skillVocherService.add(skillVocher);
return "add success";
}
@Service
public class SkillVocherService {
@Resource
private SkillVocherMapper skillVocherMapper;
@Resource
private VocherOrderMapper vocherOrderMapper;
private static AtomicInteger atomicInteger1 = new AtomicInteger(1);
private static AtomicInteger atomicInteger2 = new AtomicInteger(1);
public void add(SkillVocher skillVocher){
SkillVocher skillVocher1 = skillVocherMapper.selectVocher(skillVocher.getVocherId());
if(skillVocher1 != null){
throw new RuntimeException("该券已经拥有了抢购活动");
}
skillVocher.setId(atomicInteger1.incrementAndGet());
skillVocherMapper.save(skillVocher);
System.out.println("添加成功");
}
}
然后通过postman等接口调用工具,初始化一条数据到skill_vochers表,
3、秒杀功能实现关键代码
实际项目中,秒杀时需要考虑的因素是很多的,对于某个参与抢购的用户来讲,秒杀的逻辑中一定会有许多前置的校验,在流程图中也做了一些说明,只有充分考虑到多方面的校验,才不至于出现一些“意外”,毕竟最后都是跟钱挂钩的
下面贴出秒杀的关键代码,省略了部分的校验,
@GetMapping("/doSkill")
public String doSkill(int voucherId,int userId){
return skillVocherService.doSkill(voucherId,userId);
}
public String doSkill(int vocherId,int userId){
//1、判断代金券是否加入活动了
SkillVocher skillVocher = skillVocherMapper.selectVocher(vocherId);
if(skillVocher==null){
throw new RuntimeException("该券未加入抢购活动");
}
//2、判断当前用户是否已经抢过
VochersOrders defineOrder = vocherOrderMapper.findDefineOrder(userId, skillVocher.getId());
if(defineOrder != null){
throw new RuntimeException("该用户已经抢到过代金券,无需再抢了");
}
//3、扣减库存
int count = skillVocherMapper.stockDecrease(skillVocher.getId());
if(count ==0){
throw new RuntimeException("该券已经卖完了");
}
//4、下单
VochersOrders vochersOrders = new VochersOrders();
vochersOrders.setId(atomicInteger2.incrementAndGet());
vochersOrders.setUserId(userId);
vochersOrders.setSkillOrderId(skillVocher.getId());
vochersOrders.setVocherId(skillVocher.getVocherId());
String orderNo = UUID.randomUUID().toString().replaceAll("-","").substring(4,9);
vochersOrders.setOrderNo(orderNo);
vochersOrders.setOrderType(1);
vochersOrders.setStatus(0);
int saveCount = vocherOrderMapper.save(vochersOrders);
if(saveCount !=0){
return "skill success";
}else {
return "skill fail";
}
}
这段代码,没有使用redis,放在单机环境下,如果部署高并发的环境,理论上也是没问题的,我们不妨测试下吧,将工程运行起来
浏览器调一下接口:localhost:8088/doSkill?voucherId=1&userId=1111
有个1111的用户抢到了一张券,券的数量减少1个
订单表多了一条记录
但是放在高并发环境下,将会出现什么问题呢?还记得在本文开篇提出的2个问题吗?超卖和一人抢多单的问题,下面通过jemeter模拟高并发环境下的情况
测试1:超卖问题复现
准备一个csv文件,保存了100个用户,将活动表的数量手动调整为50个,模拟100用户抢50张券
csv用户
jemeter相关配置
5000个线程并发抢购
设置请求参数
为模拟100个用户,这里的userId使用变量,数据读取从csv中读取
点击开始按钮,进行压测,观察数据表结果,
活动表的50张代金券变成-149张
订单表产生了199条订单
这个很明显发生了超卖,不用说,在高并发环境下,如果没有任何的控制,超卖问题必然会出现
测试2:一人抢多单
jemeter做简单的配置即可
点击开始压测,清空数据表再测试,
抢购活动表的优惠券数量只减少了1个,这个是正确的
订单表同一个用户却产生了31条订单,即一人多单的现象发生了
以上,通过压测复现了一下,在秒杀活动中,如果不对代码做任何的控制下,会产生的两个严重的问题,下面来探讨如何解决呢?
1、解决超卖问题
借助redis,和上一篇一样,为提升整体的性能,我们提前将待抢购的代金券信息放到redis中,因此改造添加活动代金券逻辑,主要将插入mysql表的操作放到插入到redis中
public void add(SkillVocher skillVocher){
String key = RedisKeyConstants.skill_vochers.getKey() + skillVocher.getVocherId();
if(redisTemplate.hasKey(key)){
redisTemplate.delete(key);
}
Map<String,Object> map = redisTemplate.opsForHash().entries(key);
if(!map.isEmpty() && (int)map.get("amount") > 0){
throw new RuntimeException("该券已经拥有了抢购活动");
}
Date now = new Date();
skillVocher.setIsValid(1);
skillVocher.setCreateDate(now);
skillVocher.setUpdateDate(now);
redisTemplate.opsForHash().putAll(key,BeanUtil.beanToMap(skillVocher));
}
按照上面同样的方式调用一下接口,
同时改造秒杀抢购的逻辑,将需要查代金券库存的地方,修改为从redis中获取,下单的逻辑不变,
public String doSkill(int vocherId,int userId){
//1、判断代金券是否加入活动了
String key = RedisKeyConstants.skill_vochers.getKey() + vocherId;
Map<String,Object> entries = redisTemplate.opsForHash().entries(key);
SkillVocher skillVocher = BeanUtil.mapToBean(entries,SkillVocher.class,true);
if(skillVocher==null){
throw new RuntimeException("该券未加入抢购活动");
}
//2、判断当前用户是否已经抢过
VochersOrders defineOrder = vocherOrderMapper.findDefineOrder(userId, skillVocher.getId());
if(defineOrder != null){
throw new RuntimeException("该用户已经抢到过代金券,无需再抢了");
}
//3、扣减库存
long count = redisTemplate.opsForHash().increment(key, "amount", -1);
if(count < 0){
throw new RuntimeException("该券已经卖完了");
}
//4、下单
VochersOrders vochersOrders = new VochersOrders();
vochersOrders.setId(atomicInteger2.incrementAndGet());
vochersOrders.setUserId(userId);
vochersOrders.setSkillOrderId(skillVocher.getId());
vochersOrders.setVocherId(skillVocher.getVocherId());
String orderNo = UUID.randomUUID().toString().replaceAll("-","").substring(4,9);
vochersOrders.setOrderNo(orderNo);
vochersOrders.setOrderType(1);
vochersOrders.setStatus(0);
int saveCount = vocherOrderMapper.save(vochersOrders);
if(saveCount !=0){
return "skill success";
}else {
return "skill fail";
}
}
改造完毕之后,清空数据表,按照多人抢购50张券的jemeter配置,开始压测,观察数据表的变化,
订单表产生了50个订单,
活动表的代金券数量却出现了负数,即出现了超卖问题
究其原因,在抢购逻辑的关键处,对于redis来说是分成了2步,即先查库存,然后再扣减库存,高并发场景下,这可能是在不同的线程中执行的,即可能是非原子操作的,如果将它们合在一个线程中执行呢?按照这个猜想,在redis中,提供了lua脚本,可以利用lua脚本来解决这个问题(关于lua,有兴趣的同学可以网上自行学习下,语法和shell比较相似)
提供简单的lua脚本:
if (redis.call('hexists',KEYS[1],KEYS[2]) == 1) then
local stock = tonumber(redis.call('hget',KEYS[1],KEYS[2]));
if (stock > 0) then
redis.call('hincrby',KEYS[1],KEYS[2],-1);
return stock;
end;
return 0;
end;
简单解释下,这段脚本的意思是:
如果传入的两个参数中,同时包含了业务key,以及"amount"这个字段,首先根据这两个key查找优惠券的数量是否大于0,大于0的情况下,才进行数量的减一
下面改造秒杀的代码,主要将扣减库存的逻辑修改为使用lua脚本执行
redis配置类添加lua脚本的bean
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
//设置value的序列化方式为JSOn
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
//设置key的序列化方式为String
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
@Bean
public DefaultRedisScript<Long> stockScript(){
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setLocation(new ClassPathResource("stock.lua"));
redisScript.setResultType(Long.class);
return redisScript;
}
}
public String doSkill(int vocherId,int userId){
//1、判断代金券是否加入活动了
String key = RedisKeyConstants.skill_vochers.getKey() + vocherId;
Map<String,Object> entries = redisTemplate.opsForHash().entries(key);
SkillVocher skillVocher = BeanUtil.mapToBean(entries,SkillVocher.class,true);
if(skillVocher==null){
throw new RuntimeException("该券未加入抢购活动");
}
//2、判断当前用户是否已经抢过
VochersOrders defineOrder = vocherOrderMapper.findDefineOrder(userId, skillVocher.getId());
if(defineOrder != null){
throw new RuntimeException("该用户已经抢到过代金券,无需再抢了");
}
//3、扣减库存
/*long count = redisTemplate.opsForHash().increment(key, "amount", -1);
if(count < 0){
throw new RuntimeException("该券已经卖完了");
}*/
//3、扣减库存采用 redis + lua
List<String> keys = new ArrayList<>();
keys.add(key);
keys.add("amount");
Long amount = (Long)redisTemplate.execute(defaultRedisScript, keys);
if(amount == null || amount <1) {
throw new RuntimeException("该券已经卖完了");
}
//4、下单
VochersOrders vochersOrders = new VochersOrders();
vochersOrders.setId(atomicInteger2.incrementAndGet());
vochersOrders.setUserId(userId);
vochersOrders.setSkillOrderId(skillVocher.getId());
vochersOrders.setVocherId(skillVocher.getVocherId());
String orderNo = UUID.randomUUID().toString().replaceAll("-","").substring(4,9);
vochersOrders.setOrderNo(orderNo);
vochersOrders.setOrderType(1);
vochersOrders.setStatus(0);
int saveCount = vocherOrderMapper.save(vochersOrders);
if(saveCount !=0){
return "skill success";
}else {
return "skill fail";
}
}
修改完毕后,我们使用jemeter相同的方式进行压测,观察redis中的数据变化和订单表的变化,
redis中 amount的数量变为0
订单表产生了50条数据
说到这里,可能还是有同学有点疑问,为什么使用了这种方式之后,就可以确保amount的数据和订单数据的正确呢?我们设想下,由于改进后的代码扣库存可以确保amount的数据没问题,就算是高并发多个线程同时走到扣库存这一段代码,由于redis执行命令是单线程的,总有一个线程进来的时候是抢完了的,一旦抢完了,就会进入到“该券已经卖完了”的异常中,从而下单的逻辑就进不去了
本篇通过代码结合压测的方式演示了高并发下秒杀存在的超卖问题,以及利用redis+lua解决超卖问题,篇幅比较长,有兴趣的同学可以深入研究,本篇到此结束,最后感谢观看!
需要源码的同学可前往下载
https://download.csdn.net/download/zhangcongyi420/15400165