一、秒杀的功能概述:
- 营销后台设置秒杀活动并为活动添加秒杀商品
- 开启活动、关闭活动
- C端获取秒杀活动列表
- C端查看秒杀商品详情
- 提交秒杀订单
- 秒杀订单支付
二、秒杀服务面临的技术难题
- 限流:
基于是大量用户抢少量商品的特点,必定绝大部分人都抢不到商品,所以需要限制住大部分流量,只允许少部分流量进入。
2.分布式缓存:
秒杀服务最大的瓶颈还是数据库的读写问题。数据库属于磁盘io,一般来说性能较低,如果能把业务数据放到缓存中,效率会大大提升。
3.超卖问题:
高并发下可能会出现多减了了库存的情况。
4.削峰:
高并发下瞬间的流量峰值通常需要走异步削峰,使得流量能够趋于平缓地处理,通常我们使用消息中间件进行异步处理。
三、解决方案
针对以上提出的问题我们给出如下解决方案
1.限流:
由于是大量用户抢少量商品,通常我们会采用预扣减库存来进行限制绝大部分流量进入。具体怎么做呢,我们可以将秒杀商品的库存在秒杀活动
开启时存入redis中,当提交订单时,我们可以通过预减redis中的库存,扣减成功过,则可以创建订单,扣减失败,则直接返回库存不足来限制住
绝大部分流量的进入。
2.分布式缓存
读:秒杀服务是属于典型读多写少的场景,所以在读取秒杀活动列表和秒杀商品的时候,为了应对这种高并发读的场景,我们需要将秒杀活动的信
息和秒杀商品的信息全部写入缓存。
写:在限流的场景我们也提到,对于写的场景,我们需要将商品的秒杀库存写入redis,通过redis来预扣减库存
3.超卖问题
高并发写商品的超卖问题,是最经常见到的问题。一个订单中可能有多个商品,而redis本身不支持多个批量原子性的操作,所以我们需要引入lua
脚本来解决。并且为了兜底,可能还需要一把分布式锁。
4.削峰
即使过滤了绝大部分的无效流量,真正可以下单的流量也是相当可观的,所以我们通常还是需要对这部分流量进行削峰,让其平缓的进入数据层
面。我们可以使用消息中间件,在redis库存预扣减成功之后,发送异步消息,去扣减数据库的秒杀库存。
四、公司已有秒杀服务设计图
1. 营销后台设置秒杀活动并为活动添加秒杀商品。
1)营销后台新增秒杀活动,秒杀活动可以按照时间段,比如设置10-1 号到 10-7 号是秒杀的日期,每天9:00 - 10:00 和 18:00 -19:00 是秒杀的时间段(时间段的设置对应 t_seckill_sale_time 表)。
2)秒杀活动中添加商品,设置商品的限购数量(秒杀库存),单个用户的限购数量,至于这个秒杀的库存需不需要受到商品的实际库存控制,用户可以单独控制。
2. 开启活动
开启活动时,会向redis中设置两个参数
1)设置活动详情
type: hash 类型
key:DETAIL:shopAdminId-ruleId
filed_1: DETAILHEADER value: 活动详情
filed_2:DETAILGOODS-时段id value:商品详情列表
expire: 活动截止时间-当前时间
2)缓存门店的秒杀活动(因为正常都是设置活动的适用门店,但是这里要缓存每个适用门店所对应的活动id)
type: set
key: MARKETING:SECKILL:INDEX:商户id-门店id
value: ruleId
expire: 活动截止时间-当前时间
3. 商城获取秒杀活动列表
1)根据门店id从缓存中获取秒杀活动
2)根据活动id从缓存中获取活动详情和商品详情。
3)校验活动的时间过滤出符合的活动、设置商品
4.商城查看秒杀商品详情
1)调用营销接口查询秒杀商品(回包括用户可购数量)
2)查询商品信息(包括商品的库存)
3)最后商品详情中的库存 应该是 用户可购数量 和 商品的库存 做比较,return 用户可购数量 > 商品的库存 ? 商品的库存 : 用户可购数量
5. 提交订单扣减库存
以下逻辑加了分布式锁
1)前置校验活动信息
2)判断当前订单中的商品数量是否已经超过门店可售数量、当前用户购买的商品数量是否已经超过单用户可购买数量(缓存中如果没有,需要初
始化这两个库存-db和缓存),如果超过,直接返回。如果校验通过,则需要向redis中更新以下两个缓存,缓存值分别加上当前订单中商品的数量。
1、用户已购数量缓存
type: hash
key: MARKETING:SECKILL:USERBUY:shopAdminId-ruleId-mod(userId)
filed: MARKETING:SECKILL:USERBUYDETAIL:userId-时段id-门店id-商品id
value: 用户已购数量
expire: 如果是时段,则过期时间是当前时间到第二天凌晨过期,因为每一天的时段都是新场次;如果是跨天,则是当前时间到,活动结束时间说明: 记录用户在门店购买商品的数量,如果活动是跨天的,则时段id为0,
因为每人限购是针对所有门店的,所以这里的门店id默认为0
2、门店商品已售数量缓存
type: hash
key: MARKETING:SECKILL:STOCK:shopAdminId-ruleId-mod(商品id)
filed: MARKETING:SECKILL:STOCKDETAIL:门店id-商品id-时段id
value: 门店已售数量
expire: 如果是时段,则过期时间是当前时间到第二天凌晨过期,因为每一天的时段都是新场次;如果是跨天,则是当前时间到,活动结束时间说明: 记录用户在门店购买商品的数量,如果活动是跨天的,则时段id为0,
因为每人限购是针对所有门店的,所以这里的门店id默认为0
注意:秒杀商品的门店可售库存 需不需要受 商品的实际库存 控制 是一个配置开关,如果配置了联动,那么 门店可售库存 = (限购数 - 已售数量)> 商品实际库存 ?商品实际库存 : (限购数 - 已售数量)
如果订单中的商品数量 > 门店可售库存,则下单失败。(商品实际库存时调用商品接口查询,有一分钟的缓存,因为订单下单时会调用商品接口占用库存,有占用库存的逻辑兜底,即使失败,会重新恢复秒杀商品商品库存,
所以这里一分钟的缓存也没有大问题)
6.订单取消
回退秒杀商品库存
总结:结合我们在文章中第三部分解决方案中提到的,和我们公司现有的设计方案做以下比较。
- 在限流方面采用是一致的方案,都是通过redis来预计秒杀库存来限制住了绝大部分流量的进入。
- 其次在缓存方面也是保持一致,缓存了活动的信息和秒杀的商品信息。
在第三部分超卖会稍有不同,之前我们说redis不支持批量原子性的操作,所以我们最初的解决方案是想通过lua脚本来实现这一操作。但是在公司的落地实现方案上,并不是这么做的。它首先是加了一把分布式锁,来保证库存扣减的并发安全性,其次,对于一个订单中的多个商品,他是循环去调用了 increment 命令,这样做会损失一定的性能损耗,但是同样也是有好处的。因为lua脚本并不保证事务性,对于lua脚本中的多个商品减库存,如果一个减失败了,其他商品的库存操作也会继续执行下去,这样就会导致一定程度上redis和数据库的库存一致性。相反如果是通过循环调用 redis的increment 命令,如果一个命令失败了,那么我们可以手动把其他商品的活动库存再给加回去。这样通过损失一定的性能,却在redis和db数据一致性上带来了保证。
- 最后在削峰方面也保持一致,都是通过mq来进行异步去扣减数据库的库存。
问题一:我们一直说,能不能创建订单是通过预扣减redis的活动库存,其实通过上面我们公司的实现方案上来看,也是略有不同的,我们没有在redis直接存商品的秒杀库存,而是缓存了商品的 门店已售数量 和 用户秒杀已购数量,每一个商品能否秒杀成功,我们是通过比较商品的可售数量(用户的可购买数量) 与 订单中商品数量 的大小。之所以这么做的原因有两个,一是我们除了要判断秒杀的商品库存是否足够,还需要判断每个用户是否已经达到购买的上限数,那这样不如保持一致,都直接缓存商品的已售数量就好。二是秒杀商品的库存是不确定的,这个不确定是说它可以通过开关来配置是否需要受到商品实际库存的控制。一旦用户改变开关,商品的实际可售的秒杀库存就会发生变化,那这样,我们缓存已售数量就是更合理的
。
问题二、秒杀商品的库存和商品实际可售库存的关系 :秒杀商品的门店可售库存 需不需要受 商品的实际库存 控制 是一个配置开关,如果配置了联动,那么 门店可售库存 = 限购数 - 已售数量 > 商品实际库存 ?商品实际库存 : 限购数 - 已售数量 。如果订单中的商品数量 > 门店可售库存,则下单失败。(商品实际库存是调用商品接口查询,有一分钟的缓存,因为订单下单时会调用商品接口占用库存,有占用库存的逻辑兜底,即使失败,会重新恢复秒杀商品商品库存,所以这里一分钟的缓存也没有大问题)
问题三:如何保证redis中 不会超卖也就是库存数不会小于 0 呢?其实也就是如果保证 门店已售数量 + 购买数 < 门店商品限购数 这个逻辑要想保证原子性,需要使用lua脚本来实现。脚本代码如下:
local cc=tonumber(购买数)
if tonumber(商品限购数)>=redis.call('HINCRBY',商品key,门店商品已售数量],0)+cc
then return redis.call('HINCRBY',key, filed , cc)
end return 0`
(其中tonumber是redis内置函数,用来将目标数转换成数值)
五、秒杀服务数据库设计
CREATE TABLE t_shop_seckill_rule
(
id
int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键id',
shop_admin_id
bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '商户管理员ID',
start_time
timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '有效期-开始时间',
end_time
timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '有效期-结束时间',
name
varchar(50) NOT NULL DEFAULT '' COMMENT '活动名称',
active_status
tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '状态:开启(1),停止(0)',
status
tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:正常(1),删除(-1)',
is_pre_sale
tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否预售1.是 0.否 默认0',
pre_days
int(10) NOT NULL DEFAULT '0' COMMENT '预售天数',
pre_hours
varchar(255) NOT NULL DEFAULT '' COMMENT '预售小时,与pre_days联合使用',
creator_id
bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '创建人',
create_time
timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_id
bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '修改人',
update_time
timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
time_mode
int(10) NOT NULL DEFAULT '0' COMMENT '秒杀模式 0:时段秒杀;1:跨天秒杀 默认0',
ext
varchar(255) NOT NULL DEFAULT '' COMMENT '扩展字段 json',
active_code
varchar(32) NOT NULL DEFAULT '' COMMENT '活动编码',
shop_id
bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '品牌id',
PRIMARY KEY (id
) USING BTREE,
KEY idx_ac_st_ad
(shop_admin_id
,active_status
,status
),
KEY idx_sid_st_et
(shop_admin_id
,start_time
,end_time
)
) ENGINE=InnoDB AUTO_INCREMENT=511 DEFAULT CHARSET=utf8 COMMENT='秒杀活动信息表'
CREATE TABLE t_seckill_sale_time
(
id
int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键id',
seckill_id
int(10) unsigned NOT NULL DEFAULT '0' COMMENT '秒杀规则ID',
start_time
char(8) NOT NULL DEFAULT '' COMMENT '开始时间',
end_time
char(8) NOT NULL DEFAULT '' COMMENT '结束时间',
status
tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:开启(1),删除(-1),停用(0)',
orders
tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '排列顺序',
create_time
timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time
timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id
),
KEY idx_skId
(seckill_id
,start_time
,end_time
)
) ENGINE=InnoDB AUTO_INCREMENT=1081 DEFAULT CHARSET=utf8 COMMENT='秒杀时间区间表'
CREATE TABLE t_shop_seckill_goods_x
(
id
int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键id',
shop_admin_id
bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '商户管理员ID',
seckill_id
int(10) unsigned NOT NULL DEFAULT '0' COMMENT '秒杀活动id',
sale_time_id
int(10) unsigned NOT NULL DEFAULT '0' COMMENT '销售时间区间id',
goods_id
bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '商品id',
product_image_url
varchar(255) NOT NULL DEFAULT '' COMMENT '商品图片',
goods_image_url
varchar(255) DEFAULT '' COMMENT '秒杀活动商品图片',
product_code
varchar(255) NOT NULL DEFAULT '' COMMENT '商品code',
product_name
varchar(255) NOT NULL DEFAULT '' COMMENT '商品名称',
product_desc
varchar(255) NOT NULL COMMENT '商品描述',
product_upc
varchar(255) NOT NULL DEFAULT '' COMMENT '商品upc',
category_name
varchar(50) DEFAULT '' COMMENT '商品分类',
sku_upc
varchar(50) NOT NULL DEFAULT '' COMMENT '规格upc',
sku_price
bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '规格销售价',
sku_id
int(10) unsigned NOT NULL DEFAULT '0' COMMENT '规格ID',
sku_name
varchar(255) NOT NULL DEFAULT '' COMMENT '规格名称',
active_status
tinyint(4) NOT NULL DEFAULT '1' COMMENT '活动状态:开启上架,关闭[下架](0)',
status
tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:开启(1),删除(-1),停用(0)',
skill_price
bigint(20) unsigned NOT NULL DEFAULT '1' COMMENT '秒杀价',
goods_number
int(10) NOT NULL DEFAULT '-1' COMMENT '秒杀限购数量,-1为不限购',
less_number
int(10) NOT NULL DEFAULT '-1' COMMENT '每人限购数量,-1为不限购',
creator_id
bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '创建人',
create_time
timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_id
bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '修改人',
update_time
timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '修改时间',
shop_id
bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '品牌id',
serial_number
int(10) NOT NULL DEFAULT '0' COMMENT '排序序号',
virtual_sale_number
int(10) unsigned NOT NULL DEFAULT '0' COMMENT '虚拟销量',
PRIMARY KEY (id
) USING BTREE,
KEY idx_sti_upc_as_st
(sale_time_id
,sku_upc
,active_status
,status
),
KEY idx_skId_gId
(seckill_id
,goods_id
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='秒杀商品信息表'
CREATE TABLE t_seckill_store_stock_x
(
id
int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键id',
bus_id
int(10) unsigned NOT NULL DEFAULT '0' COMMENT '秒杀时段id',
bus_type
tinyint(4) unsigned NOT NULL COMMENT '类型:秒杀(13)',
store_id
bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '门店ID',
sku_upc
varchar(50) NOT NULL DEFAULT '' COMMENT '规格UPC',
real_number
int(10) NOT NULL DEFAULT '0' COMMENT '剩余库存,-1为不限购',
status
tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:开启(1),删除(-1),停用(0),此字段作废',
creator_id
bigint(20) unsigned DEFAULT '0' COMMENT '创建人',
update_id
bigint(20) unsigned DEFAULT '0' COMMENT '更新',
create_time
timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time
timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
bought_stock
int(10) NOT NULL DEFAULT '0' COMMENT '已售数量',
shop_admin_id
bigint(20) NOT NULL COMMENT '商户id',
PRIMARY KEY (id
),
KEY idx_SBS
(store_id
,bus_id
,sku_upc
),
KEY idx_bId_sku
(bus_id
,sku_upc
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='门店秒杀商品库存表'
CREATE TABLE t_seckill_user_quantity_x
(
id
bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
shop_admin_id
bigint(20) NOT NULL COMMENT '商户id',
sec_kill_id
bigint(20) NOT NULL COMMENT '秒杀活动id',
sale_time_id
int(10) unsigned NOT NULL DEFAULT '0' COMMENT '销售时间区间id',
user_id
bigint(20) NOT NULL COMMENT '用户id',
store_id
bigint(20) NOT NULL COMMENT '门店id',
sku_upc
varchar(32) NOT NULL COMMENT '规格UPC',
quantity
int(11) NOT NULL DEFAULT '0' COMMENT '购买数量',
create_time
timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time
timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id
),
KEY idx_USSS
(sec_kill_id
,sale_time_id
,store_id
,user_id
,sku_upc
)
) ENGINE=InnoDB AUTO_INCREMENT=51 DEFAULT CHARSET=utf8 COMMENT='秒杀活动用户已购商品记录'