先说那次事故,凌晨 0 点 07 分我还端着泡面
去年做一场限时抢购,活动 0 点开闸。前 10 分钟我盯着大盘,QPS 是 180 到 220,挺稳。0 点 07 分我端着泡面回工位,屏幕上的曲线已经立起来了——1.5 秒之内从 200 冲到 86000。Nginx 的 worker_connections 我配的是 1024,ulimit -n 到 65535 但 net.core.somaxconn 还是默认的 128,半连接队列直接溢出,客户端看到的是连接超时,不是 503,所以我们那套基于 5xx 的告警一声没响。
更惨的是回落阶段。峰值只持续了 40 秒左右,但 connections 掉下去以后,TIME_WAIT 在四台机器上堆到 3 万多个,端口被占着,后续正常流量进不来,等于被余震又打了一次。事后复盘发现,我们的压测用的是 2 小时恒定 3000 QPS,压的是“耐力”,而杀死我们的是“斜率”和“回落”,这俩压测里根本没覆盖。
所以我现在不太爱说“怎么扛高并发”这种话,因为脉冲流量的核心根本不是峰值多高,而是这个峰值是怎么起来的、又是怎么掉下去的。
第一步:分清你面对的是哪一档脉冲
我把见过的脉冲按上升沿时间(从基线到峰值所需时间)分成四档,因为不同档位的武器完全不一样,混着用就是烧钱或者丢用户。
第一档:<2 秒。典型的定时开抢、整点秒杀、0 点刷新。这种脉冲快到人肉扩容、K8s HPA 全都来不及,只能靠前置的静态化和排队挡住。
第二档:2 秒到 30 秒。热搜带流量、大 V 转发、开屏广告投放落地页。这个有几十秒的窗口,自动扩容勉强能赶上,但要看你的镜像拉取速度和就绪探针配了多久。
第三档:30 秒到 5 分钟。新闻事件、突发故障引发的重试风暴。这类用 HPA + 连接池自适应基本能吃下。
第四档:5 分钟以上。那其实不叫脉冲了,叫趋势,该走正常的容量规划和预留实例。
我踩过的第一个认知坑就是:把第一档当成第二档处理。当时我们配了 HPA,CPU 到 70% 触发扩容,看起来挺合理。但 HPA 默认 --horizontal-pod-autoscaler-sync-period 是 15 秒采一次,还有 --horizontal-pod-autoscaler-initial-readiness-delay 默认 30 秒,加上镜像拉取和 Spring 启动,从 0 到能接流量,四五十秒起步。等新 Pod ready 的时候,活动早结束了。
各层的具体参数,我列一遍
入口层是最值得花钱的地方,因为在这一层拦住一个请求,成本几乎为零;放到数据库层再拦,成本可能翻几百倍。
Nginx 上我最终用的是漏桶 + 连接数双限,配置大概长这样:
limit_req_zone $binary_remote_addr zone=perip:10m rate=20r/s;
limit_conn_zone $binary_remote_addr zone=connperip:10m;
limit_req zone=perip burst=40 nodelay;
limit_conn connperip 20;
burst=40 nodelay 的意思是允许瞬间 40 个请求直接过,超出的立刻拒绝,不当场排队。这里有个取舍:不加 nodelay 会平滑排队,用户体验是“卡但能成”;加了 nodelay 是“快但直接拒”。秒杀场景我选前者,普通页面我选后者。
内核参数这几个是必须动的,默认值在脉冲下真扛不住:
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
net.core.netdev_max_backlog = 250000
注意 net.ipv4.tcp_tw_recycle 这个参数别去开,4.12 之后内核已经把它移除了,很多老博客还在教这个,在 NAT 环境下开它会导致大量连接被静默丢弃,比不开还惨。
应用层我改用 Redis 令牌桶做全局限流,Lua 脚本保证原子性,大概逻辑是 HMGET 取 token 和 ts,按 min(cap, token + (now-ts)*rate) 补令牌,不够就拒绝。单 key 实测在 4 核机器上能跑到 3 万多次/秒,比想象中抗造。
数据库层就是连接池,HikariCP 的 maximumPoolSize 我们一开始设了 200,结果 MySQL 端 max_connections 是 500,几个服务一起冲上来直接打满,报的是 Too many connections。后来按 CPU核数 * 2 + 有效磁盘数 这个经验公式压到 24,反而更稳——池子太大,等待队列只是从应用层挪到了数据库层,死得更隐蔽。
压测为什么测不出脉冲的破坏力
这是我最有话说的一点。大部分团队的压测报告是“恒定 5000 QPS 跑 2 小时,错误率 0.01%”,然后据此认为系统能扛 5000 QPS。这句话对,但没用。
脉冲的杀伤力来自三个常数压测测不到的东西:上升沿的斜率、回落的陡度、以及回落后的连接回收速度。我后来在压测脚本里加了三个阶段,先用 10 秒从 100 拉到 8000,保持 30 秒,再用 5 秒啪地掉回 100。第一次跑,涨的时候没崩,掉的时候崩了——因为连接池归还风暴加上 GC,把 CPU 顶到 100%。
还有个更隐蔽的坑:重试风暴。客户端 SDK 一般默认失败重试 3 次,后端一超时,实际流量瞬间变成原来的 4 倍。你在入口看到的是 8000 QPS,到数据库可能是 3 万。所以限流要限在最外面,而不是限在业务逻辑里,否则重试会把你的限流器本身也打满。
如果一定要给个数字,我的经验是脉冲场景的容量按峰值的 1.2 倍准备,剩下 80% 靠限流和排队兜住,不要妄想按峰值全额扩容。
说点钱的事:95 计费和扩容的账
刚开始我特别迷“自动扩容”,觉得弹性就是省钱。后来账单教育了我。
云上带宽大多走 95 峰值计费:一个月按 5 分钟粒度采样,大概 8640 个点,把最高的 5%(432 个点)砍掉,取剩下最高的那个点计费。也就是说,你一个月只要有一次脉冲把带宽顶到 500Mbps 且持续了超过 432 个采样点,那这 500Mbps 就按整月收你钱。一次持续 40 分钟的脉冲,就足够让当月账单翻倍。
我们后来算过一笔账:那场活动的峰值带宽成本,比它带来的 GMV 还高 30%。现在我的做法是反过来——宁可牺牲一部分转化率,也要把峰值削平。具体是把秒杀的库存分批放,每 30 秒放一批,把 1.5 秒的上升沿拉长到 3 分钟,峰值从 86000 压到 12000,带宽账单直接降了四成多。
当然这招有代价,用户要等。所以这是个纯商业决策,不是技术决策,我现在的习惯是把“削峰会造成多少用户流失”这个数字摆给业务方看,让他们签字,别自己扛。
我最后用的那套土办法
总结下来就是三句话:入口排队、中间无状态、出口异步。
入口排队:所有写请求先扔进一个队列(我们用的 Kafka,16 个分区,消费者 32 并发),前端立刻返回“排队中,第 3821 位”,用户看到位次反而更愿意等,比转圈圈强。这一步把不可控的并发变成了可控的吞吐。
中间无状态:所有 Session 进 Redis,所有能缓存的读走 CDN。我们那次活动把商品详情页做成静态化推到 CDN,回源率压到 3% 以下,等于 97% 的读流量根本没碰我们的服务器。
出口异步:下单成功不立刻写 MySQL,先落 Redis 和 MQ,由消费者慢慢写。代价是短暂的数据不一致,前端显示“订单处理中”,大概 2 到 5 秒。
这套东西一点都不高级,没有任何“架构”的美感,跑起来还挺丑。但它是我试过的、唯一一个在 8 万 QPS 下没崩、而且账单还能看的方案。
最后留个反直觉的结论:脉冲流量你其实不需要“扛住”,你只需要决定哪些请求该被丢掉。所有请求都接住,往往意味着在最贵的那一层,把所有请求都处理了两遍。