前言
抢购问题不仅是电商类项目中一个重要的业务,也是许多开发人员在进阶过程中绕不开的问题,关于抢购,如果理清了前后的逻辑和里面涉及到的几个关键性的问题,问题就迎刃而解了
抢购中的几个常见问题
- 如何设计抢购功能?(表结构,以及整体的抢购思路)
- 不借助中间件如何实现抢购?(不借助redis)
- 怎么利用redis解决抢购中的超卖问题
- 怎么提升抢购的整体并发?
上图是抢购中的两个重要步骤,对于抢购用户,抽象来讲,服务端只需要完成对待抢购商品的锁定以及锁定后的下单操作即可
分开来说,商品名额锁定阶段,活动期间待抢购商品数量是有限的,参与抢购的用户数可能很大,因此必然存在高并发问题
既然存在高并发,为了提升整体的并发性能还能兼顾系统不至于崩溃,使用数据库作为秒杀抢购显然不合适,高并发场景下数据库IO将成为性能瓶颈,如此一来,参与抢购的活动涉及到的商品需借助redis来实现
抢购过程中,为保证公平,我们希望一个用户只能抢一单,但是在高并发场景下,用户数远大于商品数,如果逻辑控制的不合理,一定会出现超卖问题,即抢到商品的用户数大于实际参与抢购的商品数
来看一个简单的抢购案例
这里为简化业务,暂时先不操作数据库,下面的代码相信有过Java基础的同学都能看懂,模拟参与抢购的商品有10个,然后使用jemeter进行并发压测
SkillMapper模拟操作DB
public class SkillMapper {
public static Integer count = 10;
public Integer getCount() {
return count;
}
public void updateCount(Integer count){
SkillMapper.count = count;
}
}
抢购业务service
@Service
public class SkillService {
private SkillMapper skillMapper = new SkillMapper();
public void skillProduct(){
Integer count = skillMapper.getCount();
if(count >0){
System.out.println("恭喜你,抢到了");
count = count-1;
skillMapper.updateCount(count);
}else {
System.out.println("抱歉,商品抢完了");
}
}
}
提供一个接口
@RestController
public class SkillController {
@Resource
private SkillService skillService;
//localhost:8088/skill
@GetMapping("/skill")
public String doSkill(){
skillService.skillProduct();
return "skill finish";
}
}
启动工程,在浏览器反复调用:localhost:8088/skill
多调用几次后,最终会出现如下效果
在单进程下,这样是不会有问题的,下面在jemeter下,模拟使用100个用户进行调用
进行如上的配置后,重新启动工程,点击启动按钮,再次观察后台日志,异常现象产生了
如此,很多同学第一时间想到,加锁,搞定!想法没错,但注意这是抢购,在高并发环境下加锁相当占用整体的性能
如果不加锁呢?又期望同时兼顾性能问题,自然,redis成为首选,首先来看看如何使用redis配合完成抢购业务
整个抢购的业务和实现思路可以结合上面这张图理解,总结几点大概如下:
- 将参与秒杀抢购的商品提前放入redis队列这里使用redis的list结构,为保证数据的可读取性,key的保存格式如: 活动ID:ProductId
- 开始抢购时,每一个抢购成功的用户,从上面的队列中弹出一个数据
- 同时,为保证后面下单能快速操作数据,将抢购成功的用户和商品保存至redis,使用redis的set结构,set集合可以确保数据不重复,从而可保证一人一单
从上面的流程图看,我们似乎并没有考虑多线程并发的问题,其实正想说的是,redis是单线程操作的,并发过来的请求到redis的list中取数据时,即便多个请求仍然要按照先后顺序在redis中单线程处理(结合自己的实际业务进行设计)
按照上面的思路和设计,下面开始编码过程(代码有需要可私信我)
1、创建一张业务抢购表
CREATE TABLE `t_promotion_seckill` (
`ps_id` bigint(20) NOT NULL AUTO_INCREMENT,
`goods_id` int(11) NOT NULL,
`ps_count` int(255) NOT NULL,
`start_time` datetime(0) NULL DEFAULT NULL,
`end_time` datetime(0) NULL DEFAULT NULL,
`status` int(255) NULL DEFAULT NULL COMMENT '0-未开始 1-进行中 2-已结束',
`current_price` float NOT NULL DEFAULT 0,
PRIMARY KEY (`ps_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_unicode_ci ROW_FORMAT = Dynamic;
INSERT INTO `dataflow`.`t_promotion_seckill` (`ps_id`, `goods_id`, `ps_count`, `start_time`, `end_time`, `status`, `current_price`) VALUES ('1', '1101', '10', '2021-02-18 16:30:09', '2021-02-18 16:50:09', '0', '25');
2、搭建springboot工程,这里使用mybatis
整个项目结构如下:
由于代码较多,下面会对关键的部分做详细的说明,主要体现在如何结合redis完成抢购,即上面流程图中的右半边部分
首先我们在数据表中初始化一条数据:
简单说明下,将会对编号为1101的商品在16:30分到16:50分之间进行秒杀,秒杀数量为10个
为了将参与秒杀的商品提前放入redis的队列,这里使用一个简单的定时任务(也可以单独写个接口调用)
@Component
@EnableScheduling
public class SecKillTask {
@Resource
private PromotionSecKillMapper promotionSecKillMapper;
@Resource
private RedisTemplate redisTemplate;
@Scheduled(cron = "0/5 * * * * ?")
public void startSecKill(){
List<PromotionSecKill> list = promotionSecKillMapper.findUnstartSecKill();
for(PromotionSecKill ps : list){
System.out.println(ps.getPsId() + "秒杀活动启动");
//删掉以前重复的活动任务缓存
redisTemplate.delete("seckill:count:" + ps.getPsId());
/**
* 有多少库存商品,则初始化几个list对象
* 实际业务中,可能是拿出部分商品参与秒杀活动,通过后台的界面进行设置
*/
for(int i = 0 ; i < ps.getPsCount() ; i++){
redisTemplate.opsForList().rightPush("seckill:count:" + ps.getPsId() , ps.getGoodsId());
}
ps.setStatus(1);
}
}
}
定时任务启动后,在redis中将会存在如下的初始化数据:
3、下面来看关键的抢购部分的逻辑
@Service
public class PromotionSecKillService {
@Resource
private PromotionSecKillMapper promotionSecKillMapper;
@Resource
private RedisTemplate redisTemplate;
public void processSecKill(Long psId, String userid, Integer num) throws SecKillException {
PromotionSecKill ps = promotionSecKillMapper.findById(psId);
if (ps == null) {
throw new SecKillException("秒杀活动不存在");
}
if (ps.getStatus() == 0) {
throw new SecKillException("秒杀活动未开始");
} else if (ps.getStatus() == 2) {
throw new SecKillException("秒杀活动已结束");
}
Integer goodsId = (Integer) redisTemplate.opsForList().leftPop("seckill:count:" + ps.getPsId());
if (goodsId != null) {
//判断是否已经抢购过
boolean isExisted = redisTemplate.opsForSet().isMember("seckill:users:" + ps.getPsId(), userid);
if (!isExisted) {
System.out.println("抢到商品啦,快去下单吧");
redisTemplate.opsForSet().add("seckill:users:" + ps.getPsId(), userid);
}else{
redisTemplate.opsForList().rightPush("seckill:count:" + ps.getPsId(), ps.getGoodsId());
throw new SecKillException("抱歉,您已经参加过此活动,请勿重复抢购!");
}
} else {
throw new SecKillException("抱歉,该商品已被抢光,下次再来吧!");
}
}
}
在该段代码中,基本上是按照上述的业务流程图进行的,其中有一处值得注意,在高并发场景下,为了避免一个人抢到多个商品,在else逻辑中,我们使用了队列的补偿处理,这里也是恰好利用了redis的list特点
4、最后提供一个接口
@GetMapping("/processKill")
public Map processKill(Long psId , String userId) throws Exception{
Map result = new HashMap();
try {
promotionSecKillService.processSecKill(psId , userId , 1);
Map data = new HashMap();
result.put("code", "200");
result.put("message", "秒杀成功");
result.put("data", data);
} catch (SecKillException e) {
result.put("code", "500");
result.put("message", e.getMessage());
}
return result;
}essKill(Long psid , String userid) throws Exception{
Map result = new HashMap();
try {
promotionSecKillService.processSecKill(psid , userid , 1);
Map data = new HashMap();
result.put("code", "200");
result.put("message", "秒杀成功");
result.put("data", data);
} catch (SecKillException e) {
result.put("code", "500");
result.put("message", e.getMessage());
}
return result;
}
启动工程后,当看到redis中的数据初始化完毕后,接口调用:
localhost:8088/processKill?psId=1&userId=0001
当前0001的用户秒杀成功之后,将会从队列中删除一个商品
同时在set集合中保存了抢购用户的信息
5、测试在高并发环境下上面的功能是否好使
在jemeter中做如下的配置
为模拟100个用户抢购,这里使用一个外部的csv文件,文件内容如下:
配置完毕后,重新启动项目,并初始化相关的数据后,使用jemeter进行压测,观察后台打印日志
从后台日志看,redis队列中初始化的10个待秒杀的商品全部秒杀,并没有出现一个用户抢多单的现象
同时,redis的set集合中,也保存了对应抢到商品的10个用户
通过上面的案例演示,完成了使用redis进行秒杀抢购的功能,但是此案例我们省略了大量其他的业务,比如和优惠券结合,以及抢购后的下单操作,有兴趣的同学可以继续深入思考
需要源码的同学可前往下载https://download.csdn.net/download/zhangcongyi420/15400165