高并发下秒杀系统的设计

在电商这片红海,秒杀活动无疑是屡试不爽的流量密码、销量利器。然而,在应对其并发请求时,其中的设计门道暗藏玄机。并发量小,数据库单表便能一夫当关,稳保活动无虞;一旦碰上爆款引爆流量,并发呈井喷式增长,单表瞬间独木难支,此时,一套高并发 "抗压" 组合拳必不可少。接下来,咱们就一起深挖业界那些屡建奇功的妙招,直击高并发痛点,重点解析其中两大 "硬核" 方案,助你轻松拿捏秒杀场景的技术难点。

1 业界通用做法

1.1 压力分摊

在应对并发的挑战时,利用分桶策略压力分摊,往往受到很多人的青睐。具体而言,面对单品库存,巧妙地拆分为多组,大大缓解抢购高峰的压力。但这背后藏着诸多棘手难题,如何精准地将库存均匀分配至各桶,杜绝分配不均;怎样有效处理拆分产生的库存碎片,避免少卖;分桶间的调度规则如何制定,确保协同高效;还有,业务量起伏不定,又该如何实现分桶动态扩容以灵活适配。这些问题也需要我们进行考虑设计。

1.2 Redis+MySQL

在秒杀系统的架构设计蓝图中,Redis 与 MQ 的组合堪称一对 "黄金搭档"。Redis 凭借其卓越的高性能读写特性以及原子操作能力可以直面秒杀活动带来的汹涌并发流量,筑起防止超卖的坚固防线。而 MQ 通过流量削峰策略,将瞬间爆发的海量数据请求进行缓冲与分流,有效减缓后端数据存储环节的压力,确保整个系统节奏平稳。

不过,不少人心中会泛起疑惑:既然 Redis 如此强大,为何不索性全程在 Redis 中完成操作,待秒杀尘埃落定,再一次性将数据同步至数据库呢?理论上看似可行,但若置于真实复杂的业务场景之中,便会破绽百出。要知道,用户成功抢购后,绝非万事大吉,后续一系列连贯操作随即而来,诸如急切地查看抢购结果,或是迅速进入支付流程等,这些都需要实时、精准的数据交互与支持。

当然,采用 Redis + MQ 这套方案并非一劳永逸,其中数据一致性问题犹如潜藏暗处的礁石,不容小觑。不过别担心,接下来我们就将针对这一方案进行详细的介绍。

1.3 Inventory Hint

大家普遍知晓高并发场景下需精心构建复杂架构应对挑战。然而,有一些公司却看似 "剑走偏锋",即便面临着不容小觑的并发压力,依旧选择直接对数据库进行操作,并未引入那些令人眼花缭乱的高并发设计体系,这难免让人满腹狐疑。

实则不然,这些公司背后有着强大的技术支撑 ------ 他们选用的是阿里的 RDS 云数据库。这款数据库绝非等闲之辈,它依托阿里的强大技术底蕴,内置了先进的 Inventory Hint 技术。凭借该技术对数据库的精准优化,使得这些公司即便在高并发的枪林弹雨中,也能让数据库稳健运行,轻松应对海量数据的读写请求。下面,咱们也会深入其中,着重揭开这项神奇技术的神秘面纱,探寻它究竟是如何赋能数据库、化解高并发难题的。

1.4 压力分摊+Redis+MQ

面对数百万 QPS 的高压冲击,多种技术方案强强联手,才能站稳脚跟。SQL 合并执行批量处理请求,削减数据库负荷。缓存阵营更是齐发力,分布式缓存广纳热点,本地缓存就近响应,近端缓存将分布式缓存与服务器 "联姻",实现超高速读取。分桶策略巧妙分流,开辟流量 "绿色通道"。各方案各司其职,分担并发压力,聚合为强大力量,确保大促活动平稳运行。

2 Redis + MQ 解决高并发下的秒杀场景

2.1 Redis库存预扣减

上面我们方案二已经介绍过该方案的整体技术架构,接下来我们进行详细的剖析。该方案我们主要是通过RedisLua脚本进行库存的扣减,这样可以保证扣减过程中的原子性和高效性。示例代码如下:

swift 复制代码
 /*
  * KEYS[1] --商品id
  * KEYS[2] --用户id uid
  * ARGV[1] --扣减数量
  * ARGV[2] --用户提交的  token
  */
String luaScript = "redis.replicate_commands()\n" +
        //防止用户是否重复提交,利用 token实现的,每次提交前会生成一个token,token更新前才可继续提交
        "if redis.call('hexists', KEYS[2], ARGV[2]) == 1 then\n" +
            //抛出用户重复提交的异常
            "return redis.error_reply('repeat submit')\n" +
        "end \n" +
        //商品id
        "local product_id = KEYS[1] \n" +
        //获取商品id对应的库存
        "local stock = redis.call('get', KEYS[1]) \n" +
        //判断库存是否充足
        "if tonumber(stock) < tonumber(ARGV[1]) then \n" +
            //购买的数量
            "return redis.error_reply('stock is not enough') \n" +
        "end \n" +
        "local remaining_stock = tonumber(stock) - tonumber(ARGV[1]) \n" +
        //更新库存
        "redis.call('set', KEYS[1], tostring(remaining_stock)) \n" +
        //获取但当前系统的时间 返回一个数组,包含2个元素 第一个元素是当前的秒数,第2个是当前这一秒已经流逝过的微秒数
        "local time = redis.call('time') \n" +
        //当前时间戳 ms
        "local currentTimeMillis = (time[1] * 1000) + math.floor(time[2] / 1000) \n" +
        //存如一条流水 {"change":"1","action":"扣减库存","from":"100","token":"token","timestamp":1735293810009,"to":99,"product":"product_id_01"}
        "redis.call('hset', KEYS[2], ARGV[2], \n" +
        "cjson.encode({action = '扣减库存', product=product_id  ,from = stock, to = remaining_stock, change = ARGV[1], token = ARGV[2], timestamp = currentTimeMillis})\n" +
        ") \n" +
        "return remaining_stock";

2.1.1 lua脚本执行流程:

涉及到的Redis数据结构以及对应存储内容:

Hash 数据结构

外层key 内层key value
KEYS[2] ARGV[2] json数据格式
uid token 流水记录

String 数据结构

key value
KEYS[1] stock
商品id 库存

2.1.2 Lua脚本主要做了几件事:

1)防重提交

在秒杀活动中用户为了能够抢到想要的商品,会进行疯狂的点击,为了防止用户重复点击提交,往往需要做一些幂等性的判断。用户在每次点击提交按钮前后端会新生成一个token,提交时携带上,后端针对token判断是否已经存在,避免重复下单。

2)库存扣减

判断购买的数据是否大于库存,如果是的话,直接返回库存。如果不是,进行库存扣减,更新Redis库存。

3)记录交易流水

很多人想不明白为啥要进行交易流水的记录,其实是为了一致性考虑的。可以依据这条流水记录去订单表中去进行查询,如果查询不到,说明订单表中未能成功生成订单,可能需要人工介入进行处理。

2.2 MySQL库存扣减

在进行数据库进行库存扣减的时候,我们是通过RocketMQ的事务消息实现的,这样做的目的是为了保证数据库库存可以扣减成功,如果数据库库存扣减失败的话,也会带来少卖问题。具体分为以下几步:

1)发送RocketMQ半消息,此时消息并不能直接消费,需要检查本地事务的执行结果。

2) 检查本地事务我们是判断Redis的Lua脚本是否执行成功,如果执行成功,则返回COMMIT给RocketMQ,如果失败,则ROLL_BACK消息。

3)RocketMQ为了防止收不到对应的本地事务执行结果会有消息回查机制,我们在消息回查中主要判断是否有对应的流水,如果存在的话,说明可以提交。

4)消费消息,进行数据库库存的扣减,同时记录对应操作流水。消费时为了保证一致性我们借助的是RocketMQ的消息重试机制,所以此处我们给MQ返回消费ACK时一定要保证我们的数据已经成功落库,否则不能随意返回。

2.3 记录操作流水的原因

我们在进行完库存扣减动作之后,对应的是下单操作,为了保证下单和库存的一致性,我们可以用定时对账机制来核对库存流水和订单表中数据是否一致。当然也有其他一致性保证方案,比如SeataTCC等,可以根据具体的业务场景选择。

