☰
Redis秒杀高并发优化实战:库存扣减、防重限流与热点Key拆分
2026/10/27 5:07:41 网站建设 项目流程

秒杀这个场景,做过电商类系统的朋友应该都不陌生。我第一次接触秒杀需求的时候,想法很朴素:不就是把商品库存放到 Redis 里,下单前减一下,减成功了就生成订单,减失败了就提示售罄。真跑起来才发现,事情远没有这么简单——超卖、重复下单、库存扣了订单没生成、数据库被打穿、热点 Key 把单个节点压垮,这些问题会一个接一个冒出来。Redis 在秒杀业务里扮演的角色,绝不只是"一个存库存的缓存",它是整个流量漏斗里最关键的那道闸门,闸门设计得好不好,直接决定了系统在大促那一刻是稳如老狗还是全线崩盘。

这篇内容我想聊的是 Redis 在秒杀业务优化中的完整落地思路:从流量分层、库存预热、原子扣减,到防重、限流、异步落单、热点拆分,再到压测验证和线上排障。适合已经写过基础 CRUD、用过 Redis 常见命令,但没真正扛过一次高并发活动的后端同学;也适合做技术方案评审时想把细节讲清楚的人。下面这些方案和参数,一部分是我自己项目里跑过的,一部分是基于常见实践做的合理推演,涉及具体数字的地方你可以按自己的机器规格和业务量级等比调整,别照抄。

1. 秒杀业务的整体设计与流量分层思路

1.1 先想清楚秒杀到底在难什么

很多人把秒杀当成"高并发扣库存"来做,方向从第一步就偏了。秒杀的本质矛盾不是扣库存难,而是瞬时流量和系统承载能力之间的巨大落差。一个平时 QPS 几百的系统,活动开始那一秒可能涌进来几万甚至几十万请求,而这些请求里 99% 以上最终都是失败的——商品就那么多,绝大多数人注定抢不到。既然绝大多数请求注定失败,那优化的核心目标就变成了:用尽可能低的成本,把注定失败的请求挡在系统最外层,让真正能成功的那一小部分请求走到后面的下单链路。

这个认知一旦建立,整套方案的设计逻辑就顺了。Redis 之所以成为秒杀的核心组件,是因为它能在内存里用极低的耗时完成"库存判断"这个动作,一次 DECR 或 Lua 脚本执行通常在亚毫秒级,比查一次数据库快两到三个数量级。把库存判断前移到 Redis,等于在数据库前面竖了一道极快的筛子,把无效流量拦下来,数据库只需要处理真正扣减成功的少量请求。

这里有个容易被忽略的点:Redis 挡住的不仅是数据库压力,还挡住了应用服务器的线程压力。如果不做前置拦截,几万请求全部打进 Tomcat 线程池,线程上下文切换、GC 压力、连接池耗尽会连锁反应,最后连健康检查都通不过。所以流量分层的意义是全链路的,不只是数据库。

我习惯把秒杀链路画成一个漏斗,从上到下依次是:静态资源与客户端限频、网关层限流、应用层前置校验、Redis 库存原子扣减、消息队列削峰、数据库最终落单。每一层都在做"过滤",层数越多,越靠后的组件压力越小。下面这张表是我自己项目里常用的分层职责划分,你可以对照着看自己的系统缺哪一层。

层级主要职责典型手段拦下的流量占比(经验值)
客户端按钮防抖、验证码、答题前端置灰、随机延迟减少无效点击 30%~60%
网关层全局限流、黑名单令牌桶、IP 维度限流拦掉超量请求
应用层活动状态、参数校验、用户资格本地缓存、布隆过滤拦掉 80% 以上
Redis 层库存原子扣减、防重Lua 脚本、SETNX拦掉剩余绝大部分
消息队列削峰、异步落单分区顺序、重试平滑数据库写入
数据库最终一致性落单唯一索引、乐观锁只处理成功请求

1.2 为什么库存要放 Redis 而不是直接怼数据库

有人会问:数据库加个乐观锁update stock set num = num - 1 where id = ? and num > 0,不也能防超卖吗?能,但性能不行。这条 SQL 在行锁竞争激烈的时候,会引发大量线程阻塞,几万请求排队等同一行锁,数据库连接池瞬间打满,响应时间从毫秒级飙到秒级,接着触发上游超时重试,流量翻倍,彻底雪崩。

