四年、十次告警、数十个技术决策:一个海外电商平台的高并发治理实录
作者:vivo 互联网服务器团队- Hang Yu
这是一篇海外电商平台近四年线上性能治理的真实复盘。从第一次大促扩容部署失败、Redis 6 节点全线灾难告警,到黄牛单账号上亿请求打垮系统、热 key 将单个 Redis 节点 CPU 压至 99.97%——每一次告警背后,都隐藏着一个更深的架构问题。文章按问题类型分四条线展开:扩容方向的演进、Redis 配置的四轮迭代、hgetall 大 hash 热 key 的三阶段根治、以及黄牛防控从 WAF 封禁到接口逻辑加固的完整过程,完整还原每次排查的推理链路和最终解法。
1分钟看图掌握核心要点👇

写在前面:带着这个框架读下去
在看具体案例之前,先抛出四年下来最核心的一个判断:线上性能问题从来不是单点问题,每一次告警的直接现象背后,都隐藏着一个更深的架构或设计缺陷。
Redis 内存满,不只是"内存不够大",背后是 9MB 的大 key 和 Session 混用;接口超时,不只是"网络抖动",背后是 Pipeline + 连接池配置在高并发下的数学必然;CPU 打爆,不只是"流量太大",背后是 hgetall 800 个字段的热 key 在 Redis Cluster 里物理上无法分散的压力。
带着这个判断去读后面的四类案例,你会发现每次处置都经历了同一个模式:临时手段止血 → 追问为什么止血没有根治 → 定位深层根因 → 结构性修正。止血和根治缺一不可,但团队往往只做到了止血。文章覆盖四类问题:扩容方向的演进、Redis 配置的四轮迭代、热 key 的三阶段根治、黄牛防控从 WAF 到接口加固的完整过程。每类问题都跨越了不止一年,正是因为第一次没有根治,才会在下一次活动以更剧烈的形式复发。
一、扩容方向:从手忙脚乱到预案驱动
1.1 第一次崩盘:扩容部署失败,流量白白打来又白白打走
背景
某年初,某国家新品首销活动。这是海外电商平台第一次真正意义上的高流量考验。
事故经过
活动第一天(周六):
- 下午 5 点起间断收到告警,看监控"没问题";
- 不到 6 点告警再次触发,联系运维,运维反馈"服务端机器运行正常";
- 发现 Node 压力大,开始扩容部署——扩容部署直接失败;
- 晚上 8 点半流量自然回落,访问才恢复正常。第一天就这么惨淡地结束了。
活动第二天(周日,更严峻):
- 下午 5 点流量开始攀升,可用性下降;
- 前端已增加 2 台 Node,再申请共扩至 20 台,陆续部署上线后,可用性没有提升,反而继续下降(Node 太多、状态不一致,反而成了问题);
- 晚 6 点半收到 Redis 内存不足告警,告警被其他告警淹没,未及时处理,直到近 8 点扩容完成才恢复;
- 晚 6 点 40 分收到主机 CPU 负载告警,联系运维追加 Tomcat 机器;
- 晚 7 点服务端机器部署完成,可用性开始回升;
- 晚 7 点半剔除异常 Node,可用性小幅回落,重新加回后才最终恢复。
最终处置:Node 扩至 20 台、Tomcat 扩至 8 台、Redis 临时扩至 5G(次日扩至 8G)。
事后反思
这次暴露了三个基础性缺陷:
- 扩容流程不成熟,部署失败没有回滚机制,扩容反而加剧混乱;
- Redis 内存告警被淹没,监控体系不完善;
- 没有 CDN,静态资源全压在 Node 上。
事后:规范了活动流量报备制度;确定了 CDN 方案(单厂商按区域配置回源);梳理了 nginx 按国家路由的流量隔离方案。
1.2 板球赛日:全集群灾难级告警,Redis 命悬一线
背景
某年年底,某国家板球赛日活动,流量再次飙升。
告警原文
【监控系统-redis实例告警-首次告警】
集群名称: exshop-admin-xxx
异常对象(当前值):
node-1(95.1389, 95.4053, 95.8176)
node-2(95.0581, 95.2839, 95.4957)
...(6 个节点全部异常)
检测规则: redis.instance.used_memory_per: 严重异常(>85), 灾难异常(>95)
异常级别: 灾难
异常比例: 100% (6/6)
6 个 Redis 节点全部达到灾难级内存使用率,最高 95.8%。这意味着 Redis 随时可能因内存耗尽开始 OOM 淘汰 key,引发大面积缓存失效,进而打穿数据库。
连锁反应
- 大量 502 错误和 Broken pipe;
- 重启 WAP 机器后访问商详页依然报错:ClientAbortException: java.io.IOException;
- MySQL 响应时间小幅上升,整体在压力边缘;
- 同期某款旗舰新品发布,同样流量下接口响应达到数秒,与内销相比差距触目惊心。
处置
- Redis 内存翻倍扩容(8G→12G);
- Tomcat 扩容至 12 台;
- CDN 配置生效;
- 修复 Redis 连接池配置:maxTotal 调大(详见第二章);
- 删除后台服务中多余的 scan 命令(详见第二章)。
新发现:排查过程中首次发现大 key——商品属性 Hash 缓存达 9MB、地址缓存达 9.7MB,列入待优化清单(直到两年后才彻底根治)。
1.3 极限压测:36 台机器扛下 QPS 16500,该国家流量天花板
背景
某年年底,某国家大促活动,三天内流量分三波持续拉升。
数据

