🚀 透过秒杀系统理解Redis、MySQL、消息队列的使用场景

在公司经常有00后来问我,如何快速积累Redis, MySQL, Kafka三大中间件的相关经验,由于在大公司,日常工作中基本是其中一种或两种中间件的组合使用,例如MySQL和Redis组合,或Kafka和MySQL组合,很少同时具备Redis, MySQL, Kafka三个系统同时使用的场景。

既然00后来咨询了,通常会让他们去实现一个秒杀系统,因为秒杀系统有下面几个特点:

瞬时大流量高并发: 秒杀系统通常是有极高的并发,例如只有100台茅台酱香,但是有10w人以上在瞬间抢购。

有限库存,不能超卖: 库存是有限的,需要精准保证只能卖掉N个商品,不能卖N+1也不能卖N-1个商品。

严格限购: 京东商城的茅台抢购,一个用户只能买1瓶茅台500ml酱香。

反作弊:京东商场的茅台抢购,是官方的1499元,抢到转手卖,即使现在白酒行情比较差,依然有1000块的差价,就会有人靠脚本去刷茅台,转手卖出去。

结合这四个特点,秒杀系统刚好可以同时使用Redis, MySQL,消息队列三大中间件。

✈️ 由哪些模块组成

秒杀系统通常属于运营活动,运营活动系统由后台运营配置和前台业务系统。

🌹后台运营系统: 在成熟的业务公司,后台运营系统往往是一系列的运营活动组成的。秒杀系统也能复用这些系统,主要包括新建和配置秒杀活动,一个运营系统承接的业务够多也会变得更复杂,这种更多的是业务复杂性,对高并发要求较少。

🍄前台业务系统: 秒杀系统的前台业务系统,主要由秒杀、商品信息、库存、订单、支付五个模块组成。五个模块都是单独的数据表或数据库,这五个模块可以是一种编程语言和框架,也可以是多个编程语言和框架。

🚅 数据表设计

前面讲到秒杀活动通常配置五个模块对应五个数据表设计:

🍆 运营活动信息表(activity_info):

运营活动信息表主要作用是支持运营人员或商家在后台新建和配置相关运营活动,因为秒杀活动是运营活动的一种,通常直接复用运营活动数据表,当然单独建一个表也是可以的, 主要包含活动id、名称、参加活动的商品id、参加活动的商品价格等字段

id name commodity_id price
100 茅台酱香500ml 秒杀 xxxxxxx 1499 其他信息
101 大疆Avata2 无人机 某节优惠 xxxxxxx 6899 其他信息
102 棉柔巾优惠活动 xxxxxxx 10 其他信息

🌶️ 商品信息表(commodity_info):

商品信息表在电商系统中是最常见的一张表, 主要包含商品id、名称、简介、价格、种类等字段, 表结构如下:

id name desc price category
200 茅台酱香500ml 1499 xxxxxxx 1499 酒类 其他信息
201 大疆Avata2 无人机 xxxxxxx 66 3C类 其他信息

🍠 库存信息表(stock_info):

库存信息表的作用是存储某个商品参加某活动的库存数量,主要包含库存ID、商品ID、活动ID、库存数量、库存是否锁定等字段,表结构如下:

id commodity_id activity_id stock is_lock
300 200 100 1000 0 其他信息
301 201 101 2000 1 其他信息

🥕订单信息表(order_info):

订单信息表的作用用户针对某个商品下订单,主要包含订单ID、商品ID、活动ID、用户ID、是否支付成功、订单创建时间等字段,表结构如下:

id commodity_id activity_id uid paid
400 200 100 10000001 1 其他信息
401 200 100 10000002 0 其他信息

🥦支付信息表(payment_info):

支付信息表的作用用户针对某个商品的订单支付状态,主要包含支付ID、订单ID、支付价金额、用户ID、是否支付成功、支付时间、支付方式等字段,表结构如下:

id order_id amount uid paid
500 400 1499 10000001 1 其他信息
501 401 1499 10000002 0 其他信息

上面五张表都没介绍给哪些字段建索引、是否分库和分表、分多少张表等信息是因为此类信息稍微有些复杂就暂时略过。

🛰️ 秒杀模块

秒杀活动的相关数据表建立完成,就可以开始编写秒杀活动的业务代码,先思考一下有用户请求,是不是得先判断有没有库存,如果有库存就继续,如果没有就认为活动结束,直接将用户的界面抢购按钮置为不可用。所以先看下有哪些数据库操作呢,只有数据库操作是不是满足上述需求?

🍉数据库操作

读取判断库存,然后扣减库存

第一步: 查询库存余量

SELECT stock FROM stock_info WHERE commodity_id = 200 AND activity_id = 100;

第二步: 扣减库存

UPDATE stock_info SET stock = stock -  1 WHERE commodity_id = 200 AND activity_id = 100;

上面两个SQL语句由于是分开执行,不具备原子性,就会引发库存超卖问题。

第三步: 如何解决库存超卖问题

通过在读取库存和扣减库存整个过程中加一个数据库事务:

## 1.事务开始
START TRANSACTION;

## 2. 查询库存余量, 并锁住数据, 
## 使用FOR UPDATE出发MySQL的行锁
SELECT stock FROM stock_info WHERE commodity_id = 200 AND activity_id = 100 FOR UPDATE;

## 3. 扣库存操作
UPDATE stock_info SET stock = stock - 1 WHERE commodity_id = 200 AND activity_id = 100;

## 4. 事务提交
COMMIT;

## ????? 思考一下数据库除了加事务,还有其他方法解决库存超卖问题么 ?????

数据库操作的弊病:

🎖️数据库的事务操作是个耗时操作,很慢。

🎖️一个茅台抢购活动,库存只有100,却有10w人抢购,99900人的流量是无效的。

🎖️MySQL单点数据库能支撑QPS约为1000, 如果10w请求都下沉到MySQL, MySQL直接崩溃。

🎖️MySQL奔溃会导致整个系统不可用。

🍎Redis操作

数据库扛不住10万QPS,但Redis单点能支撑10万QPS。

从系统设计的角度,就不应该有10万QPS打到数据库,一个好的系统设计,理论上库存有多少,就应该有多少请求到数据库,例如库存只有100,最终也只有100个请求到数据库。

既然Redis单点能支撑10万QPS, 就想办法让Redis来判断并扣减库存,通过Redis扣减库存,必须先将库存信息加载到Redis中。

第一步: 库存预热

库存预热是将库存写入到Redis中,是商家在运营活动配置平台配置时,通过脚本写入Redis。

## Redis语法: SET KEY VALUE
## KEY 是 activity:100:commodity:200:stock
## VALUE 是 100
SET activity:100:commodity:200:stock 100

第二步: 扣减库存

## 1. 获取库存
GET activity:100:commodity:200:stock

## 2. 扣库存: 将KEY中存储的数值减一
DECR activity:100:commodity:200:stock

扣减库存里的Redis的GET和DECR操作各自是原子操作,但是先GET再DECR就不是原子操作,还是会引起库存超卖。

第三步: Redis如何解决库存超卖问题

## Lua脚本功能是Redis在2.6版本中支持的,通过内嵌对Lua环境的支持,
## Redis解决了长久以来不能高效地处理CAS(check-and-set)命令的缺失。
## Lua脚本其实类似Redis事务,具有原子性,不会被其他命令插队,可以完成Redis事务性的操作
if (redis.call('exists', KEYs[1] == 1)) then;
		local stock = tonumber(redis.call('get', KEYS[1]));
  	if (stock < = 0) then
    		return -1;
		end;
  	redis.call('decr', KEYS[1]);
  	return stock - 1;
end;
return -1;

## ????? 除了Lua脚本的支持,还有其他什么办法解决Redis的库存超卖问题 ?????
## 分布式锁也可以解决库存超卖问题 

🍓消息队列(Kafka)

通过MySQL和Redis可以轻松解决库存超卖问题,但只是解决了库存较少的情况,例如库存只有100,如果库存是1万或者更高呢,Redis虽然能解决库存超卖问题,但还是一样会有1万的库存请求打到MySQL数据库,MySQL数据库依然会崩溃。

解决库存较多的情况,可以通过消息队列(MQ)进行削峰(Peak Clipping)操作。

整体流程

消息队列

思考一下

如果消息队列出现丢消息怎么办?

解决方案是Redis中的库存量可以比实际的库存量多一点,比如1.5倍。

🍑库存扣减时机

第一种情况: 下单时立即减库存

用户体验最好, 控制最精准,只要下单成功,利用数据库锁机制,用户一定能成功付款。可能被恶意下单。下单后不付款,别人还买不了。

第二种情况: 先下单,不减库存。实际支付成功后减库存

可以避免恶意下单。

但是用户体验极差,因为下单是没有减库存,可能造成用户下单成功后但无法付款。

第三种情况: 下单锁定库存,支付成功后减库存

可以规避第一种和第二种情况。

一般下单之后先锁定库存15分钟。如果支付成功,直接扣减库存,如果超过15分钟不付款或者付款失败,则释放库存。

🍒如何限购

以京东抢购茅台为例,每人每次只能限购一瓶。限购有两种办法:

第一种:MySQL数据校验

MySQL数据校验

可以在订单表中将用户id, 商品id, 活动id建一个联合唯一索引,这样就限制了用户只能抢购有一次抢购机会。

实践过程中,因为MySQL的操作很耗时,就很少采用这种方案。

第二种: Redis数据校验

Redis数据校验

实践中,因为Redis的单点可以支撑10万QPS,会使用Redis进行限购。

具体步骤按上面流程图所示:

1️⃣ 用户请求先去Redis的集合中查询,查询key设计为: 活动id:商品id:user, 值为用户id

2️⃣ 如果不在Redis集合中,则构建库存,并且将用户id写入Redis的集合中

3️⃣ 如果在Redis的集合中,说明用户已经够买过了,直接返回。

4️⃣ 最后处理一下边界情况,如果用户在最后没有成功付款,需要将用户的购买机会释放出来,删除用户在Redis集合中的用户id。

使用到Redis的集合, 命令如下:

## 将一个或者多个成员元素加入到集合中, 已经存在于集合中的成员元素将被忽略。
SADD KEY VALUE1 ... VALUEN
## 例如: 
SADD activity:100:commodity:200:user uid1 

## 判断成员元素是否是集合的成员
SISMEMBER KEY VALUE
## 例如:
SISMEMBER activity:100:commodity:200:user uid1

🚁总结

全文基本介绍了引入三大中间件在秒杀活动是为了解决哪些问题,并简单介绍了一些用法。除此以外,秒杀活动还有很多其他细节都没有涉及,三大中间件还有更多的技术细节也没有涉及。

MySQL: 主要用来保存用户数据,在秒杀活动中也会用到数据库事务保证库存超卖问题。

Redis: Redis在高并发系统设计中,几乎避不开,主要是让流量都留在Redis中解决,帮助MySQL拦住大部分无用流量 。

消息队列: Redis能解决少量库存,例如100,1000等,如果一个活动的库存达到1w甚至更多,就得考虑削峰了,最终还是让大部分流量最好不要到MySQL中。