Redis 是单线程处理命令的(指命令执行主线程),天然串行,扣减库存这种操作不需要加锁就具备原子性——前提是你用对了命令。用DECR或者 Lua 脚本,一条命令下去,判断和扣减在同一个原子操作里完成,不存在并发窗口。这就是它比数据库乐观锁更适合秒杀的根本原因:不是数据库不行,是数据库的锁机制在这个场景下成本太高。

不过要提醒一句,Redis 的单线程原子性只保证单机内的命令原子,集群模式下如果库存 Key 分散在不同节点,跨 Key 的操作就没法保证原子了。所以库存 Key 通常要打上 hash tag 让它落在同一个 slot,或者干脆用单 Key 方案。这个细节后面会展开。

1.3 方案选型:几种常见库存扣减模型的取舍

实际项目里我见过三种库存扣减模型,各有适用边界,选错了会给自己挖坑。

第一种是纯 Redis 扣减 + 异步落库。Redis 里维护可售库存,Lua 脚本原子扣减成功就发消息给队列,消费者异步写订单和扣数据库库存。优点是性能极致,缺点是 Redis 和数据库之间要靠消息保证最终一致,消息丢了就出现"Redis 扣了、订单没生成"的少卖问题。适合对少卖容忍度稍高、追求极致性能的场景。

第二种是 Redis 预扣 + 数据库强一致。Redis 只做前置拦截(比如放行量是库存的 1.2 倍),真正扣减在数据库事务里用乐观锁完成,失败了再回滚 Redis。优点是数据准确,缺点是数据库还是要扛一部分并发,且有回滚逻辑要处理。适合库存少、对超卖零容忍的场景。

第三种是分段库存 + 多 Key 分散。把总库存拆成 N 份放到 N 个 Key,请求按用户 ID 哈希到某个 Key 扣减,扣完再从别的段借。这一套主要是为了解决单 Key 热点问题,后面第 4 章会详细讲。

我的建议是:中小规模活动(库存几百到几千、峰值几万 QPS)用第一种加补偿即可;金融属性强、绝对不能超卖的场景用第二种;单 Key 已经出现明显热点的用第三种。别一上来就上最复杂的方案,先跑通再优化。

1.4 库存预热:活动开始前你必须做完的事

秒杀活动开始前,库存必须已经在 Redis 里准备好,不能等第一个请求进来才去数据库加载——那一瞬间的缓存击穿会把数据库打死。预热的做法很简单:活动配置审核通过后,由一个定时任务或管理后台触发的接口,把活动库存、商品信息、限购规则写入 Redis,并设置合理的过期时间(通常比活动结束时间晚一到两个小时,留出对账和补偿的窗口)。

预热时有两个细节值得说。一是写入要用SET key value EX seconds NX这种带过期和不存在才写的语义,避免重复触发预热把已经扣减过的库存覆盖回初始值——这个坑我踩过,手动重跑预热脚本把活动进行中的库存重置了,直接导致超卖。二是预热完要做一次校验,读回来比对一下写入值和数据库配置值是否一致,避免序列化错误或者类型问题(比如把整数写成了字符串,Lua 里比较时类型不匹配)。

注意:预热接口一定要做幂等和开关控制,最好加一个人工确认步骤,或者用活动状态机约束——只有"待开始"状态才允许预热,进行中的活动直接拒绝。

1.5 活动状态机:把时间维度的判断也前置

秒杀有明确的时间窗口,活动没开始和已结束的请求根本不该走到库存扣减。这些判断放在应用层用本地缓存做最划算:活动配置在启动时或定时刷新到本地 Map,请求进来先看当前时间是否在窗口内、活动是否被手动下架,不符合直接返回。本地缓存的好处是零网络开销,几万 QPS 打过来也只是内存比较。

状态机我一般设计成:待审核 → 待开始 → 进行中 → 已结束 → 已结算。只有"进行中"允许下单,"待开始"允许预热,"已结束"允许对账。状态流转由后台操作触发,同时广播到各应用实例刷新本地缓存。这样即使 Redis 里还有残留库存,活动结束后也不会被继续扣。

2. 核心细节解析:原子扣减、防重与限流

2.1 Lua 脚本实现原子的"判断 + 扣减 + 记录"