内销某款旗舰发布高峰 1066k/min,但持续时间极短,很快回落;而海外该国家这次流量持续时间极长,服务可用性缓慢下降,最终在 87% 左右长时间徘徊,这种形态比内销的脉冲式更难处理。
操作过程
提前预判到风险,活动前在限流平台配置了限流开关。第一波来袭,立刻追加 12 台至 24 台;第三波再次突破,继续扩至 36 台,同时开启降级兜底,终于扛住。
尴尬的发现
活动结束后看转化数据:商详页点击率仅 1.25%,有流量无转化。大量流量是刷屏式点击或机器人,并非真实购买意图,这 36 台机器扛的更多是"无效流量",最终引出了黄牛防控专项。
二、Redis 配置优化:四次迭代,从扩容到根治
2.1 连接池配置:maxTotal 过小打爆的惨痛教训
问题
板球赛日活动中,Redis 内存告警还没处理完,又发现另一个问题:连接池耗尽。
连接池 maxTotal 的默认值极小,高并发场景下连接全部被占用,新请求无法拿到连接,一直等到超时报错:
JedisConnectionException: Could not get a resource from the pool
修复:将 maxTotal 调大至 100,后续高峰未再出现连接耗尽。
2.2 scan 命令:一个"无害"的后台操作,差点让 Redis 崩溃
问题发现(第一次)
排查板球赛日告警时,在消息消费服务中发现定时执行的 scan 命令——用于清除商品列表页的缓存 key。
这个操作看起来人畜无害:不就是清个缓存吗?但 SCAN 虽然是游标式的分批遍历、单次调用不会阻塞整个 Redis(这也是它替代 KEYS 命令的设计初衷),问题在于 Redis 处理命令是单线程串行的——一旦并发量上去(如后面提到的均值 80、峰值 300 并发),大量 SCAN 请求会挤占同一条处理队列,跟业务请求相互排队,拖慢整体响应速度。
更重要的是:这个操作根本不必要,商品列表页缓存就算晚几分钟清除,用户完全感知不到差异。
处置:删除消息消费服务中多余的 scan 调用。
问题再次爆发(第二次)
以为 scan 已经处理干净,但次年 push 活动中 Redis 再次超时,检查发现:
redis scan 命令均值并发:80
redis scan 命令峰值并发:30
原来后台还有另一个触发路径:降级开关打开时,会触发后台批量清缓存的 scan 操作。300 并发的 scan 把 Redis 打得动弹不得,整个服务响应奇慢无比。
处置:降级时同步屏蔽后台 scan 触发逻辑——这类清缓存操作在降级场景下完全可以跳过。
2.3 Session Redis 隔离:一个最"意想不到"的性能杀手
背景
某次 push 活动,处理完 scan 问题后,Redis 超时告警依然没有消除,大量超时继续在日志中涌现。
仔细分析超时的 key pattern:
spring:session:sessions:*
spring:session:expirations:*
这些全是 Spring Session 的 key!
原来,商城使用 Spring Session 将用户会话存储在 Redis 中,而 Session Redis 和业务 Redis 共用同一个集群。在流量高峰期:
- 大量用户请求 → 大量 Session 读写
- 业务接口也在大量读写缓存
- 两者共享连接池,互相抢占
- Session 的大量写操作(每次请求都可能更新 session 过期时间)消耗了本应用于业务的连接和内存
这就解释了一个令人困惑的现象:关掉了 scan、加大了连接池,Redis ops/sec 并不高,但接口还是在超时——因为连接根本没到 Redis 那里,在本地连接池就已经排队等超时了。
处置
- 物理隔离:Session Redis 与业务 Redis 分开部署,各自独立实例,彻底断开耦合;
- 本地缓存失效时间延长至 7200s,减少缓存过期后大量回源 Redis 的压力;
- 限流配置上线,防止业务层接口被打穿;
- 非必要请求不主动创建 Session(减少 session key 膨胀速度)。
效果:上线后 Redis key 数量恢复正常,接口访问趋于平稳,持续无超时告警。
2.4 Pipeline 连接池超时:Redis 负载不高,为什么还是超时?
背景
近期线上偶发 Redis 读取超时告警,频率不高但持续存在,让人找不到规律。
告警错误
JedisConnectionException: Read timed out
不是连接建立失败,而是发出请求后等待响应超时。
排查过程
查看 Redis 监控:
- 正常基线:约 500 ops/s;
- 告警时峰值:约 2500 ops/s;
- 整体负载不高,不是 OOM,也不是 CPU 打满的典型表现。
奇怪:Redis 负载那么轻,为什么会超时?
深入分析调用日志,发现超时集中在 getProduct
SkuDtoListFromCache,该方法使用 Pipeline 批量读取 8 个规格的促销数据。
定位到根本原因:是应用侧连接池的问题,不是 Redis 本身的问题。
推导链路:
多个用户同时查商品列表(高并发瞬间)
→ 每个请求走 Pipeline 批量读 8 个 key
→ 每个 Pipeline 操作占用连接时间较长(需要等 8 个 key 的响应全部回来)
→ 50 个连接瞬间全被占满(maxTotal=50 太小)
→ 新请求等待连接,maxWaitMillis=1000ms 太短
→ 等了 1 秒还没拿到连接 → 抛出 Read timed out
这完美解释了"偶发、集中在某几个时间点"的现象——平时并发低没问题,流量小高峰的几秒内连接池瞬间打满。
处置:按 Tomcat 线程数和 Redis 操作占比重新计算,调整连接池参数:
# 单机 Tomcat 线程数 200,Redis 操作占比约 60%,按部署节点数分摊
xxx.cache.depend.common.poolConfig.maxTotal=200
# Pipeline 本身耗时比单次操作长,等待时间需要更宽松
xxx.cache.depend.common.poolConfig.maxWaitMillis=3000
# 保持最小空闲连接,避免每次都新建连接
xxx.cache.depend.common.poolConfig.minIdle=10
xxx.cache.depend.common.poolConfig.maxIdle=50
如下图所示:

