设计秒杀系统

介绍

纯粹为了熟悉系统设计而搭建的项目。

  • 场景没什么可说的就是减库存下单,流程很简单;
  • 秒杀会在短时间内产生大量流量,会有很多用户“疯狂”下单,但是又只有少部分用户能够真正秒杀成功;
  • 主要关注如何设计系统的高可用和高性能特性。

系统分析

按照场景、服务、存储、升级四个层次来分析秒杀系统的设计。

场景

  1. 瞬时大流量高并发
    从QPS来讲,每秒1000人访问,秒杀时每秒数十万人访问,QPS增加100倍以上。
  2. 有限库存,不能超卖
  3. 防止黄牛的恶意请求
    一般黄牛请求都是有规律的,能够制定一些规则来屏蔽黄牛的非法请求。

服务

秒杀系统架构

  • 秒杀服务
  • 商品信息和库存服务
  • 订单服务
  • 支付服务

存储

商品信息表

1
2
3
4
5
6
7
create table product (
id unsigned bigint(20) primary key,
name varchar(20) not null default '' commnet '商品名称',
desc varchar(20) not null default '' comment '商品描述',
price decimal(5, 2) not null default 0 comment '商品价格',

) default engine = InnoDB;

秒杀活动表

1
2
3
4
5
6
7
8
create table seckill (
id unsigned bigint(20) primary key,
name varchar(20) not null default '' commnet '活动名称',
product_id unsigned bigint(20) not null default 0 comment '商品id',
price decimal(5, 2) not null default 0 comment '秒杀价格',
number int not null default 0 comment '秒杀数量',

) default engine = InnoDB;

库存信息表

1
2
3
4
5
6
7
8
create table stock (
id unsigned bigint(20) primary key,
product_id unsigned bigint(20) not null default 0 comment '商品id',
seckill_id unsigned bigint(20) not null default 0 comment '秒杀活动id',
number int not null default 0 comment '库存数量',
lock int not null default 0 comment '锁定',

) default engine = InnoDB;

订单信息表

1
2
3
4
5
6
7
8
create table order (
id unsigned bigint(20) primary key,
product_id unsigned bigint(20) not null default 0 comment '商品id',
seckill_id unsigned bigint(20) not null default 0 comment '秒杀活动id',
user_id unsigned bigint(20) null default 0 comment '用户id',
paid int(1) not null default '' comment '是否已付款',

) default engine = InnoDB;

为了分析如何加索引,先来看下需要实现的有哪些功能。
商家侧

  • 商家查询商品信息
  • 商家创建秒杀活动
  • 商家创建库存

用户侧

  • 用户查询秒杀活动、商品、库存
  • 用户下单
  • 用户更新库存
    1
    2
    3
    4
    5
    6
    -- 查询库存余量
    select number from stock
    where product_id = 123 and seckill_id = 456;
    -- 扣减库存
    update stock set stock = stock - 1
    where product_id = 123 and seckill_id = 456;

库存扣减时机

  • 下单时立即扣减库存
    优点:用户体验最好,利用数据库锁机制一定能够精准扣减库存,用户一定能支付成功;
    缺点:可能被恶意下单,下单后不付款,别人也买不了了。
  • 先下单,不减库存,实际支付后扣减库存
    优点:可以避免恶意下单;
    缺点:对用户体验差,可能下单成功但是无法付款
  • 下单后锁定库存,支付成功后再扣减库存

限购

限制一个用户只能秒杀一次。
扣减库存后将用户ID存入Redis集合中,之后用户下单时需要校验用户ID是否存在于Redis集合中。
如果用户支付失败,需要再删除集合内的用户ID。

解决超卖问题

库存最终是通过数据库存储的。

  • 加行锁
    开启事务,并在查询库存时加行锁。
    缺点:加行锁性能慢
    1
    2
    3
    4
    5
    6
    7
    start transaction;
    -- 查询库存余量
    select number from stock
    where product_id = 123 and seckill_id = 456
    for update;
    -- 扣减库存
    ...没有变化
  • 使用update语句自带的行锁
    只有库存大于0的时候才会进行更新操作,因为update语句本身是带行锁的、当前读的。
    缺点:还是对MySQL依赖比较大,如果MySQL崩溃服务就不可用了。
    解决办法:在秒杀活动中,大部分请求都是失败的,可以利用Redis来过滤这些请求。
    1
    2
    3
    4
    5
    6
    -- 查询库存余量
    ...没有变化
    -- 扣减库存
    update stock set stock = stock - 1
    where product_id = 123 and seckill_id = 456
    and number > 0;

库存预热

初步设计中,使用数据库保存库存,面临的主要问题是MySQL无法应对秒杀的瞬时大量请求。
秒杀之前将商品信息缓存起来,减少数据库本身的流量,在下单前校验库存,只有检查成功的请求才能进入到下游。

数据库中的库存是通过行锁来保证并发更新安全的,但是数据库中的库存数据怎么和缓存保持一致呢?
MySQL扣减库存成功或失败都会影响Redis中前置库存(用于锁定)的值,所以在扣减操作后需要更新Redis中库存的值。

削峰限流

MQ堆积订单,下游Consumer根据自己的消费能力来处理下单请求。

其他优化

  1. 数据库层面
    分库分表
  2. 缓存层面
  3. 可拓展
    服务的可扩展,可以水平添加机器将用户请求分担到不同的机器上去。数据库可扩展,支持分库分表,对于用户的请求,映射到不同的数据库,减少单台数据库的压力。

扩展

如果Redis不足以应对流量

上面的设计中,库存锁定依赖Redis,虽然Redis性能高过MySQL,但是能力仍然有限,那么如果流量超过了Redis的承载能力怎么办?

  1. Redis库存扣减完毕后,后面请求可以直接拒绝
  2. 限流

秒杀服务器挂掉

尽量不要影响其他服务,加入熔断机制。

流量过滤

库存预热用Redis来过滤流量,大大减轻了MySQL压力(MySQL最多支持1KQPS,Redis可支持10WQPS),其他流量过滤措施如:

  1. 页面资源静态化,可使用CDN提速
  2. 禁止重复提交(前端限流):秒杀开始之后,可以对用户点击后响应前按钮置灰。

秒杀前防刷

防止商品详情页被刷爆:

  1. 未开始抢购时,禁用抢购按钮
    比如轮询服务器时间,获取距离活动开始时间的时间差,轮询主要是为了不断纠正客户端时间,和服务端的时间保持一致;

防刷(脚本、爬虫)

  1. 刷单问题
    有的用户会频繁调接口来达到刷单的目的,一方面刷接口对系统压力比较大,另一方面同一用户可能会占用大量的库存资源
    解决办法是限制一个用户调用的频率,比如一个用户一分钟只能调10次,超过这个时间就提示用户访问过于频繁、或弹出验证码让用户输入。
    防刷功能可以通过在Redis中计数来实现,记<userId, count>,使用lua脚本实现原子操作。
  2. 多账号刷单问题
    有的用户为了避免被上述规则拦住,就会注册多个账号来刷单。
    解决办法是拦IP,但是如果多个用户有相同的出口IP,就会导致误拦的情况,解决办法是加白名单,比如阿里云盾就是采用的这种方式:通过设置白名单解决因误判IP被拦截问题

以上的两种维度(用户userId、IP)都可以通过黑名单来实现。

秒杀系统的变种

抢票(火车票、机票)

和秒杀系统的区别是:根据区段锁定库存。

抢红包

参考

  1. 秒杀系统架构分析与实战