单纯用DECR有个问题:它只减不判断,减到负数了你还得再补一次INCR回滚,中间这一小段就是超卖窗口。正确做法是用 Lua 脚本把"读库存、判断是否大于 0、扣减、记录购买用户"打包成一个原子操作。Redis 执行 Lua 是单线程的,脚本内部不会被其他命令打断,天然满足原子性。

下面这个脚本是我项目里用的简化版,包含库存判断、用户防重、扣减三个动作:

-- KEYS[1] 库存 key, KEYS[2] 已购用户集合 key -- ARGV[1] 用户 id, ARGV[2] 本次购买数量 local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil then return -1 -- 库存 key 不存在,未预热 end if stock < tonumber(ARGV[2]) then return 0 -- 库存不足 end if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return -2 -- 该用户已购买过 end redis.call('DECRBY', KEYS[1], ARGV[2]) redis.call('SADD', KEYS[2], ARGV[1]) return 1 -- 扣减成功

返回码设计很重要,-1 表示未预热、0 表示售罄、-2 表示重复购买、1 表示成功,调用方根据返回码给不同提示,也方便打点统计各分支的命中量。实测下来这个脚本在单机上执行耗时在 0.1~0.3 毫秒,一次活动峰值几万 QPS,靠它拦掉 99% 的请求毫无压力。

几个必须注意的点:脚本里所有的 Key 都要通过 KEYS 数组传入,不要在脚本里拼接 Key 名,否则集群模式下会报错;脚本要尽量短,因为 Redis 执行 Lua 期间会阻塞其他命令,脚本太长会影响整体吞吐;tonumber转换不能省,Redis 里 GET 出来是字符串,直接和数字比较在某些版本下行为不一致。

2.2 防重的三个层次,别只靠一层

重复下单是秒杀里投诉率最高的问题之一。用户在客户端疯狂点,或者网络重试导致同一个请求发了两次,如果不做防重,一个用户可能扣两次库存。我一般做三层防护。

第一层是客户端:按钮点击后立即置灰,加一个短时间的防抖(比如 500 毫秒),同时前端生成一个一次性的请求令牌(token),随请求带上。这一层能挡掉大部分误触,但不能信任,因为客户端可以绕过。

第二层是 Redis 层的用户维度去重,就是上面 Lua 脚本里的SISMEMBER判断。这里有个选型问题:用 Set 还是用 String 的 SETNX?Set 的好处是能顺便统计已购人数,坏处是活动大了以后这个 Set 会很大,几百万用户就是几百万成员,内存占用可观。我的做法是活动结束后清理,或者按用户 ID 取模分片成多个 Set。如果只需要防重不需要统计,用SET key:userId 1 NX EX 3600更省内存,每个用户一个短过期的小 Key。

第三层是数据库唯一索引兜底:订单表上建(activity_id, user_id)的唯一索引,即使前面两层都漏了,数据库插入时也会报唯一键冲突,捕获后返回"已参与"。这是最后一道保险,绝对不能省——我见过 Redis 里的防重 Key 因为过期时间设太短(设了 5 分钟,但消息消费延迟了 10 分钟)导致防重失效,最后靠数据库唯一索引拦住的案例。

注意:三层防重的 Key 过期时间要大于整个链路的处理时长,宁可设长一点事后清理,也不要设短了中途失效。经验值是活动时长加上 2 小时。

2.3 限流应该放在哪一层,参数怎么定

限流的目的不是精确控制,而是在流量超过预期时保住系统不崩。我习惯在网关层做全局令牌桶限流,桶容量按压测得出的系统最大承载的 70%~80% 设定,比如压测出单机最大处理 5000 QPS,三台机器就是 15000,那限流阈值设 11000~12000,留出突发余量。

限流的粒度也值得琢磨。按 IP 限流能挡掉脚本刷单,但容易误伤 NAT 出口后的正常用户;按用户 ID 限流更精准,但需要先解析出用户身份;按活动维度限流是必须的,防止单个活动打垮全局。我一般组合使用:活动维度总量限流兜底,用户维度做频率限制(比如每个用户每秒最多 5 次),IP 维度只对异常 IP 做黑名单。

