V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
V2EX 提问指南
franklinre
V2EX  ›  问与答

请教秒杀抢购架构设计问题

  •  
  •   franklinre · 2022-09-07 10:04:00 +08:00 · 3093 次点击
    这是一个创建于 590 天前的主题,其中的信息可能已经有所发展或是发生改变。
    需求:用户可以抢购某个商品,提交信息后跳转到指定页面( http:\\host\productId\userId )等待刷新,后台任务队列处理完成后,刷新出抢购的订单信息。
    这样可以等待 order_table 出现相应的 productId 和 userId 的记录出现时能正确刷新出订单信息。
    但是,如果 order_table 已经有该 productId 和 userId 的记录或者允许多次抢购,order_table 会出现多条 productId 和 userId 的记录,该怎么确定指定的订单信息呢?
    我暂定是前端生成一个 uuid ,跳转到: http:\\host\productId\userId\uuid 等待刷新,uuid 存进该条记录到 order_table 。
    各位老哥,你们也是类似这样设计的吗?

    另外,我看到很多文章设计抢购系统是在 redis 放进库存数量,抢购成功扣库存,类似的设计。
    我想,能不能生成预备订单数据,库存 100 个,就先生成 100 个预备订单,抢购时就查找有无预备订单,有的话就把用户信息放进该预备订单,就表示抢购成功。
    问:在每次订单只允许抢购一个商品的前提下,是否方法二的可靠性更强?
    30 条回复    2022-09-08 17:55:22 +08:00
    xzh20121116g
        1
    xzh20121116g  
       2022-09-07 10:09:15 +08:00
    redis 更快一点,最多给用户加个锁;
    生成预备订单的话,是要给接口加锁吧,要大并发的话,还是选 redis 吧
    stonewu
        2
    stonewu  
       2022-09-07 10:15:49 +08:00
    这个 UUID 的方案是可行的,但是直觉上会放到后端生成返回给前端

    库存问题的话,扣减库存数的操作在能保证原子性的情况下,速度应该是比查找预备订单快的,扣库存的方案足够满足需求了
    opengps
        3
    opengps  
       2022-09-07 10:15:51 +08:00
    order_table 不现实,抢购对于处理效率要求极高,能在内存层面快速处理就不适合放入硬盘层面降低效率。并发系统选用 redis 原因:redis 在内存中快速处理,redis 提供可靠的锁来防超售
    lmshl
        4
    lmshl  
       2022-09-07 10:27:34 +08:00
    正好在写一篇关于秒杀抢购系统的文章,我先说结论:抢购秒杀系统考验的是你对计算机体系结构的认知,所有涉及 Redis / 分布式锁的方案,有一说一,路走窄了

    前两天我自己写的秒杀例子,在我笔记本上使用 2 核心,性能大约是
    !!!: 17704ms consume 524287
    折合每秒 2.96 万单,模拟的是 50 万请求同时抢购同一件商品,尚未做拆分。

    代码: https://github.com/mingyang91/akka-ticketing
    LeegoYih
        5
    LeegoYih  
       2022-09-07 10:57:29 +08:00   ❤️ 1
    秒杀的主要瓶颈在数据库,尤其是 MySQL 这种,所以目标是减少无效请求,保证请求到数据库层面的都是有效的,所以用缓存是必要的。


    1. 客户端按钮设置隐式冷却

    OP 描述的“提交信息后跳转到指定页面”,用户点提交按钮,可能会返回上一页,然后继续提交,这样会生成很多无效的订单,我抢消费券就是这么干的,所以需要加一个冷却时间,用户一直操作,实际上每秒可能只有一次请求

    2. 网关层限流

    按钮冷却只能防普通用户,网关限流用来防止懂技术的脚本哥,根据 IP 或者 UserId 进行限制请求次数。
    或者直接对商品进行限流,例如:如果商品只有 10 个,每秒请求有 10000 个,那么实际上大部分请求都是无效的,允许每秒 100 - 1000 请求进来即可,其他的直接返回「已抢光」,这里可以使用「令牌桶」和「滑动窗口」算法。

    3. 服务层

    OP 说用 UUID ,我感觉还是后台生成比较好,收到请求后,生成一个 OrderId 放到缓存中,然后通过 MQ 异步创建订单,直接把 OrderId 返回给客户端,再提供一个接口让前端轮询缓存中的订单状态。
    MQ 消费完成后更新缓存中的订单状态,客户端发现订单已创建,再去查询真正的订单详情。

    服务层使用 Redis 维护库存数量的优点也很明显,如果库存没了直接返回即可,不用调数据库,保证数据库扣减库存、生成订单都是有效操作。

    4. 数据库层

    通过数据库自身的锁保证原子性,防止超卖,此时数据库基本没什么压力。
    wdwwtzy
        6
    wdwwtzy  
       2022-09-07 11:20:24 +08:00
    @lmshl 请问文章在哪里能看到呢?
    lmshl
        7
    lmshl  
       2022-09-07 11:27:01 +08:00
    文章昨天才开始写,示例代码已经放 GitHub( https://github.com/mingyang91/akka-ticketing) 了。
    在我笔记本连远程数据库的环境中,单商品每秒接近三万的订单确认速率,完全不需要引入任何的 MQ / Redis / 主从分表等复杂中间件
    wdwwtzy
        8
    wdwwtzy  
       2022-09-07 11:35:01 +08:00
    @lmshl 这么神奇吗,拿本地锁来做的吗?
    lmshl
        9
    lmshl  
       2022-09-07 12:19:27 +08:00
    @wdwwtzy

    因为在计算机体系结构中
    L1/2/3 >> Memory >> SSD > Network
    所以只要开始扯 Redis ,就已经输在起跑线上了
    限制秒杀系统极限的有两个因素
    1. CPU 的 IPC 和 主频 :提供锁
    2. 磁盘顺序写入速度:提供持久化

    结论显而易见,数据库只存储交易日志,事务由程序保证,以数据库写入响应视为事务提交
    同时将交易日志的持久化做批量化聚合,一次写入一批以最大程度减少 磁盘 / 网络 的 IO 响应次数

    做到以上几点,软件正确性和性能就都得到了保证,在此之上还可以利用负载均衡 hash ,或引入 akka cluster sharding 等分布式集群组件来做横向扩展和故障转移,一个接近理论上限的秒杀系统就完成了。

    这也就是为什么我选择了 CQRS 模型的 Akka-Projection
    Pythoner666666
        10
    Pythoner666666  
       2022-09-07 12:53:47 +08:00
    文章发出来记得 @我 我很想拜读一下
    @lmshl
    2bad4u
        11
    2bad4u  
       2022-09-07 12:59:29 +08:00
    @lmshl 有干货,求细讲。
    sujin190
        12
    sujin190  
       2022-09-07 16:07:00 +08:00
    @lmshl #4 粗略看下,秒杀的难点本来也不是多快,否则这的人大概率都能写出一个处理 10k 以上 qps 的程序,现实中秒杀麻烦的是除了要处理商品库存订单问题外,还有营销折扣系统、优惠系统、风控系统、配送与地址系统等等,这一系列下来之后会是一个非常长的流程,在各系统负载一致和事务一致处理起来会非常麻烦,从这一点上来说,直接使用带库存数分布式锁直接拦截在所有系统前面才是最容易实现且靠谱的方案,反而是使用队列平滑再反馈结果其实更麻烦

    而且还有现实大概率不会出现却又不得不考虑的崩溃恢复问题,队列造成了较长处理链路是清理的复杂性想满足秒杀场景下较短的崩溃恢复时间还是十分难的,使用分布式锁拦截则可以设置较短的等待时间即可,也没有进入下单的业务流程,随着时间超时后就会自然恢复
    buster
        13
    buster  
       2022-09-07 16:31:44 +08:00
    网上搜索了一下,大概思路可能是这个 https://scala.cool/2017/08/learning-akka-8/
    lmshl
        14
    lmshl  
       2022-09-07 16:40:24 +08:00
    @sujin190 并不是
    1. 我的例子代码中已经保证了库存严格不超售,且订单记录有审计。
    2. 其他系统和核心下单系统并不属于一个 Domain ,属于可以垂直拆分的组件,同时,折扣优惠,封控属于可以被前置业务逻辑检查的,也绝对不应该被合并到下单事务中一起处理
    3. 崩溃恢复已经被 akka-projection 实现了,当然自己实现也不难,从快照+快照以后发生的日志快速回放一遍很容易。

    分布式锁是最差最差的方案了,使用了分布式锁以后,你的库存扣减逻辑很难高过 100tps ,这是理论上限
    lmshl
        15
    lmshl  
       2022-09-07 16:43:03 +08:00
    @buster 是的没错,扫了一眼他的文章,和我思路基本一致,不过他没提供代码示例

    我用的 akka-projection 是依赖 akka-cluster-sharding 和 akka- persistence 实现的
    sujin190
        16
    sujin190  
       2022-09-07 16:50:04 +08:00
    @lmshl #14 那你这分布式锁实现有问题吧,否则按你这么说 mysql 也无法完成超过这个限值了,那怎么搞岂不是都没用了

    超不超卖的本来也不难处理吧,想要尽可能可靠从秒杀来说,既然库存非常小否则也不叫秒杀了,那么就应该除了让正常库存进入下单流程外,其他请求的处理过程都尽可能短,最好到达网关就直接返回,你再搞快照搞回滚,那么不是增加了可能出错的点了吧,多一步就多一步出错的点,啥都不用做自然也不可能有任何异常了
    lmshl
        17
    lmshl  
       2022-09-07 17:00:00 +08:00
    @sujin190 磁盘和网络 IO 都需要时间,即使是内网,最简单的 KV 访问也需要 >1ms 时间
    你用分布式锁,怎么和软件内存中实现的事务比,直接输在起跑线上了好吧

    访问 L1/2/3 到内存,时间单位可都是 ns ,内存运算完事务扔给磁盘持久化,磁盘写入多快,秒杀就能有多快。
    高下立判
    sujin190
        18
    sujin190  
       2022-09-07 17:14:07 +08:00
    @lmshl #17 网络请求又不是串行的,整个分布式锁本来也就只在加减一才串行,哪里有问题了,就算要多机强一致,网络请求同步过程依然可以并行,多机强一致下百万以上 qps 可能做不到,十几万 qps 也还是可以的,一旦库存抢完就进入完全并行阶段,百万以上 qps 不很轻松么,这个的实现有简单,直接集成在网关里也没毫无问题,这显然已经是改造最小集成最简单的方案了
    sujin190
        19
    sujin190  
       2022-09-07 17:22:14 +08:00   ❤️ 1
    @lmshl #17 https://github.com/snower/slock
    顺便说我已经做出来了,真的不是在嘴炮,3 节点多机强一致阶段大概能有超过 10 万 qps ,并行阶段能接近 200 万 qps ,极其简单的协议也可以直接集成在 openresty 里,我还是觉得这种在非淘宝拼多多这种超大站点,这个应该是最容易实现维护的架构了,毕竟只要你不是动辄秒大量商品库存,只要在现有订单系统前添加拦截流程就可以,几乎不需要针对秒杀逻辑对订单系统做单独调整
    lmshl
        20
    lmshl  
       2022-09-07 17:24:34 +08:00
    @sujin190 “整个分布式锁本来也就只在加减一才串行” 这不就是串行了么?如果失败了你还得再分布式里处理回滚,复杂度不就上升了么?

    而且我是秒杀在 1 个根上就可以做到一秒接近 3 万笔交易,横向扩展也没有上限,整个系统除了业务 Java 进程,只需要 数据库 + 负载均衡就可以运作了,维护成本更低。显然更小吧
    wangchonglie
        21
    wangchonglie  
       2022-09-07 17:42:35 +08:00
    @lmshl #4 mark 一下
    micean
        22
    micean  
       2022-09-07 17:58:32 +08:00
    @lmshl

    意义不大,实际业务场景会涉及到很多,比如限定单个用户购买数量
    lmshl
        23
    lmshl  
       2022-09-07 18:37:23 +08:00
    @micean 这可太简单了
    如果仅允许一次下单,直接前过滤,TPS 丝毫不受影响
    如果允许多次下单,但总购买数量不能超过 n 个,那无非就是在软件事务内存里多加一个 if/else branch ,TPS 下降可能都低于可测量误差了
    micean
        24
    micean  
       2022-09-08 10:17:32 +08:00
    @lmshl

    举例“限定单个用户购买数量”,一是架构里要涉及商品以外的数据加入到事务内存中增加了项目维护复杂度,二是看集群内存如何保证强一致性。单机跑性能意义真的不大。
    lmshl
        25
    lmshl  
       2022-09-08 10:58:35 +08:00
    @micean STM ( Software transactional memory )软件事务内存,https://hackage.haskell.org/package/stm
    lmshl
        26
    lmshl  
       2022-09-08 11:01:09 +08:00
    @micean 如果你了解 STM 的话就不会这么说了,软件事务远比分布式事务更容易实现,也更容易做到高并发高吞吐量,几行代码能描述清楚的规则,犯不着上分布式事务,上了分布式事务你不得设计回滚么?

    而且我的事务以持久化视为事务提交并返回客户端,已经把系统最重要的部分模拟出来了,且保证正确性。
    micean
        27
    micean  
       2022-09-08 11:54:49 +08:00
    @lmshl

    也没有说分布式事务(基本也是避免使用分布式事务)。你的性能我并没有异议,stm 没毛病。但是场景更像网络游戏而不是电商。
    现实是不可能只用一台服务器去承载所有业务(只用一台服务器的电商业务量相信也用不上你的架构),既然是集群的话,必然就要考虑一致性的问题,秒杀的压力并不在于最终事务,而是在于查询过滤的效率,前面小哥也证明了加锁减库存也不差。
    lmshl
        28
    lmshl  
       2022-09-08 13:57:04 +08:00
    @micean 我列举单机数据的意思是,“我只用单机(甚至单核)就可以做的比别人集群更快”
    而不是我只能用单机,实际上我是用的框架是 akka-cluster-sharding ,是一套水平伸缩集群,意味着并发抢购多件商品的时候,性能是随着节点增加而近乎线性增长的。

    而且,你说场景更像网络游戏的话,akka-cluster-sharding 的一大优势应用场景就是 MMORPG ,腾讯和暴雪都在用 akka 做百万同服同时在线。

    加锁减库存的问题在于,锁掉整个商品的长时间事务,这个商品的交易会被串行化导致吞吐量急剧下降。
    如果采用预扣除算法,又会要求所有其他组件都要设计回滚操作,增加了整个架构的成本。
    micean
        29
    micean  
       2022-09-08 17:16:40 +08:00
    @lmshl

    一个商品只能在一个 shard 上对不对?如果用户提交多个商品并带有跨店满减优惠、代金券,是不是要设计回滚
    lmshl
        30
    lmshl  
       2022-09-08 17:55:22 +08:00
    @micean 没见过有在秒杀业务上做加法的,业务做减法才是常态吧。

    如果硬要在秒杀系统上加跨商品结算的话,我选择 saga pattern ,可能每单结算时间会长一点,但不会降低每个商品交易吞吐量,总吞吐量影响也不大。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   853 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 21:42 · PVG 05:42 · LAX 14:42 · JFK 17:42
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.