2018 年, 楼主所在业务线开始发力探索线上 O2O 业务, 但楼主所在公司并非纯电商公司, 电商体系标配的商品中心, 库存中心, 活动系统都处于萌芽阶段不成气候. 10 月中旬, 业务决定搞一波双十一大促提升品牌知名度. 整个大促最核心功能点就是造一个秒杀系统, 为整个活动会场积蓄流量. 作为该业务线营销工作负责人, 这个秒杀系统设计任务自然就落到楼主身上. 时间紧任务重, 从需求提出到上线, 2 周时间紧锣密鼓开发, 多次代码写到凌晨两点. 值得欣慰的是, 最终这个系统完美支撑了大促, 帮助业务拿到显著超出预期的结果. 时光飞逝, 一晃一年多过去了, 楼主 19 年初也转战另一条新业务线负责导购交易, 这篇文章就当是对这个念念不忘的秒杀系统一个总结, 祭奠那段写代码不知疲倦激情满满的岁月.
友情提示: 网上搜秒杀系统架构, 大多一上来就开始讲 CDN 静态资源缓存, Nginx 请求拦截, 缓存设计, 消息队列削峰, 请求限流等一系列技术点. 看似面面俱到考虑周全, 但楼主真忍不住想问, 以当前业务体量, 真有必要搞那么多招式吗? 以上技术点本文通通不涉及! 阅读本文不需要高深的知识, 楼主就想从纯后端角度, 带领大家看一看如何快速落地一个秒杀系统.
这一篇题目标注「战略篇」, 事实上来源就是楼主做秒杀需求的技术方案. 内容过于真实, 楼主摘取最核心的内容贴出来. 从一个亲历者角度来复盘一个秒杀系统是如何设计并落地的.
业务需求
产品需求
此处应该贴产品需求文档; 我简单概述下: 双十一业务要上线活动大促, 需要通过秒杀这种玩法来为整个会场蓄水流量. 秒杀活动的玩法是: 指定几个商品, 商品库存有限, 同时每个商品还限制单个用户累计只能买 N 件.
业务流程
秒杀活动整体业务流程
通过对需求分析, 我们提炼出秒杀活动的三大子流程, 即:(运营)创建秒杀活动,(运营 / 买家)查看秒杀活动,(买家)参与秒杀活动; 有了这个整体把握, 我们再针对每一个子流程, 分析该场景下需要编排哪些产品功能.
创建秒杀活动
创建秒杀活动: 做的事情很简单, 需要配置好活动场次, 每个场次又需配置参与活动的秒杀商品; 这个功能点, 主要是面向运营, 为了方便运营完成活动配置编辑, 附带着还需提供活动查询能力: 如查看活动列表, 活动详情, 发布 / 禁用活动等 mis 接口.
查看秒杀活动
查看秒杀活动: 主要功能为支持秒杀会场的活动列表, 秒杀商品列表, 秒杀商品详情等活动页面; 这些都是直接面向 C 端用户的系列读接口, 承载流量会很高.
参与秒杀活动
查看秒杀活动: 这一块核心述求是要能正确高效完成库存扣减, 严格保证不能出现超卖! 从功能点上来看, C 端买家下单实现秒杀商品库存扣减, 如果买家在规定时间未完成支付或拍下后取消订单, 需及时释放用户下单锁定的库存, 也就是要回库存.
小节
这一章首先通过需求分析提炼, 建立需求整体大局观(创建秒杀活动, 查看秒杀活动, 参与秒杀活动). 然后通过拆解需求三大业务子流程, 明确各子流程功能点, 进一步分而治之. 明确业务流程和场景, 有了清晰的产品功能认识, 我们就可以开展下一步概要设计了.
概要设计
E-R 关系图
E-R 关系图, 用于指导如何建立领域模型. 从 E-R 图上我们能看出, 几个比较重要的领域模型: 如活动, 活动商品, 系统真正编码落地的时候, 就紧紧围绕这些领域模型去建模, 做到代码和领域模型的表达是一致的.
产品边界
概要设计的目的是为了明确产品功能和系统边界, 通过领域驱动的界限上下文图, 能清晰地看出完成当前需求需要参与协作的团队, 以及团队与团队之间任务划分边界. 活动上下文是我们关注的重点, 同时也应该看到, 我们需要商品团队, 交易团队的协作.
接口定义
通过概要设计划清了系统边界, 每部分每个团队应该做什么就容易确定了, API 定义呼之欲出.
配置活动
1, 定义
设置活动, 活动商品, 活动库存, 开始结束时间等配置
系统需为每场活动分配全局唯一活动 id: 若提交的数据带有活动 id, 则表示更新
2, 接口变更: 新增接口
3,API:POST http://${domain}/API/v1/activity/save
4, 输入:
- {
- "activityName": "双十一秒杀第一场",
- "startTime": 1540174800000, // 活动开始时间
- "endTime": 1541988000000, // 活动结束时间
- "itemLine": [
- {
- "itemId": 123, // 活动商品 id
- "itemType": 7, // 活动商品类型
- "itemTitle": "这是商品标题",
- "subTitle": "这是商品副标题",
- "itemImage": "这是图片链接",
- "salePrice": 66800, // 商品原价(单位: 分)
- "activityPrice": 100, // 活动价
- "quota": 3, // 单个用户商品抢购件数限制
- "stock": 100 // 商品活动库存
- }
- ],
- "activityRuleConfigs": [ // 活动规则列表
- {
- "configKey": "city", // 城市规则: 在规则列表的城市可看到活动
- "configValue": "17,5,10,2,3,4,11"
- }
- ]
- }
5, 输出:
- {
- "traceId": "2910c88d0d4f45d5fe299f0c5829d72c",
- "code": "SERVICE_RUN_SUCCESS",
- "msg": "服务运行成功",
- "status": 10000,
- "success": true
- }
活动列表
1, 定义: 返回已创建的全部活动简要信息(不包含活动商品及销量)
2, 接口变更: 新增接口
3,API:POST http://${domain}/API/v1/activity/list
4, 输入: 无
5, 输出:
- {
- "traceId": "2910c88a0d4f45d5be290f0c5829d72c",
- "success": true,
- "status": 10000,
- "msg": "OK",
- "code": "SUCCESS",
- "data": [
- {
- "activityId": 1,
- "activityName": "双十一秒杀第一场",
- "startTime": 1541901600000,
- "endTime": 1741951999000,
- "enabled" true
- }
- ]
- }
活动详情
1, 定义: 返回指定活动详细信息(包含活动商品及其销量)
2, 接口变更: 新增接口
3,API:GET http://${domain}/API/v1/activity/detail
4, 输入: activityId=1
5, 输出:
- {
- "traceId": "889924ef8e6241a7a766107f38c5e0c0",
- "success": true,
- "status": 10000,
- "msg": "OK",
- "code": "SUCCESS",
- "data": {
- "activityId": 1,
- "activityName": "双十一秒杀第一场",
- "startTime": 1541901600000,
- "endTime": 1741951999000,
- "enabled" true
- "items": [
- {
- "itemId": 53725,
- "itemType": 1,
- "itemTitle": "x 商品",
- "subTitle": "x 商品副标题",
- "itemImage": "http://img.xxxx.com/static/do1_QtSq1m2xM7VL6zEI4sUH",
- "itemPrice": 19800,
- "activityPrice": 4800,
- "quota": 3,
- "stock": 50,
- "sold": 0
- },
- {
- "itemId": 53724,
- "itemType": 1,
- "itemTitle": "y 商品",
- "subTitle": "y 商品副标题",
- "itemImage": "http://img.xxxx.com/static/MrcNjUeeoOG24zZH7nR.png",
- "itemPrice": 42800,
- "activityPrice": 17000,
- "quota": 3,
- "stock": 50,
- "sold": 0
- }
- ]
- }
- }
活动商品详情
1, 定义: 返回活动商品详细信息(包含活动商品销量, 活动信息)
2, 接口变更: 新增接口
3,API:GET http://${domain}/API/v1/activity/itemDetail
4, 输入: activityId=1&itemId=53725
5, 输出:
- {
- "traceId": "a77edf653da644959d331b7b55607958",
- "success": true,
- "status": 10000,
- "msg": "OK",
- "code": "SUCCESS",
- "data": {
- "itemId": 53724,
- "itemType": 1,
- "itemTitle": "x 商品",
- "subTitle": "商品副标题",
- "itemImage": "http://img.xxxx.com/static/do1_QtSq1m2xM7VL6zEI4sUH",
- "itemPrice": 42800,
- "activityPrice": 17000,
- "quota": 3,
- "stock": 50,
- "sold": 0,
- "activity": {
- "activityId": 1,
- "activityName": "双十一秒杀第一场",
- "startTime": 1541901600000,
- "endTime": 1741951999000,
- "enabled" true
- }
- }
- }
扣库存
1, 定义: 扣活动库存, 扣用户参与抢购资格(注: 不局限 Http 接口, 可采用 Dubbo 调用; 此处仅方便演示)
2, 接口变更: 新增接口
3,API:POST http://${domain}/API/v1/stock/reduce
4, 输入:
- {
- "activityId": 1,
- "buyerId": "buyer_001",
- "itemId": 53724,
- "orderId": "20191111123456789",
- "orderTime": 1541901700000,
- "quantity": 1
- }
5, 输出:
- {
- "traceId": "f689852f113e413d9940ce24020e7083",
- "success": true,
- "status": 10000,
- "msg": "OK",
- "code": "SUCCESS",
- "data": true
- }
回库存
1, 定义: 回商品活动库存, 回用户参与资格; 可看做是扣库存的逆向操作;(注: 笔者在真正实现时, 未采用 Http 接口, 而是通过监听订单 MQ 来异步回库存)
2, 接口变更: 新增接口
3,API:POST http://${domain}/API/v1/stock/cancelReduce
4, 输入:
- {
- "activityId": 1,
- "orderId": "20191111123456789"
- }
5, 输出:
- {
- "traceId": "5342243fd424468ab9ad13d03ffcdc62",
- "success": true,
- "status": 10000,
- "msg": "OK",
- "code": "SUCCESS"
- }
详细设计
系统流程
1, 创建秒杀活动
2, 查看秒杀活动
3, 参与秒杀活动
数据模型
纵观整个需求的核心, 就是如何做好库存扣减. 在系统落地上, 楼主采用了业界较为广泛的 Redis + Lua 脚本方式来实现库存扣减控制.
活动配置表结构
说明:
activity_catalog 这个 Hash 结构, 用来配置活动信息(活动 id, 名称, 开始 / 结束时间, 活动准入规则等); 全部活动共用这一个结构
activity_items:$ 活动 id 这个 Hash 结构, 用来配置指定活动的商品信息(活动库存, 限购量, 活动价等); 每一个活动都有一个这样的结构
库存扣减核心表结构
说明:
buyer_hold:$ 活动 id:$ 商品 id, 这个 Hash 结构, 用来记录买家在某个活动拍下活动商品的数量
item_salse:$ 活动 id, 这个 Hash 结构, 用来记录活动商品销量
stock_reduce_flow$ 活动 id, 这个 Hash 结构, 称为库存扣减流水表. 用来记录「哪个活动 (activityId) 哪个买家 (buyerId) 在何时 (orderTime) 下了哪个订单 (orderId) 拍下哪个商品 (itemId) 多少件(quantity)」这一库存扣减流水
为什么是这三个结构?
buyer_hold:$ 活动 id:$ 商品 id, 可以知道用户已拍下多少件, 就能做到控制用户累计只能买 N 件;
通过 item_salse:$ 活动 id 能知道商品已售出多少件, 再结合商品的库存限制, 就有办法去控制库存避免超卖; 同时也能给 C 端透出商品秒杀进度.
通过库存扣减流水 stock_reduce_flow$ 活动 id, 在做回库存的时候, 就能依据这一流水, 知道去回哪个商品的库存, 以及回哪个买家的已拍下数量.
放张图, 直观感受下上面罗列的五种数据结构; 眼精的同学肯定会发现, 其中有四个 Hash 结构的 key 都带上了 {seckill_$ 活动 id} 的前缀. 为何要这样特殊处理? 其实楼主也在《 这就是你要找的分布式锁 》这篇中有所提及, 原因在于 Redis 集群环境下 Lua 脚本操作的 key, 必需限制这些 key 落在同一个 slot 中, 否则运行会报错 Lua script attempted to access a non local key in a cluster node . channel; 对此, Redis 就提供了 HashTag 的方案, HashTag 是用 {和} 包裹的一个子串, 相同 HashTag 子串, 会落到同一个 slot 中.
库存扣减 Lua 脚本伪代码演示
扣库存: 扣用户抢购资格, 扣商品库存, 记录库存扣减流水
hincrby buyer_hold:$ 活动 id:$ 商品 id $ 买家 id $ 抢购数量
hincrby item_sales:$ 活动 id $ 商品 id $ 抢购数量
hset stock_reduce_flow:$ 活动 id $ 订单 id $JSON 化库存扣减流水
回库存: 回用户抢购资格, 回商品库存, 删除库存扣减流水
hincrby buyer_hold:$ 活动 id:$ 商品 id $ 买家 id -1*$ 抢购数量
hincrby item_sales:$ 活动 id $ 商品 id -1*$ 抢购数量
hdel stock_reduce_flow:$ 活动 id $ 订单 id
总结
至此, 我们就就从业务流程, 产品流程, 系统流程, 由整体到局部, 完成了整个秒杀系统的需求分析和接口定义; Talk is cheap, Show me your code, 在下一篇《一个极简, 高效的秒杀系统(实战篇)》楼主会再结合源码, 一步步揭开整个秒杀系统的面纱; 最后的最后, 写作不易, 人过留名燕过留声, 如果有收获, 点个赞呗~
来源: http://www.tuicool.com/articles/QNz2uaA