限流被触发时返回什么也有讲究。直接返回 429 或者错误提示会让用户以为系统挂了,体验很差。我一般返回一个"当前参与人数过多,请稍后再试"的友好提示,同时在响应头里带上Retry-After,前端据此做倒计时。别小看这些细节,活动当天客服压力能小一半。

2.4 库存 Key 的过期与清理策略

库存 Key 不能永久存在,否则内存会被历史活动堆满。过期时间我一般设成活动结束时间加 2 到 4 小时,这个窗口要覆盖对账和补偿的时间。但对账要用的数据(比如最终售出数量、已购用户集合)不能只靠 Redis,必须在数据库里有落地,否则 Key 一过期数据就没了。

清理策略上,用户防重的 Set 是内存大户,活动结束后应该主动清理而不是等过期。我一般在对账任务跑完后触发清理,或者干脆给每个活动的 Key 加上统一前缀,用 SCAN 批量删(注意不要用 KEYS 命令,会阻塞)。这里提醒一句:SCAN 删除大量 Key 也会占用 CPU,建议分批、限速,放在低峰期做。

3. 完整实操流程:从预热到落单的实现细节

3.1 环境与数据结构的准备

先把数据结构定清楚,后面所有代码都依赖它。我项目里的 Key 规划如下表,前缀统一用业务名加活动 ID,方便排查和批量操作。

Key 模式类型内容过期时间
sk:stock:{actId}String剩余可售库存活动结束 + 2h
sk:users:{actId}Set已购用户 ID活动结束 + 2h
sk:order:{actId}:{userId}String下单令牌/订单号活动结束 + 2h
sk:limit:{actId}:{userId}String用户请求频率计数10s
sk:goods:{actId}Hash商品快照信息活动结束 + 2h

注意sk:stock:{actId}这种带大括号的写法是 Redis Cluster 的 hash tag 语法,保证同一个活动的库存和用户集合落在同一个 slot,Lua 脚本操作两个 Key 时才不会跨 slot 报错。这一点在单机版测试时不会暴露,一上集群就炸,必须提前注意。

数据来源是数据库里的活动配置表和商品表,预热时组装好写入。商品快照用 Hash 存,字段包括价格、名称、限购数量等,下单时从 Redis 读快照而不是查数据库,进一步减少数据库访问。

3.2 预热脚本的实现与校验

预热我一般写成一个独立的定时任务,活动开始前 10 分钟触发,逻辑是:查数据库拿到活动配置和库存 → 组装数据 → 用SET ... NX写入 → 读回校验 → 记录日志和监控打点。

# 简化的预热命令示例(实际用代码执行) SET sk:stock:{act1001} 500 EX 10800 NX HSET sk:goods:{act1001} name "测试商品" price 99 limit 1

校验逻辑不能省:写入后GET回来,和数据库的值比对,不一致就告警并中止活动上线。我遇到过序列化框架把Long写成带引号的字符串,导致 Lua 里tonumber拿到 nil 的情况,如果没有校验,这个错误会一直潜伏到活动开始才爆发。

预热完成后还要做一次"冒烟验证":用一个测试账号走一遍完整下单流程,确认库存能正常扣减、订单能正常生成、防重能正常生效。这个验证建议在预发环境做,别在生产环境用真实库存试。

3.3 下单接口的完整执行链路

用户点击抢购后,请求依次经过这些环节,我在每个环节标注了失败时的处理和耗时预期:

  1. 网关层:限流校验,超限返回友好提示。耗时预期小于 1 毫秒。
  2. 应用层活动校验:从本地缓存读活动状态,非"进行中"直接拒绝;校验用户登录态和参数合法性。耗时小于 1 毫秒。
  3. 频率限制:INCR sk:limit:{actId}:{userId},配合EXPIRE设 10 秒过期,超过阈值拒绝。这里要注意 INCR 和 EXPIRE 不是原子的,最好也用 Lua 包一下,或者接受这个小瑕疵(最坏情况是计数 Key 没有过期时间,可接受,因为量不大)。耗时 1 毫秒左右。
  4. Lua 原子扣减:执行前面那个脚本,根据返回码分支。耗时 0.1~0.3 毫秒。
  5. 生成订单号并入队:扣减成功后生成全局唯一订单号(雪花算法或 Redis INCR),把订单消息发到消息队列,立即返回"抢购成功,正在处理"。耗时 1~2 毫秒。
  6. 异步消费者:从队列取消息,写订单表、扣数据库库存、更新用户订单列表。这一环在后台跑,不影响用户响应。