同时建议:对促销价格数据加 L1 本地缓存(5-10s TTL),热点规格的高频读请求在本地命中,不再每次都走 Redis Pipeline。
三、代码层性能优化:热 key 的终极根治
热 key 爆炸:一个 Hash 800 多个字段,击垮了整个 Redis 节点
背景
某次某国家 push 活动,流量突增。
告警原文
【监控系统-redis实例告警-首次告警】
集群名称: exshop-admin-prd-xxx
异常对象(当前值): node-1(99.97)
检测规则: redis.instance.cpu.usage: 严重异常(>95), 灾难异常(>=99)
异常级别: 灾难
异常比例: 16.6667% (1/6)
6 个 Redis 节点中的 1 个 CPU 达到 99.97%,已经是灾难级别。其他 5 个节点正常,说明这是热 key 的典型特征——大量请求集中打到同一个节点,把那个节点的 CPU 打爆,其他节点闲着。
定位过程
先用降级手段临时止血:打开降级开关,非核心业务不展示,那台热点节点 CPU 有所下降,但预约等业务也一起被屏蔽,体验折损明显,只是临时方案。
次日同样的问题再次出现,开始认真定位:
- 流量分析平台查哪个页面访问量高;
- 应用监控查调用链,定位到热点商品(关联了预约活动的新品);
- Redis 热点 dump 日志确认:hot key 集中在 product-sku-promotion:{spuId} 系列。
确认后发现:这个 key 的 field 数量达到了 800+
根因
商品活动缓存的设计问题:将一个商品下所有规格的促销信息全部存储在同一个 Hash key 中(field = 规格ID,value = 促销数据):
product-sku-promotion:{spuId}
field: skuId_8280 → 促销信息 JSON
field: skuId_8281 → 促销信息 JSON
...(800+ 个 field)
每次查询调用 hgetall 全量拉取整个 hash——即便只需要其中 1 个规格的数据,也要把 800 多个字段全部读出来。
热点商品(如正在做活动的新品)被大量用户同时访问,所有请求都打向存储该商品 hash 的同一个 Redis 节点,在 Redis Cluster 架构下无法通过扩容分散这个压力,那个节点 CPU 直接被压垮。
三阶段根治方案
阶段一:数据结构改造(立刻见效)
将"一个商品 → 一个大 hash"的设计改为"每个规格 → 独立 String key":
改造前:hgetall product-sku-promotion:{spuId} # 读 800+ 字段
改造后:mget product-sku-promotion:{skuId_1}
product-sku-promotion:{skuId_2}
... # 按需批量读
同时优化序列化方式,降低单次读写耗时。
效果:Redis CPU 骤降,接口响应时间无突刺。
阶段二:商详页场景精简(从源头减少请求量)
改造前:进入商详页就请求该商品所有规格的促销数据; 改造后:进入商详页只返回默认规格的促销数据,用户切换规格时才按需查询当前规格。
效果:大幅减少每次商详页请求的 Redis 调用次数,降低数据传输量。
阶段三:本地热点缓存(最终防线)
接入热点缓存组件,在进程内维护 L1 本地缓存(短 TTL):热点商品的促销数据在本地命中,完全不需要到达 Redis。
如下图所示:

