秒杀服务落地方案

一、秒杀的功能概述:

  • 营销后台设置秒杀活动并为活动添加秒杀商品
  • 开启活动、关闭活动
  • C端获取秒杀活动列表
  • C端查看秒杀商品详情
  • 提交秒杀订单
  • 秒杀订单支付

二、秒杀服务面临的技术难题

  1. 限流:
基于是大量用户抢少量商品的特点,必定绝大部分人都抢不到商品,所以需要限制住大部分流量,只允许少部分流量进入。

2.分布式缓存:

秒杀服务最大的瓶颈还是数据库的读写问题。数据库属于磁盘io,一般来说性能较低,如果能把业务数据放到缓存中,效率会大大提升。

3.超卖问题:

高并发下可能会出现多减了了库存的情况。

4.削峰:

高并发下瞬间的流量峰值通常需要走异步削峰,使得流量能够趋于平缓地处理,通常我们使用消息中间件进行异步处理。

三、解决方案

针对以上提出的问题我们给出如下解决方案
1.限流:

由于是大量用户抢少量商品,通常我们会采用预扣减库存来进行限制绝大部分流量进入。具体怎么做呢,我们可以将秒杀商品的库存在秒杀活动 
开启时存入redis中,当提交订单时,我们可以通过预减redis中的库存,扣减成功过,则可以创建订单,扣减失败,则直接返回库存不足来限制住 
绝大部分流量的进入。

2.分布式缓存

读:秒杀服务是属于典型读多写少的场景,所以在读取秒杀活动列表和秒杀商品的时候,为了应对这种高并发读的场景,我们需要将秒杀活动的信 
息和秒杀商品的信息全部写入缓存。
写:在限流的场景我们也提到,对于写的场景,我们需要将商品的秒杀库存写入redis,通过redis来预扣减库存

3.超卖问题

 高并发写商品的超卖问题,是最经常见到的问题。一个订单中可能有多个商品,而redis本身不支持多个批量原子性的操作,所以我们需要引入lua 
 脚本来解决。并且为了兜底,可能还需要一把分布式锁。

4.削峰

即使过滤了绝大部分的无效流量,真正可以下单的流量也是相当可观的,所以我们通常还是需要对这部分流量进行削峰,让其平缓的进入数据层 
面。我们可以使用消息中间件,在redis库存预扣减成功之后,发送异步消息,去扣减数据库的秒杀库存。

四、公司已有秒杀服务设计图

image.png

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='秒杀活动用户已购商品记录'

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,544评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,430评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,764评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,193评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,216评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,182评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,063评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,917评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,329评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,543评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,722评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,425评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,019评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,671评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,825评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,729评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,614评论 2 353

推荐阅读更多精彩内容