整条同步链路的总耗时控制在 5 毫秒以内,这是我认为比较舒服的体验阈值。超过 10 毫秒用户会有明显卡顿感,也会拉长线程占用时间,降低整体吞吐。

这里有个关键决策:扣减成功但入队失败怎么办?我的处理是入队失败时立即回滚 Redis 库存(用INCRBY加回去并SREM移除用户),然后返回失败让用户重试。回滚虽然不优雅,但这个窗口极小(入队失败率通常很低),比让用户以为抢到了实际没订单要好。更严谨的做法是把"扣减 + 入队"做成一个本地事务消息或者用可靠消息表,但复杂度上升明显,中小项目用回滚兜底就够了。

3.4 消息队列削峰与落单消费者的设计

队列这一层的作用是把瞬时的高并发写请求拉平成长时间的匀速写入。峰值几万 QPS 的扣减成功请求可能只有几百到几千(取决于库存量),如果库存是 1000,那最多 1000 条消息,消费者慢慢处理就行,数据库压力完全可控。

消费者的实现要注意几点。一是幂等:消息可能重复投递,消费者要能识别并跳过已处理的订单,用订单号做唯一键或者查库判断是否已存在。二是失败重试:写库失败要重试,但要设最大重试次数,超过后进死信队列并告警,不能无限重试堵住队列。三是顺序性:如果同一个用户的多次请求可能乱序到达,需要保证"先扣减先成单",通常按用户 ID 分区发送就能解决。

数据库落单这一步,扣库存要用带条件的更新:update stock set num = num - ? where id = ? and num >= ?,影响行数为 0 说明数据库库存不够(正常情况下不该发生,发生了说明 Redis 和数据库不一致,要告警排查)。订单插入依赖唯一索引防重。

3.5 库存回补与对账机制

活动结束后必须对账,核对三个数字:Redis 初始库存、Redis 实际扣减量、数据库实际成单量。正常情况下三者应该满足"初始 = 剩余 + 扣减"、"扣减 ≈ 成单量"。不一致的地方就是需要人工介入的异常,常见的有消息丢失导致的少卖、消费者重复处理导致的超卖。

对账我一般做成一个离线任务,活动结束后跑一次,输出差异明细。差异处理原则是:少卖的库存可以回补给数据库(让用户能买到),超卖的订单不能简单取消(用户会投诉),要人工评估。所以前面强调的数据库唯一索引和条件更新非常关键,它们是防止超卖的最后防线。

4. 常见问题排查与避坑实录

4.1 超卖了,第一时间查什么

超卖排查我有一套固定顺序,最快能定位到根因。先查是不是 Lua 脚本没生效或者被绕过——比如有老代码路径直接调DECR,或者某个入口没走脚本。再查集群模式下库存 Key 和用户 Key 是否落在不同 slot,跨 slot 的脚本执行会报错,如果代码里捕获了异常又继续走了后面的流程,就会出现"脚本没扣但订单生成了"的假象。

然后查回滚逻辑:扣减成功、入队失败、回滚时只加了库存但没移除用户,或者加库存的量超过了实际扣减量(比如重试导致回滚两次),都会造成库存虚高。这种情况要靠日志里的订单号和扣减记录对账。

最后查预热:有没有重复预热把进行中的库存重置了,这个我前面提过,是真实发生过的坑。

现象可能原因排查手段处理方式
售出量大于库存库存被重置/回滚超量查预热日志、回滚日志立即下架活动,人工核账
同一用户多单防重 Key 过期/未生效查 Set 内容和过期时间取消多余订单,补 Key 过期时间
库存扣了没订单消息丢失/消费失败查队列堆积和死信从死信重投,或按扣减记录补单
库存没扣但订单有脚本执行失败被吞异常查脚本错误日志补扣库存或作废订单
活动结束仍能下单本地缓存未刷新/Key 未过期查实例缓存时间和 Key TTL强制刷新缓存,缩短 TTL

4.2 库存明明有却提示售罄

这类问题多半和类型或者数据格式有关。Redis 里存的库存如果是字符串"500",Lua 里没做tonumber直接比较,在某些边界值上会出问题。还有一种情况是预热写入了,但写到了错误的 Key(活动 ID 拼错、前缀不一致),导致脚本读的 Key 根本不存在,返回 -1 被当成售罄处理。