3 Inventory Hint 数据库扣减

很多公司直接利用阿里云的数据库就完成了秒杀的功能,也就是我们上面介绍的方案三。上文已经提到过其底层是依赖Inventory Hint技术实现的,接下来我们介绍下Inventory Hint技术的使用以及实现原理。

3.1 使用

Inventory Hint的使用比较简单,只需要在对应的语句上加上特殊的hint语句就行了。具体可以参考阿里云文档

3.2 原理介绍

其实高并发下库存的扣减动作最后瓶颈落在了数据库单行的热更新上,Inventory Hint技术就是对热更新做了相应的优化。

当用Inventory Hint技术的hint语句标记一个SQL后,就相当于告诉MySQL内核这可能是一行热更新记录。于是,MySQL内核层就会自动识别带此类标记的更新操作,在一定的时间间隔内,将收集到的更新操作按照主键或者唯一键进行分组,这样更新相同行的操作就会被分到同一组中。

为了进一步提升性能,在实现上,使用两个执行单元。当第一个执行单元收集完毕准备提交时,第二个执行单元立即开始收集更新操作;当第二个执行单元收集完毕准备提交时,第一个执行单元已经提交完毕并开始收集新批的更新操作,两个单元不断切换,并行执行。

3.2.1 关键优化点:

1)减少行级锁的申请等待

同组更新同一记录时依 SQL 提交顺序排队,Leader 率先尝试拿目标行锁,成功即操作,Follower 拿锁前先确认,若 Leader 已得锁,Follower 可直接获取,大幅削减行级锁申请的阻塞时长。

2)减少B+树的索引遍历操作

MySQL 依 B + 索引管数据,查询常需遍历索引寻目标行,表大层级多则耗时。对热点行更新分组后,首条 SQL 定位数据存 Row Cache 并修改,后续操作直取缓存改,速减索引遍历耗时。

3)减少事务提交次数

常规多条 update 语句对应多条事务,各需单独提交。分组、排队结合组提交后,一组并发操作完,一次组提交搞定,大大精简提交次数。

4 总结

在电商秒杀的舞台上,技术方案需因 "量" 制宜。

若并发量轻柔、数据量微小,数据库单表辅以加锁策略即可,确保业务有序运转,成本低且易维护。

当业务进阶,数据量攀升、并发趋高,Redis 与 MQ 携手登场。Redis 防止超卖;MQ 缓冲请求,削峰填谷,平衡系统负载,二者联动保障高效稳定。 一旦数据海量、并发如潮,Redis + MQ 稍显吃力,压力分摊策略必须就位,如同给系统装上多重缓冲,分散高流量冲击。

数据量达巅峰时,Redis + MQ + 压力分摊还需 Inventory Hint 技术助力,深挖数据库潜能。

总之,业务多样,技术无万全之策,唯有贴合场景精挑细选、巧妙组合,才能在秒杀中稳操胜券。

关于作者

赵培龙 采货侠JAVA开发工程师

相关推荐
Asthenia04126 小时前
浏览器缓存机制深度解析:电商场景下的性能优化实践
后端
databook7 小时前
『Python底层原理』--Python对象系统探秘
后端·python
超爱吃士力架8 小时前
MySQL 中的回表是什么?
java·后端·面试
追逐时光者9 小时前
Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
后端·.net
苏三说技术9 小时前
10亿数据,如何迁移?
后端
bobz9659 小时前
openvpn 显示已经建立,但是 ping 不通
后端
customer0810 小时前
【开源免费】基于SpringBoot+Vue.JS个人博客系统(JAVA毕业设计)
java·vue.js·spring boot·后端·开源
qq_4592384910 小时前
SpringBoot整合Redis和Redision锁
spring boot·redis·后端
灰色人生qwer10 小时前
SpringBoot 项目配置日志输出
java·spring boot·后端
阿华的代码王国11 小时前
【从0做项目】Java搜索引擎(6)& 正则表达式鲨疯了&优化正文解析
java·后端·搜索引擎·正则表达式·java项目·从0到1做项目