高并发秒杀系统
分析需求
场景分析
- 秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增。
- 秒杀一般是访问请求数量远远大于库存数量,只有少部分用户能够秒杀成功。
- 秒杀业务流程比较简单,一般就是下订单减库存。
问题分析
秒杀系统一般要注意的问题就是 :
- 库存少卖,超卖问题(原子性)
- 流量削峰,这里我们设定的时候每个用户只能秒杀一次所以比较好处理
执行流程
- 初始化数据,提前预热要秒杀的商品(项目里设置为启动,如果秒杀列表有就预热)
- 使用 redis 缓存秒杀的商品信息,使用redis来承担秒杀的压力最后生产秒杀到的用户,再到mysql生成订单
- 在秒杀时使用(事务,分布式锁两种方式都实现)对商品库存,保证原子性
设计思路图
秒杀系统
技术选型
开发语言 : Java 8 或 Java 11
框架技术: SpringBoot2.x ,Mybatis-plus ,Thymeleaf
中间件: Redis
数据库:MySQL 8.0
数据源: druid 1.16
测试工具: apache jmeter
数据库表设计
三张表,分别是
- 商品表: id 商品id 商品name 商品图片 商品类别 商品价格 库存
- 秒杀商品表 : id 商品id 秒杀开始时间 秒杀结束时间 秒杀价 可秒杀的数量
- 订单表 id 订单id 商品id 秒杀价格 用户id 地址 电话
sql表
CREATE DATABASE /*!32312 IF NOT EXISTS*/`seckill` /*!40100 DEFAULT CHARACTER SET utf8 */ USE `seckill`; DROP TABLE IF EXISTS `goods`; CREATE TABLE `goods` ( `id` int NOT NULL AUTO_INCREMENT, `goods_id` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '商品id', `goods_name` varchar(100) DEFAULT NULL COMMENT '商品名字', `goodtype` varchar(100) DEFAULT NULL COMMENT '商品类别', `price` double DEFAULT NULL COMMENT '商品价格', `img_path` varchar(200) DEFAULT NULL COMMENT '商品图片地址', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb3; /*Data for the table `goods` */ insert into `goods`(`id`,`goods_id`,`goods_name`,`goodtype`,`price`,`img_path`) values (5,'1001','华为手机','电子用品',5000,'/img/huaweiphone.jpg'); /*Table structure for table `goodsorder` */ DROP TABLE IF EXISTS `goodsorder`; CREATE TABLE `goodsorder` ( `id` int NOT NULL AUTO_INCREMENT, `order_id` varchar(100) DEFAULT NULL, `user_id` varchar(100) DEFAULT NULL, `goods_id` varchar(100) DEFAULT NULL, `telephone` varchar(20) DEFAULT NULL, `address` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb3; /*Data for the table `goodsorder` */ /*Table structure for table `seckillgoods` */ DROP TABLE IF EXISTS `seckillgoods`; CREATE TABLE `seckillgoods` ( `id` bigint NOT NULL AUTO_INCREMENT, `goods_id` varchar(100) DEFAULT NULL, `seckill_price` double DEFAULT NULL COMMENT '秒杀价格', `stock_num` int DEFAULT NULL COMMENT '秒杀库存', `start_time` datetime DEFAULT NULL COMMENT '开始时间', `end_time` datetime DEFAULT NULL COMMENT '结束时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3; /*Data for the table `seckillgoods` */ insert into `seckillgoods`(`id`,`goods_id`,`seckill_price`,`stock_num`,`start_time`,`end_time`) values (1,'1001',3599,10,'2022-03-07 00:00:00','2022-03-09 00:00:00');
项目配置文件
application.yml
spring: datasource: username: root password: root url: jdbc:mysql://localhost:3306/seckill? userSSL=true&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8 driver-class-name: com.mysql.cj.jdbc.Driver redis: database: 0 host: 120.79.14.203 port: 6379 password: root timeout: 1000 thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html mybatis-plus: configuration: map-underscore-to-camel-case: false log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # configuration: # log-impl: org.apache.ibatis.logging.stdout.StdOutImpl mapper-locations: classpath:mapper/*
redis 配置类
@Configuration public class RedisConfig { /** * @author 冷环渊 Doomwatcher * @context:redis 存储数据 序列化方式 * @date: 2022/3/7 21:19 * @param redisConnectionFactory * @return: org.springframework.data.redis.core.RedisTemplate<java.lang.String, java.lang.Object> */ @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); //声明 key 和 value 的序列化方式 StringRedisSerializer keySerializer = new StringRedisSerializer(); GenericFastJsonRedisSerializer valueSerializer = new GenericFastJsonRedisSerializer(); redisTemplate.setKeySerializer(keySerializer); redisTemplate.setValueSerializer(valueSerializer); redisTemplate.setHashKeySerializer(keySerializer); redisTemplate.setValueSerializer(valueSerializer); //多种的序列化方式 最好是谁序列化的谁处理 //GenericFastJsonRedisSerializer //Jackson2JsonRedisSerializer return redisTemplate; } }
redis工具类
package com.hyc.seckillsystem.util; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.SessionCallback; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Component; import java.util.List; import java.util.concurrent.TimeUnit; /** * @projectName: seckillsystem * @package: com.hyc.seckillsystem.util * @className: RedisUtil * @author: 冷环渊 doomwatcher * @description: TODO * @date: 2022/3/7 21:25 * @version: 1.0 */ @Component public class RedisUtil { @Autowired private RedisTemplate<String, Object> redisTemplate; // 设置缓存 public void set(String key, Object value) { redisTemplate.opsForValue().set(key, value); } //设置缓存和超时时间 public void set(String key, Object value, long timeout) { redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS); } //获取命中key 的内容 public Object get(String key) { if (!redisTemplate.hasKey(key)) { return null; } return redisTemplate.opsForValue().get(key); } //递减操作 public void decr(String key) { redisTemplate.opsForValue().decrement(key); } //触发 提交 参数里的sessionCallback 队列里的命令 public Object execute(SessionCallback sessionCallback) { return redisTemplate.execute(sessionCallback); } // 使用脚本语言 public Object execute(RedisScript script, List keylist) { return redisTemplate.execute(script, keylist); } // 是否存在 key public boolean hasKey(String key) { return redisTemplate.hasKey(key); } }
商品列表
商品列表界面,是比较简单的,设计思路还是基于传统的三层开发
Controller -> servier -> dao
这样的分层开发模式
实体类
这里为了方面展示,就展示实体类的属性,小伙伴们自行补充getter/setter以及构造方法等
public class goods { @Id @TableId(type = IdType.AUTO) private Integer id; private String goods_id; private String goods_name; private String goodtype; private double price; private String img_path; @TableField(exist = false) private SeckillGoods seckillgoods; }
DAO
虽然使用了 mybatis-plus 但是我们还是根据自己的查询需求新增了自己的方法,
@Repository public interface GoodsMapper extends BaseMapper<goods> { goods getGoodsByGoodsId(String goodId); List<goods> selectGoods(); List<goods> getGoodList(); }
映射文件
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.hyc.seckillsystem.dao.GoodsMapper"> <select id="getGoodsByGoodsId" resultType="com.hyc.seckillsystem.pojo.goods"> select seckill.goods.goods_id, seckill.goods.goods_name, seckill.goods.goodtype, seckill.goods.price, seckill.goods.img_path from seckill.goods where goods_id = #{goodId}; </select> <select id="getGoodList" resultType="com.hyc.seckillsystem.pojo.goods"> select seckill.goods.goods_id, seckill.goods.goods_name, seckill.goods.goodtype, seckill.goods.price, seckill.goods.img_path from seckill.goods </select> <resultMap id="goodsResult" type="com.hyc.seckillsystem.pojo.goods"> <id property="id" column="id"/> <result property="goods_id" column="goods_id"/> <result property="goods_name" column="goods_name"/> <result property="goodtype" column="goods_type"/> <result property="price" column="price"/> <result property="img_path" column="img_path"/> <association property="seckillgoods" javaType="com.hyc.seckillsystem.pojo.SeckillGoods"> <result property="seckill_price" column="seckill_price"/> <result property="stock_num" column="stock_num"/> </association> </resultMap> <select id="selectGoods" resultMap="goodsResult"> SELECT a.`goods_id`, a.`goods_name`, a.`goodtype`, a.`price`, a.`img_path`, b.`seckill_price`, b.`stock_num` FROM seckill.goods AS a LEFT JOIN seckill.seckillgoods AS b ON a.`goods_id` = b.`goods_id` </select> </mapper>
service 实现类
两种查找商品数据的方法
getlist:
逐个表 匹配查询selectGoods
链表查询
返回商品详情(包括秒杀价格等)VO getGoodsDetail(String goodId)
service 层的设计思路就是 调用DAO层接口 实现对数据库中取出数据的处理,并且提供给controller封装好的接口
@Service public class GoodsServiceImpl implements GoodsService { @Autowired GoodsMapper goodsMapper; @Autowired SeckillGoodsMapper seckillGoodsMapper; /** * @author 冷环渊 Doomwatcher * @context:逐个表 匹配查询 * @date: 2022/3/7 16:20 * @param * @return: java.util.List<com.hyc.seckillsystem.vo.GoodVo> */ @Override public List<GoodVo> getlist() { /* 两种方式 * 每个表单独查询 * 从第一个表查多个数据,再根据数据去第二个表中查询 * */ List<goods> goods = goodsMapper.getGoodList(); ArrayList<GoodVo> result = new ArrayList<>(); for (goods good : goods) { Map<String, Object> map = new HashMap(); map.put("goods_id", good.getGoods_id()); SeckillGoods seckillGoods = seckillGoodsMapper.getSeckillByGoodsId(good.getGoods_id()); GoodVo vo = new GoodVo(); vo.setGoodsId(good.getGoods_id()); vo.setGoodsName(good.getGoods_name()); vo.setGoodType(good.getGoodtype()); vo.setPrice(good.getPrice()); vo.setImgPath(good.getImg_path()); vo.setSeckillPrice(seckillGoods.getSeckill_price()); vo.setStockNum(seckillGoods.getStock_num()); result.add(vo); } return result; } @Override //返回秒杀商品详情页面 public GoodsDetailVo getGoodsDetail(String goodId) { SeckillGoods seckillGoods = seckillGoodsMapper.getSeckillByGoodsId(goodId); goods good = goodsMapper.getGoodsByGoodsId(goodId); //商品表信息1 GoodsDetailVo goodsDetailVo = new GoodsDetailVo(); goodsDetailVo.setGoodsId(good.getGoods_id()); goodsDetailVo.setGoodsName(good.getGoods_name()); goodsDetailVo.setGoodType(good.getGoodtype()); goodsDetailVo.setPrice(good.getPrice()); goodsDetailVo.setImgPath(good.getImg_path()); //秒杀表信息 goodsDetailVo.setSeckillPrice(seckillGoods.getSeckill_price()); goodsDetailVo.setStockNum(seckillGoods.getStock_num()); goodsDetailVo.setStartTime(seckillGoods.getStart_time()); goodsDetailVo.setEndTime(seckillGoods.getEnd_time()); return goodsDetailVo; } /** * @author 冷环渊 Doomwatcher * @context: 联表查询 * @date: 2022/3/7 16:20 * @param * @return: com.hyc.seckillsystem.vo.GoodVo */ @Override public List<GoodVo> selectGoods() { List<goods> goods = goodsMapper.selectGoods(); ArrayList<GoodVo> result = new ArrayList<>(); for (goods good : goods) { GoodVo vo = new GoodVo(); vo.setGoodsId(good.getGoods_id()); vo.setGoodsName(good.getGoods_name()); vo.setGoodType(good.getGoodtype()); vo.setPrice(good.getPrice()); vo.setImgPath(good.getImg_path()); vo.setSeckillPrice(good.getSeckillGoods().getSeckill_price()); vo.setStockNum(good.getSeckillGoods().getStock_num()); result.add(vo); } return result; } }
Controller
Controller层,负责控制接口数据和渲染界面的值传递 , 跳转 , 这里基本上不包含业务代码
尽可能的再service层 封装好方法 让Controller只负责调用
@Controller @Slf4j public class GoodsController { @Autowired GoodsService goodsService; @GetMapping(value = "/index") public String list(Model model) { // 逐层调用 List<GoodVo> getlist = goodsService.getlist(); model.addAttribute("goodslist", getlist); return "list"; } @GetMapping(value = "/goodDetail/{goodsId}") public String getGoodDetail(@PathVariable String goodsId, Model model) { GoodsDetailVo goodsDetail = goodsService.getGoodsDetail(goodsId); model.addAttribute("GoodsDetail", goodsDetail); //获取到时间 Date startTime = goodsDetail.getStartTime(); Date endTime = goodsDetail.getEndTime(); Date nowtime = new Date(); /*设置状态值 * 0 未开始 * 1 正在秒杀 * 2 秒杀结束 * */ int status; int remainSeconds = -1; if (nowtime.before(startTime)) { // 秒杀未开始 status = 0; remainSeconds = (int) ((startTime.getTime() - nowtime.getTime()) / 1000); } else if (nowtime.after(endTime)) { //秒杀已经结束 status = 2; } else { //正在秒杀中 status = 1; } model.addAttribute("status", status); model.addAttribute("remainSeconds", remainSeconds); return "Detail"; } }
秒杀商品
这里才是我们的重头戏这里我们主要讲解使用思路,不过多的去展示无用代码如实体类等,我们这里从最开始的
- 直接处理
- redis 事务处理
- 分布式锁 Lua处理
三种方式 由浅至深的来理解秒杀的思路和超卖问题的解决
直接处理
- 判断用户id 的有效性 我们没有用户
- 判断goodsid的有效性
- 判断当前是否处于可以秒杀的状态
- 判断是否有剩余库存
- 判断用户的秒杀权限(是否秒杀过)
- 减少库存
- 生成新的订单
public String handle(String userId, String goodsId) { String stockkey = goodsId + "_count"; String startTimekey = goodsId + "_startTime"; String userkey = goodsId + "_" + userId; String dateStr = (String) redisUtil.get(startTimekey); SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); Date startTime = null; try { startTime = format.parse(dateStr); } catch (ParseException e) { e.printStackTrace(); } if (startTime == null || new Date().before(startTime)) { return "秒杀还没开始"; } int stockNum = (int) redisUtil.get(stockkey); if (stockNum <= 0) { return "秒杀一空"; } if (redisUtil.get(userkey) != null) { return "用户秒杀成功过了"; } //减少库存 redisUtil.decr(stockkey); //限额 1 可以这么处理 //如果限额 多个 可以获取value 自增 redisUtil.set(goodsId + "_" + userId, 1); return userId + "用户秒杀成功"; }
测试
这个时候启动 jmeter 来测试一下接口的结果
- 库存 10
- 一百个线程 抢这 10个 手机
查看 redis 中 库存 key 的数量 为 -4
再次测试
通过测试和查看日志可以看到,我们直接处理,系统不行不能在第一时间反应过来是否超过了库存,导致特价商品被超额卖出,那这个问题怎么解决呢?
事务处理
优秀成熟的数据库 一定会有对事务的支持, redis 也不例外
Redis的事务在不出现异常的时候是原子操作,exec是触发事务执行的命令
相关命令:
- watch 设置一个key 表示开始对这个key的命令监听
- multi 将 后续的命令 加入到执行队列里
- exec 出发执行队列里的命令
- discard 停止提交,redis不能回滚
使用事务
public String handleByTrans(String userId, String goodsId) { String stockkey = goodsId + "_count"; String startTimekey = goodsId + "_startTime"; String userkey = goodsId + "_" + userId; String dateStr = (String) redisUtil.get(startTimekey); SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); Date startTime = null; try { startTime = format.parse(dateStr); } catch (ParseException e) { e.printStackTrace(); } if (startTime == null || new Date().before(startTime)) { return "秒杀还没开始"; } if (redisUtil.get(userkey) != null) { return "用户秒杀成功过了"; } SessionCallback sessionCallback = new SessionCallback() { @Override public Object execute(RedisOperations redisOperations) throws DataAccessException { // 编写事务逻辑 // 调用 watch 开始事务, mutil 开始将之后的命令加入队列 exec 执行队列里的命令 redisOperations.watch(stockkey); int stockNum = (int) redisUtil.get(stockkey); if (stockNum <= 0) { return "秒杀一空"; } redisOperations.multi(); redisUtil.decr(stockkey); redisUtil.set(userkey, 1); return redisOperations.exec(); } }; redisUtil.execute(sessionCallback); if (redisUtil.hasKey(userkey)) { return userId; } return userId + "秒杀失败"; }
测试
和之前一样的案例 100 抢 10
可以发现使用了 事务之后我们解决了超卖的问题
举一反三
那除了事务 还有什么方式可以解决超卖问题呢?
脚本语言 : lua
redis是支持执行脚本语言的且一段脚本执行的时候是不会被其他操作影响的,保证原子性
编写lua 脚本
--接受到参数1 local userId = KEYS[1]; --接收到参数2 local goodsId = KEYS[2]; --redis中的 key 存储为 局部变量 --秒杀商品数量 --.. 是lua 里面字符串的拼接 local cntkey = goodsId .. "_count"; --秒杀用户 id local orderkey = goodsId .. "_" .. userId; --判断是否秒杀过 local orderExists = redis.call("get", orderkey); if (orderExists and tonumber(orderExists) == 1) then return 2; end --判断库存是否为空 local num = redis.call("get", cntkey); if (num and tonumber(num) <= 0) then return 0; else -- 秒杀成功 redis.call("decr", cntkey); redis.call("set", orderkey, 1); end return 1
实例代码
我们可以使用 DefaultRedisScript 类 来执行我们写在 resouce 下的lua脚本
script.setScriptSource(new ResourceScriptSource( new ClassPathResource("lua/sekill.lua")));
设置脚本的位置,之后用之前工具类封装的redis方法将脚本和需要的参数list 传入执行即可
//使用lua 脚本 保证原子性 (分布式锁) @Override public String handleByLua(String userId, String goodsId) { String startTimekey = goodsId + "_startTime"; String dateStr = (String) redisUtil.get(startTimekey); SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); Date startTime = null; try { startTime = format.parse(dateStr); } catch (ParseException e) { e.printStackTrace(); } if (startTime == null || new Date().before(startTime)) { return "秒杀还没开始"; } DefaultRedisScript<Long> script = new DefaultRedisScript<>(); //设置返回脚本类型 script.setResultType(Long.class); script.setScriptSource(new ResourceScriptSource( new ClassPathResource("lua/sekill.lua"))); List<String> keylist = new ArrayList<>(); keylist.add(userId); keylist.add(goodsId); Object execute = redisUtil.execute(script, keylist); String resultStr = String.valueOf(execute); if ("0".equals(resultStr)) { return "秒杀库存已经空了"; } else if ("1".equals(resultStr)) { return userId; } else if ("2".equals(resultStr)) { return "该用户秒杀过了 =>" + userId; } else { return "秒杀和抢购可能出了点问题,正在紧急维护"; } }
测试
同样的 100 -》 10 ,从结果来看,也是保证了不超卖,不少卖的
解决了秒杀的问题,剩下就是将秒杀之后的成功用户信息和商品信息传给订单模块即可
Controller
@Controller @Slf4j public class redisController { @Autowired redisService redisService; @Autowired RedisUtil redisUtil; // 为了 方面测试 每次重启项目都会重置秒杀次数 @PostConstruct public void initdata() { redisService.initdata(); } @PostMapping(value = "/seckillAPI") public String seckill(String userId, String goodsId, Model model) { String key = goodsId + "_" + userId; if (userId == null || goodsId == null) { return "参数异常"; } String result = redisService.seckill(userId, goodsId); if (redisUtil.hasKey(key)) { log.info(result); model.addAttribute("userId", result); model.addAttribute("goodsId", goodsId); } return "order"; } }
生成订单
解决了秒杀的问题,生成订单其实就简单和常规的不少
就是查询 必要的信息生成一个订单信息
service 实现类
@Service public class orderServiceImpl implements orderService { @Autowired GoodsMapper goodsMapper; @Autowired SeckillGoodsMapper seckillGoodsMapper; @Autowired orderMapper ordermapper; @Override public orderVo createOrder(order order) { int insert = ordermapper.insert(order); if (insert != 0) { List<order> orders = ordermapper.selectList(null); for (order o : orders) { goods goods = goodsMapper.getGoodsByGoodsId(o.getGoods_id()); SeckillGoods seckillGoods = seckillGoodsMapper.getSeckillByGoodsId(o.getGoods_id()); orderVo orderVo = new orderVo(); orderVo.setOrderId(String.valueOf(o.getId())); orderVo.setUserId(o.getUser_id()); orderVo.setGoodsId(o.getGoods_id()); orderVo.setTelephone(o.getTelephone()); orderVo.setAddress(o.getAddress()); orderVo.setImgPath(goods.getImg_path()); orderVo.setSeckillPrice(seckillGoods.getSeckill_price()); return orderVo; } } return null; } }
Controller
等待用户 确认信息之后 就可以生成订单 同步到数据库了
@Controller public class orderController { @Autowired orderService orderService; @PostMapping(value = "/create") public String createOrder(order order, Model model) { orderVo order1 = orderService.createOrder(order); model.addAttribute("orderinfo", order1); return "success"; } }
流程展示
总结
设计一个秒杀项目 其实要考虑的东西十分的多,我们这次的系统也不是最终的版本,先做出来的核心的,
套用鱼皮的话 先有 再调优 追求更好
拓展
- 页面动静分离
- nginx ip 分流
- MQ 流量削峰,异步任务
- 前端验证码
- 数据库与缓存同步策略(MQ redis 都可以实现)
上述都是我学过的知识点暂时并且实战过,会在今后不断的将优化功能实现出来
这些都是我在考虑的范围内,今后会不断的学习 晚上其中的内容,