另一种隐蔽情况是负数库存:回滚逻辑加多了,或者某次DECRBY减的数量比实际库存大,库存变成负数,后续正常请求看到负库存直接拒绝。我的经验是在脚本里加一个"库存不能减到负数"的硬判断,同时监控库存为负的告警。

4.3 大促当天 Redis 变慢甚至超时

Redis 变慢通常有三个来源。一是大 Key,比如把已购用户集合做成一个几百万成员的 Set,SISMEMBER虽然快,但整个 Key 的迁移、过期删除、持久化都会拖慢实例。解决办法是分片,按用户 ID 取模拆成多个小 Set。

二是慢查询,比如误用了KEYS、SMEMBERS这种 O(N) 命令,或者 Lua 脚本里有循环。上生产前一定要开慢查询日志(slowlog-log-slower-than设成 10000 微秒,也就是 10 毫秒),定时检查。

三是网络和连接数:应用侧连接池没配好,每次请求新建连接,光是握手就吃掉大量时间。连接池大小按"平均耗时 × QPS"估算,比如单次操作 1 毫秒、峰值 5000 QPS,理论上需要 5 个连接,但考虑突发和阻塞,我一般配 50 到 100 个,加上超时和熔断。

4.4 消息队列堆积了怎么办

堆积说明消费速度跟不上生产速度。先看消费者是不是卡在某个少数派消息上(比如某条消息一直重试失败阻塞了分区),再看单条消费的耗时是不是太长(比如消费者里查了数据库还做了远程调用)。临时扩容消费者实例是最直接的手段,但前提是分区数够,分区数不够要先扩分区。

预防性措施是:消费者逻辑尽量轻,重活异步化;监控队列的堆积深度和生产消费速率差;设合理的批处理大小,批量拉取减少网络往返。

4.5 我踩过的几个具体坑

说几个印象深刻的。第一个是防重 Set 没设过期,活动跑了三个月,那个 Set 涨到几百万成员,一次过期删除直接把实例 CPU 打满。教训是所有临时 Key 必须有 TTL,且大 Key 要分片。

第二个是回滚逻辑写成"扣减失败也回滚",把没扣成功的请求又加了一次库存,导致活动刚开始库存就变多了。教训是回滚只在"扣减成功但后续失败"的分支执行,且要严格依赖扣减返回值。

第三个是本地缓存的活动状态没刷新,活动已经开始五分钟了,部分实例还认为是"待开始",用户看到的是活动未开始。教训是状态变更要有广播机制,且本地缓存设一个较短的 TTL(比如 30 秒)做兜底。

这些坑的共同点是:都不是 Redis 本身的问题,而是使用方式的问题。Redis 的语义很明确,出问题的永远是我们对它的假设。

5. 热点 Key 拆分与性能压测验证

5.1 单 Key 热点的成因与拆分方案

秒杀场景下所有请求都打同一个库存 Key,这个 Key 所在的那个 Redis 节点会承受全部压力,其他节点闲着。单节点能力再强也有上限,单 Key 的 QPS 瓶颈通常在几万到十几万之间(取决于命令复杂度和机器规格),超过就会成为瓶颈。

拆分方案是分段库存:把总库存 1000 拆成 10 段,每段 100,请求进来根据用户 ID 哈希到某一段扣减,这一段扣完了再尝试其他段。好处是压力分散到多个 Key,理论上可以线性扩展;坏处是会出现"某段空了但其他段还有"的情况,需要借库存或者让用户重试别的段。

实现上我一般这样做:分段 Key 用sk:stock:{actId}:{segNo},段数按预估峰值除以单 Key 承载能力来定,比如预估 10 万 QPS、单 Key 稳跑 2 万,那就分 5 到 8 段留余量。Lua 脚本里先试本段,本段不足则遍历其他段(段数少时遍历成本可接受)。借库存要有原子性,避免多段同时借超。

代价是复杂度上升,库存统计变麻烦(要汇总所有段),对账逻辑也要相应调整。所以我的建议是:单 Key 能扛住就别拆,先压测出真实瓶颈再决定。

5.2 压测怎么做才有参考价值

