秒杀系统设计

后端业务学习

Posted by John Mactavish on July 15, 2021

这是我进行的第一个与互联网实际业务相关的系统设计。

所谓“秒杀”,就是网络卖家以促销等目的,发布少量超低价格的商品,让所有买家在同一时间网上抢购的一种销售方式。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟。

秒杀业务区别于一般的后端业务,其在短时间内产生巨量的请求, 普通的 Web 服务器和数据库很可能撑不住这样的压力。 如果我们尝试使用集群来分摊流量,还需要解决一致性问题, 比如避免“超卖”(实际卖出商品数量大于预设的数量)等问题。

用户设计

但是首先,我们需要设计好“用户”,他是参与秒杀的主体。

我们先为用户创建这样一张表,里面都是些基本信息。

CREATE TABLE `seckill_user` (
  `id` bigint(20) NOT NULL COMMENT '用户ID',
  `nickname` varchar(255) NOT NULL,
  `password` varchar(32) DEFAULT NULL COMMENT 'MD5(pass明文+salt)',
  `salt` varchar(10) DEFAULT NULL,
  `avatar` varchar(128) DEFAULT NULL COMMENT '头像',
  `register_date` datetime DEFAULT NULL COMMENT '注册时间',
  `last_login_date` datetime DEFAULT NULL COMMENT '上次登录时间',
  `login_count` int(11) DEFAULT '0' COMMENT '登录次数',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

为了防止数据库被黑客攻破后泄露了用户的密码 (很多用户喜欢在不同网站用同一个密码),我们的数据库不能存放原密码。 通过哈希函数(如 MD5),可以把任意密码变成另一串字符串。 因为哈希函数是不可逆的,黑客便无法反推原密码。 但是,黑客还可尝试跑彩虹表(即暴力枚举常见密码的 MD5 结果)来找到原密码。因此,我们改进了算法, 在计算 MD5 前为原密码拼接上一段随机字符串(即 salt)。 而且不同用户的 salt 还不同,这便可以大幅增加黑客的破解开销。

然后便要考虑登录系统的设计。用户登录时我们从数据库中取得该用户的信息,然后比对密码即可。

我们自然不希望用户每次访问页面时都要重新登录,因此需要采取措施记住当前用户。 常用的措施便是 Session(会话)。Session 是一种记录客户状态的机制,保存在服务器上, 通常采用键值对形式实现。Session 的键称为 Session Id,值可以是用户账号信息、购物车列表等状态。

Session 工作流程一般是这样的:

  • 客户端发送请求
  • 服务端接收请求,分配一个 Session Id 并创建对应的状态信息
  • 客户端再次访问时在请求中带上 Session Id
  • 服务端解析 Session Id,找到对应的 Session 信息

这里有几点需要注意。

首先是 Session Id 的生成。Session Id 需要满足唯一性(防重复)与随机性(防止被人发现规律, 进行伪造)要求。所以最好不要用 UUID 之类的随机性不强的方法, 而是使用专门工具类的 Session Id 生成器, 比如 Tomcat 的基于 java.security.SecureRandomSessionIdGenerator

参考Best practices for SessionId/Authentication Token generation

Session 可以简单存在 HashMap 中,也可用内存数据库存储。对于我们的秒杀系统, 并发量较大,单个 Tomcat 服务器可能撑不住,因此用 nginX 作反向代理、负载均衡。 通常负载均衡算法有 Round Robin、随机分配、加权分配和源 IP 地址哈希法等。 前三种方法无法保证同一用户的多次 TCP 连接到达同一个 Tomcat 服务器, 因为 Session 存储在本地,其他服务器便无法提供 Session 服务。 按源 IP 地址哈希分配服务器可以保证,只要用户的 IP 不变(一般不会变), 他的 TCP 连接总会负载均衡到同一个 Tomcat 服务器。即使不用此法,也有办法达到目的, 那就是把 Session 单独放到一台 Redis 服务器上去。 所有的 Tomcat 共享一个 Session 存储空间————Redis 服务器,通过内网网络请求它。 如此一来,即使同一用户的多次 TCP 连接到达不同的 Tomcat 服务器,它们也能一致地维护用户状态。

session

另外,客户端处理 Session Id 的方法也有多种。常用方法是利用 Cookie, 它是浏览器本地存储的 KV 值;传输时,服务器在响应报文中设置首部字段 Set-Cookie 的值, 带上 Session Id, 浏览器以后便会在请求报文的首部字段 Cookie 中捎带上 Session Id。 如果客户端禁用了 Cookie,则可以用其他地方存储,例如 localStoragesessionStorage; 传输时,可以改写 url 以带上 Session Id (例如 xxx.com/?session=xxxxxxxxxxxxxxxx), 或者自定义请求报文的首部字段(例如 xhr.setRequestHeader('X-SessionID-Header', 'B6SE3C66');)等,服务器相应处理即可。

商品设计

商品表包含商品基本信息。

CREATE TABLE `goods` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
  `goods_name` varchar(16) DEFAULT NULL COMMENT '商品名称',
  `goods_title` varchar(64) DEFAULT NULL COMMENT '商品标题',
  `goods_img` varchar(64) DEFAULT NULL COMMENT '商品的图片',
  `goods_detail` longtext COMMENT '商品的详情介绍',
  `goods_price` decimal(10,2) DEFAULT '0.00' COMMENT '商品单价',
  `goods_stock` int(11) DEFAULT '0' COMMENT '商品库存,-1表示没有限制',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

极端情况下,秒杀系统的并发量可能会击垮数据库。所以, 我们最好把要秒杀的商品单独放进一张表中————即秒杀商品表,并单独部署于一个数据库中。 即使秒杀商品数据库被击垮了,也不会影响正常商品的业务逻辑。 虽然我们之后通过 Redis 缓存可以大幅缓解数据库压力,但是多一层防护没有害处, 毕竟缓存系统也有失效的可能。同时秒杀商品表还可供以后使用,毕竟秒杀系统不是一次性用品。

CREATE TABLE `seckill_goods` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀的商品表',
  `goods_id` bigint(20) DEFAULT NULL COMMENT '商品Id',
  `seckill_price` decimal(10,2) DEFAULT '0.00' COMMENT '秒杀价',
  `stock_count` int(11) DEFAULT NULL COMMENT '库存数量',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

秒杀商品表中的 goods_id 是连接商品表的外键。用户浏览商品时,对应的信息通过 秒杀商品表与商品表左连接得到。当然,从减少商品表压力的角度来说, 也可允许冗余、牺牲一致性保护,而把商品表中的属性复制到这里。

顺带讲一下连接

  • left join (左连接或者叫左外连接):返回左表中的所有行(包括右表中连接字段相等的行)。
  • right join (右连接或者叫右外连接):返回右表中的所有行(包括左表中连接字段相等的行)。
  • inner join (等值连接或者叫内连接):只返回两个表中连接字段相等的行。
  • full join (全外连接):返回左右表中所有的行和左右表中连接字段相等的行。

MySQL 不支持全外连接,但可以通过合并左右连接的数据并自动去重得到:

select * from A left join B on A.name = B.name union select * from A right join B on A.name = B.name

秒杀功能设计

终于讲到了系统的核心功能。

首先,秒杀是有严格时限的活动,对于到达的秒杀请求,如果当前时间在秒杀开始前或结束后则直接拒绝。 这个时间限制可以通过配置文件等方式注入系统,系统在初始化后读取时限, 以后的判断就全在内存中进行。之所以能这样做,是因为秒杀业务通常不会临时变更时限, 系统便也不需动态获取。

其次,我们一定要防止“超卖”,即实际卖出的商品数量大于秒杀库存数量。 为此,我们在数据库上必须加锁保护。如果编写的 SQL 是先查询、再更新,因为两个操作不是原子的, 便会产生 ABA 问题。我们应当这样编写 SQL:

update seckill_goods set stock_count = stock_count - 1 where goods_id = given_id and stock_count > 0

这样一来,查询和更新在一条 update 语句中完成,数据库会自动为我们加锁以保证原子性。

然而,如果让所有的秒杀请求都到达数据库的话,压力还是太大了。 其实,只有最开始的一些请求可以成功,其他的请求得到锁时就会发现 stock_count 已为 0,直接失败。 所以我们实际上让 Redis 来预减库存、过滤请求。 我们可以一开始就把各种商品与其对应的库存信息加载到 Redis 中去, 之后的请求全由 Redis 来先行处理库存,如果库存为 0 就阻止其访问数据库。 这样一来,数据库的访问数就只等于秒杀成功数。 另外,我们还可以在预减库存失败后本地记住这个结果,以后对于同一个商品的秒杀直接失败, 连 Redis 都不再访问。

下面就是 SeckillController 中处理秒杀的方法实现。

@RequestMapping(value="/seckill", method= RequestMethod.POST)
@ResponseBody
public Result<Integer> seckill(SeckillUser user, @RequestParam("goodsId") long goodsId) {
    Result<Integer> result = Result.build();

    // 没登录
    if (user == null) {
        result.withError(SESSION_ERROR.getCode(), SESSION_ERROR.getMessage());
        return result;
    }

    // localOverMap 是 Controller 内的 Map,用于内存标记,减少 Redis 访问
    boolean over = localOverMap.get(goodsId);
    if (over) {
        result.withError(SECKILL_OVER.getCode(), SECKILL_OVER.getMessage());
        return result;
    }

    // Redis 预减库存,decr 命令先减 1 再返回新值
    Long stock = redisService.decr(GoodsKey.getSeckillGoodsStock, "" + goodsId);
    if (stock < 0) {
        localOverMap.put(goodsId, true);
        result.withError(SECKILL_OVER.getCode(), SECKILL_OVER.getMessage());
        return result;
    }

    // 数据库在某个事务中减库存、下订单
    // ...

    return result;
}

其他优化策略

浏览器与 nginX 都可以对一些静态资源进行缓存,以降低访问流量。

商品信息、用户信息、订单信息等都没有必要在每次请求时都到数据库中去请求, 我们也可以在 Redis 中缓存。商品、订单信息的键通过其在数据库中的 id 构建,值是对应的 pojo; 用户信息的键其实就是前面提到的 Session Id,值也是 pojo,这其实就是 Session 了。 在 Redis 中缓存 Java 对象的行为称为对象缓存,可以通过“对象-JSON 转换”等方法实现。

可以针对某个 IP 进行访问频率限制,以削减并发量。


部分内容参考 CSDN博主「灰太狼_cxh」的文章

参考实现:

https://github.com/gonearewe/SecKill

https://github.com/qiurunze123/miaosha

如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆

contribution

最后附上 GitHub:https://github.com/gonearewe