上线效果
- WAP 端超时告警:消除
- APP 熔断告警:消除
- Redis 响应时间:稳定,无突刺
- 商品接口响应时间:明显降低,间接降低了 APP 服务的压力
四、黄牛防控:从"封了没用"到"釜底抽薪"
某国家黄牛刷单:单账号上亿请求,CPU 被打到报废
背景
某次活动,日志告警突然涌现,监控显示某国家商城被攻击了。
问题规模
随手搜了一个账号的请求日志——请求量达到上亿。这还只是其中一个账户,整个攻击规模可想而知。
CPU 频繁告警:
【监控系统-主机告警】
异常对象(当前值): node-x(1.795)
检测规则: CPU.SERVER.LOADAVG.PERCORE值_prd: 严重异常(>2)
异常级别: 严重
Redis、MySQL、账号系统的请求量全线大幅上升。
攻击特征
目标接口:/api/order/confirm(下单确认接口)
这个接口正常每分钟请求量约 400 次。黄牛绕过了前端的商品校验,直接调后端接口,反复提交下单请求。由于接口缺少对下架商品和无库存商品的前置校验,每一次请求都会走完整的下单链路:查商品信息、查库存、查用户信息、查账单地址……即便最终因库存不足失败,已经浪费了大量数据库和 Redis 查询。
处置过程(充满波折)
第一波:WAF 封禁(无效)
发现攻击后立刻联系 WAF 配置封禁规则——封了一些 IP,但请求量没有下降。
原因排查:WAF 读的是请求头中的第二个客户端 IP,但同事写 WAF 规则时路径配置错误,根本没有拦截到正确的请求路径。修正后,重新配置封禁 15 个 IP 段,流量立马下降不少。
第二波:IP 封禁见顶,换策略(账号黑名单)
但黄牛换 IP 成本极低,IP 封禁效果有时效。且这次攻击"没有集中的固定 IP",更多是账号维度的异常。改为后台手动拉黑高频账号——有效,但效率低,黄牛账号数量太多,人工拉黑是打地鼠。
第三波:釜底抽薪,接口逻辑加固(核心优化)
真正解决问题的是改代码:在 confirm 接口入口处补充商品下架和无库存的前置校验。
改造前:黄牛提交 confirm → 走完整下单链路(查商品、查库存、查用户、查账单地址)→ 最后库存不足失败;
改造后:黄牛提交 confirm → 检查商品状态,下架/无库存 → 立刻返回错误,整个链路 10ms 内结束。
效果:submit 请求量下降 5 倍。
同步优化:账单地址查询改为后置(先通过所有业务校验,最后才查账单地址),进一步减少无效数据库查询。
长效机制
- 账户校验接口加限流,防止单账号高频操作;
- 接入风控体系,建立基于行为特征的拦截机制(风控对支付记录少的新账号黄牛效果有限,需结合单账号操作频率限制综合治理);
- 大促活动后对无效流量来源持续分析,进一步推动防刷机制的完善。
五、突发流量应对方法论
四年的案例沉淀出以下五条可复用的原则,供参考:
1. 分层应对,而不是单点押注
扩容、限流、降级、WAF 封禁、代码加固是五个不同层次的手段,每一层都可能单独失效——扩容部署失败、WAF 路径配错、降级屏蔽了正常业务。真正应对突发流量靠的不是某一层做到极致,而是多层同时生效形成纵深防御。活动前逐层检查每个手段是否真的能生效,比活动中临时补救代价低得多。
2. 先止血,再根治,不能只止血
降级和扩容是争取时间的手段,不是解决问题的手段。每次用完临时方案之后,必须逼自己找到根因——Session Redis 隔离那次,降级打开后只争取了几小时喘息,关键是同期把 Spring Session 混用问题彻底查清楚,才有了后续的根治。不去找根因,下次同样的流量来了必然以同样的方式崩。
3. 告警是结果,往上游推两层才是根因
不要停在告警的直接现象上。Redis 内存满 → 往上推:为什么满?大 key + Session 混用。接口超时 → 往上推:为什么超时?Pipeline 占用连接时间长 + maxTotal 太小,高并发下连接池必然打满。CPU 打爆 → 往上推:为什么某一个节点特别高?hgetall 一个 Hash 800 个字段,Redis Cluster 根本无法分散这个热点。每次多问一个"为什么",才能走到真正值得修的地方。
4. 工具链决定排障速度,投入有复利
没有 hot key dump 和调用链追踪,热 key 问题靠猜可能排查一周;有了工具,一天内定位到具体商品和 key 结构。监控告警、热点分析、调用链追踪这些基础设施的建设,每提升一个层次,下次排障就能节省大量时间。工具链的投入是有复利的,越早建越值。
5. 无效流量是隐性性能问题,不能只当安全问题看
黄牛和机器人请求消耗的是真实的 CPU、Redis、数据库资源。36 台机器扛住了 QPS 16500,但转化率只有 1.25%——大量资源在为无效请求服务。防控无效流量本质上也是性能预算的一部分:把有限的系统资源优先留给真实用户,才是高并发治理的最终目标。
六、总结与感悟:写给每一个在凌晨盯着告警的人
技术债会在最脆弱的时候爆发。
板球赛日活动时发现的 9MB 大 key,在当时的优先级排期里被标注为"待优化",两年后才以更剧烈的形式(CPU 99.97% 灾难告警)逼迫我们彻底处理。几乎每一个"先欠着"的设计缺陷,最终都会在最忙、最紧张、最不希望出问题的时候还账。欠下的技术债,总是要还的,只是早还和晚还的利息差别很大。
降级开关是双刃剑,用了就要去找根因。
多次经历让我们意识到:降级开关打开容易,但打开的那一刻你同时也关掉了部分正常业务,代价并不低。降级之后要立刻去找根因,而不是降级成功就松了口气。降级是借来的时间,不是解决问题的答案。
四年的成长,不只是系统的成长。
从第一年"完全没有预案,告警了才知道要扩容",到能够通过 hot key dump 精确定位到哪个商品的哪个 key 结构有问题,并系统性地给出三阶段改造方案——这期间,不只是系统在进化,每一个经历过这些夜晚的人,排障直觉和系统观也在悄悄成长。
那些盯着告警看到天亮的夜晚,没有白费。

浙公网安备 33010602011771号