压测最容易犯的错是压测环境和生产差太远,或者压测数据不真实。我的做法是:压测环境尽量和生产同规格(至少同量级),压测前把 Redis 预热到和真实活动一样的库存量,压测脚本模拟真实用户行为(不是同一个用户 ID 猛刷,要模拟多用户、带随机间隔、带完整的登录态)。

压测指标要看全:QPS、P99 延迟、错误率、Redis 的 CPU 和内存、应用侧的线程池活跃数、数据库连接池使用率、队列堆积深度。只看 QPS 没意义,P99 延迟才是用户真实感受。

压测分两轮:第一轮阶梯加压找拐点,从 1000 QPS 开始,每轮加 1000,观察哪个点开始延迟陡增或者错误率上升,那就是系统的真实承载上限;第二轮按预估峰值的 1.5 倍做稳定性压测,跑 10 到 30 分钟,看有没有内存泄漏、连接泄漏、慢慢变慢的问题。

压测中常见的假象也要提防:施压机自身成为瓶颈(CPU 打满、端口耗尽),导致压测结果偏低;网络带宽打满;压测没有走完整链路(比如绕过了网关),结果虚高。压测时同步监控施压机的资源,别只盯着被测系统。

5.3 监控指标与告警配置

线上要有几个必看的指标,我按重要性排:Redis 库存 Key 的剩余量(实时曲线,能看出扣减速率是否符合预期)、Lua 脚本各返回码的计数(售罄率、重复率突增要告警)、队列堆积深度、消费者处理耗时、数据库订单写入成功率和耗时、Redis 实例的 CPU 和慢查询数。

告警阈值我一般这样设:库存低于 10% 提醒运营准备补货或者下架;脚本返回 -1(未预热)出现任何一次立即告警;队列堆积超过 1 万条告警;消费者 P99 超过 1 秒告警。阈值要结合业务量级调整,别照抄。

光有监控还不够,要定期做故障演练:手动杀掉一个 Redis 节点看主从切换期间业务表现,手动让消费者停止看队列堆积告警是否触发,这些演练能在真实故障时救你一命。

6. 几个容易被忽略的设计权衡

6.1 少卖和超卖,你更怕哪个

这是个业务决策问题,不是技术问题。超卖意味着承诺了没有的货,要赔付或者道歉,用户体验伤害大;少卖意味着有货没卖出去,损失的是收入。大多数电商场景宁可少卖不可超卖,所以方案偏向严格。但有些清仓场景对超卖容忍度高,可以放宽,换取更高的吞吐和更简单的链路。

这个权衡要在方案设计阶段和业务方确认清楚,因为它决定了你选哪种扣减模型、要不要做数据库强一致、回滚逻辑怎么写。

6.2 Redis 挂了怎么办

秒杀这种强依赖 Redis 的场景,必须在设计时想清楚 Redis 不可用时的降级策略。常见做法是:Redis 不可用时直接快速失败,返回"系统繁忙",而不是降级去查数据库——因为数据库根本扛不住这个流量,降级只会让它死得更彻底。快速失败保住系统,等 Redis 恢复再继续。

同时要有主从和哨兵或者集群模式保证高可用,主从切换期间会有短暂的写入失败,客户端要能重试或者优雅降级。切换时间通常在几秒到几十秒,这个窗口内活动可以考虑暂停入口,避免大量失败请求。

6.3 数据一致性的最后一道防线

不管 Redis 层做得多严密,最后一道防线永远是数据库:唯一索引防重复订单,条件更新防超卖。任何绕过数据库约束的优化都是危险的。我在方案评审时有个习惯:先问"如果 Redis 全错了,数据库能不能兜住",能兜住再谈性能优化,兜不住先补数据库约束。

这套东西说到底,Redis 是矛,帮你把性能做到极致;数据库约束是盾,保证最坏情况下数据不出错。两者缺一不可,只谈性能不谈兜底,早晚要还债。

我个人在实际操作中的体会是,秒杀优化最难的从来不是写那段 Lua 脚本,而是把所有边界情况想全:预热会不会重、消息会不会丢、回滚会不会多、缓存会不会旧、集群会不会跨 slot。这些细节没有一个是 Redis 命令层面的问题,全是工程层面的严谨性。每次活动前把清单过一遍,比事后救火轻松得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询