「这是我参与11月更文挑战的第8天,活动详情查看:2021最后一次更文挑战」
概述
随着互联网电商的兴起,各种活动层出不穷。秒杀活动作为一种经典活动具有瞬时并发大的特点,同时秒杀设计也是面试常考题之一,本文以单机为示例设计开发秒杀系统。
源码地址: gitee.com/tech-famer/…
效果展示
系统分析
- 秒杀页面静态化
- 倒计时时间服务器中获取
- 秒杀活动开始前隐藏秒杀链接
- 秒杀限流
- 秒杀商品redis缓存
- 防止超卖
系统设计
表结构设计
create datebase seckill;
use seckill;
CREATE TABLE seckill.`goods` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`goods_name` varchar(32) NOT NULL COMMENT '商品名称',
`goods_price` decimal(10,2) NOT NULL COMMENT '商品价格',
`goods_count` int(11) NOT NULL COMMENT '剩余数量',
`total_count` int(11) NOT NULL COMMENT '总数量',
`start_time` datetime NOT NULL COMMENT '开始时间',
`end_time` datetime NOT NULL COMMENT '结束时间',
`create_user` varchar(32) NOT NULL COMMENT '创建用户',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_user` varchar(32) NOT NULL COMMENT '更新用户',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='秒杀商品表';
CREATE TABLE eckill.`secorder` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`order_no` varchar(32) NOT NULL COMMENT '订单号',
`goods_id` bigint(20) NOT NULL COMMENT '商品ID',
`goods_name` varchar(32) NOT NULL COMMENT '商品名称',
`goods_num` int(11) NOT NULL COMMENT '商品数量',
`amount` decimal(10,2) NOT NULL COMMENT '订单总价',
`pay_seq` varchar(32) NOT NULL COMMENT '支付流水号',
`order_status` varchar(2) NOT NULL COMMENT '订单状态',
`goods_snapshots` text NOT NULL COMMENT '商品快照',
`user_id` varchar(32) NOT NULL COMMENT '购买用户',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_user` varchar(32) NOT NULL COMMENT '更新用户',
`update_time` datetime NOT NULL COMMENT '更新时间',
`pay_time` datetime DEFAULT NULL COMMENT '支付时间',
`expire_time` datetime NOT NULL COMMENT '过期时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='秒杀订单表'
复制代码
页面静态化
大型电商平台的页面一般在做活动的时候会租用CDN服务,将静态资源放到CDN服务来避免每次用户刷新页面请求服务器带来压力。静态页面会利用浏览器缓存静态资源,在用户持续刷新页面时,浏览器直接读取缓存而非直接请求链接获取。
单机具体做法如下:
1. 项目配置文件中添加静态资源相关配置
spring:
resources:
static-locations: classpath:/static/
mvc:
static-path-pattern: /static/**
复制代码
2. 将静态资源放入静态资源目录下
倒计时设计
活动页面倒计时一般是活动开始时间-当前时间计算得出,前端页面通过setTimeout函数动态定时更新倒计时。此方法中当前时间取得是客户端时间,聪明的用户通过修改系统时间绕过倒计时直接参加活动,为避免发生这种情况当前时间也从服务器获取即可。具体做法如下: 在服务端直接计算好活动开始时间-当前时间值,前端只需要将服务器计算好的差值通过setTimeout函数倒数至0即可开始活动。
@PostMapping("/goodsDetail")
public CommonJsonResponse<Object> goodsDetail(@Validated @RequestBody CommonJsonRequest<ReqGoodsDetailVO> request){
final String redisKey = TotalConstants.SECKILL_GOODS_DETAIL_PREFIX + request.getData().getId();
if(!redisTemplate.hasKey(redisKey)){
return new CommonJsonResponse("9999","秒杀活动已经结束");
}
final Map<Object, Object> map = redisTemplate.opsForHash().entries(redisKey);
final Long startTime = (Long) map.get("startTime");
map.put("startTimeLong",startTime - System.currentTimeMillis());
return CommonJsonResponse.ok(map);
}
复制代码
活动开始前隐藏秒杀链接
为避免黄牛党通过机器刷秒杀接口,在活动开始前不暴露秒杀接口,所有参与秒杀的用户只能在活动开始后通过页面秒杀按钮抢购。为此做两点设计。
1. 增加获取秒杀链接的接口
所有用户只能通过此接口获取秒杀链接,此接口只在活动开始才返回秒杀链接,秒杀链接包含加密信息
@PostMapping("/goodsOrderUrl")
public CommonJsonResponse goodsOrderUrl(@Validated @RequestBody CommonJsonRequest<ReqGoodsDetailVO> request){
final Long id = request.getData().getId();
final String redisKey = TotalConstants.SECKILL_GOODS_DETAIL_PREFIX + id;
if(!redisTemplate.hasKey(redisKey)){
return new CommonJsonResponse("9999","秒杀活动已经结束");
}
final Long startTime = (Long) redisTemplate.opsForHash().get(redisKey, "startTime");
if(startTime > System.currentTimeMillis()){
return new CommonJsonResponse("9999","秒杀活动未开始");
}
final String md5 = DigestUtils.md5DigestAsHex((id + TotalConstants.SECKILL_MD5_SALT).getBytes());
return CommonJsonResponse.ok("/seckill/" + md5 + "/order");
}
复制代码
2. 对前端js进行加密混淆
秒杀限流设计
1. 前端秒杀按钮在秒杀开始前置灰,且不可连续点击
其中变量canBuy
用来标识按钮是否可点击,默认为false,活动倒计时结束置为true,用户点击后置为false
//用户点击秒杀按钮,获取秒杀链接
var canBuy = false;
function doBuy(){
if(!canBuy){
return
}
canBuy = false;
$('button').css({background:'grey'})
$.ajax({
url:'/seckill/goodsOrderUrl',
data:JSON.stringify({reqTime:111,sign:'111',data:{id:queryParam.id}}),
type:'post',
contentType: 'application/json',
dataType:'json',
}).success(function(data){
if(data.respCode === "0000"){
var url = data.data
doOrder(queryParam.id,url)
}else{
showMsg(data.respMsg)
}
}).error(function(){
});
}
//倒计时函数
var addTime = 0;
function updateTime(time){
setTimeout(function(){
addTime += 1000;
if(time - addTime <= 0){
$('button').css({background:'red'})
$('span').remove()
canBuy = true
}else{
$('#time').text(calcuTimeStr(time - addTime))
updateTime(time)
}
},1000)
}
复制代码
2. 服务器端限流
通常服务器端的限流月前置越好,若在系统层限流测服务器系统可用连接在可承受范围就限流了;若限流在应用层,只会对应用服务高并发坐限流,系统还是会有大量的连接,当大量连接占满系统后,则系统不可用,此时服务也不可用。所以在nginx层等更高层都要做好限流配置。
本机演示只在应用服务成做限流,基于guava的RateLimiter利用spring的aop原理做封装。
1. 定义自定义注解 @FarmerLimiter
其中value默认值20,表示并限流数。
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(value= METHOD)
public @interface FarmerLimiter {
int value() default 20;
}
复制代码
2. 编写aop处理逻辑
达到限流提示“活动太火爆,稍后再试”
@Component
@Aspect
public class LimiterAop {
private static final ConcurrentHashMap<String, RateLimiter> map = new ConcurrentHashMap<>();
@Pointcut("@annotation(FarmerLimiter)")
public void cut(){}
@Around("cut()")
public Object deal(ProceedingJoinPoint point) throws Throwable {
final MethodSignature signature = (MethodSignature) point.getSignature();
final FarmerLimiter annotation = signature.getMethod().getAnnotation(FarmerLimiter.class);
final int value = annotation.value();
final String name = signature.getMethod().toString();
RateLimiter rateLimiter;
if(map.containsKey(name)){
rateLimiter = map.get(name);
}else{
rateLimiter = RateLimiter.create(value);
map.put(name, rateLimiter);
}
if(rateLimiter.tryAcquire()){
return point.proceed();
}else{
return new CommonJsonResponse("0000","活动太火爆,稍后再试");
}
}
}
复制代码
3. 秒杀接口添加限流功能
@PostMapping("/{md5}/order")
@FarmerLimiter(50)
public CommonJsonResponse getBuyUrl(@PathVariable("md5") String md5,@Validated @RequestBody CommonJsonRequest<ReqGoodsDetailVO> request, HttpSession session){
final Long id = request.getData().getId();
final String newMd5 = DigestUtils.md5DigestAsHex((id + TotalConstants.SECKILL_MD5_SALT).getBytes());
if (!newMd5.equals(md5)) {
return new CommonJsonResponse("9999","请求不合法");
}
final String redisKey = TotalConstants.SECKILL_GOODS_DETAIL_PREFIX + id;
final Long endTime = (Long) redisTemplate.opsForHash().get(redisKey, "endTime");
if(!redisTemplate.hasKey(redisKey) || endTime < System.currentTimeMillis()){
return new CommonJsonResponse("9999","秒杀活动已经结束");
}
final String limitKey = TotalConstants.SECKILL_GOODS_LIMIT_PREFIX + id + ":" + session.getId();
if(redisTemplate.hasKey(limitKey)){
return new CommonJsonResponse("9999","您已经达到最大购买次数");
}
if(redisTemplate.opsForHash().increment(redisKey, "goodsCount", -1L) < 0){
return new CommonJsonResponse("9999","秒杀商品已经抢完");
}
final SecOrder order = orderService.generateSecOrder(id, session);
redisTemplate.opsForValue().set(limitKey,1);
redisTemplate.expire(redisKey,(endTime - System.currentTimeMillis())/1000L + (long) new Random().nextInt(3600), TimeUnit.SECONDS);
return CommonJsonResponse.ok(order);
}
复制代码
秒杀商品redis缓存
为秒杀系统设计三种缓存:
1. 秒杀活动列表缓存,使用redis的list数据结构存储
通过定时任务,定时将有效的活动添加到redis缓存中,失效的删除。
@Scheduled(cron = "0/5 * * * * *")
public void loadIntoRedis(){
//添加未过期的商品
final List<Goods> goodsList = goodsService.listSeckillGoods();
if(!goodsList.isEmpty()){
StringBuilder sb = new StringBuilder("redis.call('lpush',KEYS[1]");
Object[] argv = new Object[goodsList.size() + 1];
argv[0] = goodsList.size() - 1;
for (int i = 0; i < goodsList.size(); i++) {
RedisGoods redisGoods = new RedisGoods();
BeanUtils.copyProperties(goodsList.get(i), redisGoods);
argv[i + 1] = redisGoods;
sb.append(",").append("ARGV[").append(i + 2).append("]");
}
sb.append(")\r\n");
sb.append("if redis.call('ltrim',KEYS[1],0,ARGV[1]) == 'OK' then return 1 else return 0 end");
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(sb.toString(), Long.class);
redisTemplate.execute(redisScript, Arrays.asList(TotalConstants.SECKILL_GOODS_LIST_PREFIX), argv);
}
}
复制代码
2. 秒杀活动详情缓存,使用redis的hash数据结构存储
在后台添加秒杀活动时,将秒杀商品信息添加redis缓存,其中hash key为字段名称,方便查询秒杀商品详情及对商品库存hincrby
原子减扣
@PostMapping("add")
@ResponseBody
public CommonJsonResponse add(Goods goods, HttpSession session){
if(goods.getGoodsName() == null){
return new CommonJsonResponse("9999","商品名称不能为空");
}
if(goods.getGoodsPrice() == null || goods.getGoodsPrice().compareTo(BigDecimal.ZERO) <= 0 ){
return new CommonJsonResponse("9999","商品价格不合法");
}
if(goods.getGoodsCount() == null || goods.getGoodsCount() <= 0){
return new CommonJsonResponse("9999","商品数量不合法");
}
if(goods.getStartTime() == null || goods.getStartTime().before(new Date())){
return new CommonJsonResponse("9999","开始时间不合法");
}
if(goods.getEndTime() == null || goods.getEndTime().before(new Date())){
return new CommonJsonResponse("9999","结束时间不合法");
}
if(goods.getStartTime().after(goods.getEndTime())){
return new CommonJsonResponse("9999","开始时时间不能再结束时间之后");
}
final Integer i = goodsService.addGoods(goods, session);
if(i > 0){
JSONObject goodsJson = (JSONObject)JSONObject.toJSON(goods);
redisTemplate.opsForHash().putAll(TotalConstants.SECKILL_GOODS_DETAIL_PREFIX + goods.getId(), goodsJson.getInnerMap());
return CommonJsonResponse.ok();
}
return new CommonJsonResponse("9999","添加商品失败");
}
复制代码
3. 秒杀活动用户已购商品数量缓存,使用redis的string数据结构
设置过期时间到活动结束+一个小时以内随机,避免大量redis的key过期引起redis卡顿。
redisTemplate.opsForValue().set(limitKey,1);
redisTemplate.expire(redisKey,(endTime - System.currentTimeMillis())/1000L + (long) new Random().nextInt(3600), TimeUnit.SECONDS);
复制代码
防止超卖
利用mysql数据库事务隔离性,及乐观锁减扣库存来防治超卖。
<update id="decrement">
update goods set update_time = now(),goods_count = goods_count - 1
where id = #{id,jdbcType = BIGINT}
and goods_count >= 1
</update>
复制代码