☰
百亿流量架构实战:CDN+Nginx+Java微服务全链路高并发优化
2026/10/19 7:49:33 网站建设 项目流程

“百亿级流量”这几个字,在方案评审会上说说很容易,真正落到架构图上,就是一连串需要抠细节的硬仗。我接手过不少从几万 QPS 往百万级冲的项目,也复盘过线上流量在半小时内翻五倍的突增场景。说实话,CDN 和 Nginx 这两层,往往是救命的,也是最先暴露问题的。而它们背后那群 Java 微服务能不能接住入口放进来的流量,才是整个架构从“能跑”到“能扛”的分水岭。

这篇文章我想换个说法,不讲 PPT 上的标准架构图,而是结合我实际搭建和排障的经验,把 CDN+Nginx 组成的流量入口怎么搭、Nginx 怎么调才能扛住突发流量、后面的 Java 微服务怎么落地才不至于拖后腿,一层一层拆开说清楚。无论你是正在设计高并发系统的架构师,还是负责优化线上服务稳定性的后端开发,这篇应该都能给你一些能直接用的思路。

1. 百亿流量不是口号:先拆解数字再定架构

刚看到“百亿级流量”这个描述,很多人的第一反应是“要上大集群、要买很多机器”。但如果你连“百亿”到底是什么概念都没算清楚,后面做的所有架构决策都是在猜。我在跟团队讨论方案时,开篇第一件事永远是:把业务数字翻译成技术指标。

1.1 口径换算:一年百亿和一天百亿隔着365倍

“百亿级流量”在不同的业务语境里,差距是一个数量级甚至两个数量级。一年 100 亿请求,均摊到每天约 2700 万次,就算高峰集中在晚上 8 点到 12 点这 4 个小时,峰值也就是每秒两三千 QPS——这个量级,一台配置好一点的 Nginx 加上几台应用服务器就能扛住。但如果是“一天 100 亿请求”,一天有 86400 秒,均摊下来就是每秒约 11.6 万 QPS,而真实业务往往有显著的峰谷,峰值可能是均值的三到五倍,也就是每秒三五十万 QPS。这两者之间,隔着 365 倍的架构复杂度。

我建议在做技术方案之前,先拉着运营和数据同学把口径对齐:是页面浏览量、接口调用次数,还是包含 CDN 边缘节点日志在内的总请求量?我曾经碰到过一个项目,对外宣称“日请求量百亿”,实际上其中百分之八十是 CDN 边缘节点直接返回的 304 静态资源缓存命中,真正穿透到源站 Nginx 的请求每天只有几千万。虽然这不妨碍对外讲故事,但如果你照着“百亿穿透到源站”去设计容量,预算会直接失控。

1.2 从 QPS 和带宽倒推每一层的压力

确认了口径之后,就要把 QPS 转换为两个关键指标:带宽和并发连接数。带宽取决于平均响应体大小。假设每个动态接口响应约 10KB,每秒 30 万 QPS 穿透到 Nginx,峰值带宽大约是 3GB/s,也就是 24Gbps;如果静态资源平均 100KB,这个数字会直接翻十倍。这就是为什么 CDN 必须挡在最前面——它把绝大多数大体重静态流量消化在边缘节点,源站只需要面对瘦身后的动态请求。

并发连接数则和请求处理时长强相关。HTTP 短连接场景下,平均响应时间 50ms,一个 worker 每秒能处理 20 个请求,单机 2 万 QPS 需要 1000 个并发连接;如果服务端 RT 恶化到 500ms,同样 QPS 就需要 1 万个并发连接。这也是为什么“RT 上涨”会引发雪崩——响应越慢,Nginx 和后端维持的连接越多,最后连接数被打满,新的请求直接排队超时。架构设计的本质,就是确保每个环节的容量水位都留有余量。

2. CDN 层:把大多数请求拦在离用户最近的地方

CDN 是整个链路的第一道防线。它解决的不仅是“用户访问慢”的问题,更核心的是“让源站只处理不得不处理的请求”。我见过不少团队把 CDN 当成一个简单的“静态资源加速器”,配完域名就丢在那里不管了。实际上 CDN 层的缓存策略、回源链路、协议协同,每一个细节都直接影响后面 Nginx 和 Java 微服务的压力。

2.1 缓存策略:TTL 与命中的日常博弈

静态资源是 CDN 最舒服的流量,图片、JS、CSS、字体这些资源几乎不变,只要 TTL 配得够长,命中率能做到 95% 以上。但这里有个容易踩的坑:文件名不带版本号或哈希值时,CDN 缓存会让用户看到旧版本资源。

我在实际项目里的做法是:所有静态资源走 Webpack 或构建工具生成带内容哈希的文件名,比如app.8f3d2a.js,然后把 CDN 的 TTL 配到 30 天以上。带上哈希的文件名天然免疫“更新问题”,因为文件内容变了,URL 就变了,CDN 会把新 URL 当作一个新资源去回源,旧资源等到 TTL 过期自然淘汰。最怕的是那种app.js?v=20250101的写法,很多 CDN 默认忽略问号后的参数,导致版本更新后用户拿到的还是旧文件;而如果配置成“参数参与缓存”,每次下发新版本号又会引发一次全量回源。

对于index.html这类入口文件,我建议设置no-cache或者极短 TTL,比如 60 秒。它的体量很小,回源成本很低,但如果不缓存导致每次刷新都回源,高峰期的回源 QPS 也会很可观。更稳妥的方式是给入口 HTML 单独配置一个缓存规则,与静态资源区分开,不要一刀切。

2.2 动态请求要不要走 CDN:动态加速的真相

很多团队纠结“动态接口能不能走 CDN”。要分两层看。第一层是普通的 POST 或带用户态的 GET 请求,这类请求无法缓存,CDN 只能帮你做链路优化——选择更优的回源线路、维持到源站的长连接、复用 TLS 会话,从而降低公网传输延迟。这确实是有效果的,尤其对跨地域用户,回源 RTT 能从 50ms 降到 10ms 左右。第二层是 CDN 边缘节点和源站之间的“动态加速”通道,本质是各大云厂商分布在各地的边缘节点,通过内部高速网络把请求转发到源站,避免用户直接走公网长途跋涉。

但我必须提醒一点:动态加速不是免费的,按流量计费,价格远高于静态分发。所以在正式接入之前,先看看业务的用户分布、源站地域、现有公网质量,判断这笔成本换来的延迟收益是否划算。如果用户和源站都在同一个城市,动态加速的意义就不大;如果用户遍布全国而你只有单地域源站,那确实值得上。我遇到过一家公司,把所有 API 都套了动态加速,每月账单翻了三倍,但用户感知的延迟几乎没有变化,因为他们的用户本来就和源站同城——这就是典型的“用错了地方”。

2.3 源站防护:CDN 背后 Nginx 需要的“安全白名单”

CDN 上线后,一个很容易被忽略的问题是:源站 IP 直接暴露了。如果业务方通过 DNS 解析到了源站真实 IP,攻击者完全可以绕过 CDN,直接打源站,你这套缓存体系瞬间失效。常规做法是让源站 Nginx 只允许 CDN 回源 IP 访问,只放行 CDN 厂商公布的回源 IP 段,其余一律 403。云厂商 CDN 控制台里一般都有“源站防护”配置,会指导你把回源 IP 段加到 Nginx 的allow列表里。

不过,回源 IP 白名单只能解决一部分问题,因为回源请求的Referer或Host头是可以伪造的。我建议在 Nginx 层做两层校验:第一层 IP 白名单,第二层检查回源请求的 Host 头必须是你的域名,否则拒绝。有些 CDN 回源时会额外携带私有请求头,比如X-Req-Source: cdn-edge,源站 Nginx 可以对此做强校验,这个信息不容易伪造,安全性比 Host 头强得多。另外,CDN 回源后的资源如果涉及动态内容,源站 Nginx 还要加独立的限流规则,防止 CDN 某一台边缘节点出现异常,导致瞬间回源流量把源站打垮。

3. Nginx 入口层:系统参数、负载均衡与限流三板斧

CDN 把静态流量消化掉之后,真正到源站的流量仍然是每秒数万甚至十几万的量级。这一层 Nginx 就是最后的闸门,它既要稳定地转发流量,又要在流量异常时兜住底线。Nginx 本身的性能上限很高,但前提是配置合理、系统参数到位。我见过太多 Nginx 部署,用的还是默认配置,稍微来一波突发流量就直接拒绝连接。

3.1 系统内核参数与 worker 配置:连接数天花板在这里

Nginx 能支撑多少并发连接,第一个瓶颈是系统文件描述符。默认ulimit -n常常是 1024,这就意味着 Nginx 同时只能维护一千多个连接,再高就报 “too many open files”。第一步先把这个调大:

ulimit -n 1048576

然后在/etc/security/limits.conf里持久化配置,再检查net.core.somaxconn和net.ipv4.tcp_max_syn_backlog。这两个内核参数决定 TCP 三次握手时 listen 队列能排多长,默认值是 128 或 1024,应对高并发明显不够,建议调整到 65535 左右。

Nginx 本身的配置里,我常用这套基准:

worker_processes auto; worker_rlimit_nofile 1048576; events { use epoll; worker_connections 65535; } http { keepalive_timeout 65; keepalive_requests 1000; sendfile on; tcp_nopush on; tcp_nodelay on; }

worker_processes auto让 Nginx 按 CPU 核数启动 worker,每个 worker 处理一部分连接。worker_connections乘以 worker 数量就是理论最大连接数,一般我会按“每个 worker 至少留 30% 余量”来估算。keepalive_requests 1000是让一个长连接能复用 1000 次请求,这对于减少握手开销很有帮助,但要注意后端服务也要配合开启 keepalive,否则 Nginx 和后端之间每次都要重新建立 TCP 连接,那条连接才是真正的瓶颈。

3.2 upstream 负载均衡策略与 keepalive 长连接

Nginx 对后端的负载均衡策略,默认是轮询,简单但不够均衡。如果后端服务处理能力有差异,比如部分是重实例、部分是轻实例,轮询会导致慢节点堆积请求。我常用的配置是least_conn,它会把新请求交给当前活跃连接数最少的节点,这种策略在 RT 波动较大的场景下最均衡。另外,很多业务有“会话保持”的需求,同一用户的请求要打到同一台后端,这时候ip_hash是最简单的方案。

但ip_hash有个隐患:后端扩容时,哈希结果会重新分布,一部分用户的会话会跳到其他节点。如果业务用了分布式 session 或者无状态 JWT,就没有这个问题;如果还在用本地 session,扩容就变成了一场事故。我的建议是:优先改造应用为无状态,然后放心用least_conn,不要为了省事在网关层用ip_hash,后面你会哭着迁移。

upstream 还有一个极容易被忽略的配置——长连接。默认情况下,Nginx 和上游之间是短连接,每转发一个请求都要新建 TCP 连接,QPS 高了之后,Nginx 的本地端口和系统资源都会被握手开销占满。配置长连接也没有很复杂:

upstream backend { server 10.0.0.11:8080 weight=5; server 10.0.0.12:8080 weight=5; keepalive 1024; } server { location /api/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ""; } }

keepalive 1024表示 Nginx 每个 worker 会维护最多 1024 条空闲的 upstream 长连接,缓解反复握手的问题。proxy_http_version 1.1和proxy_set_header Connection ""是让上游支持长连接的必要条件。我见过很多配置把这两行漏了,结果 keepalive 形同虚设。

3.3 限流与防刷:令牌桶、连接限制与封禁实战

即使有 CDN,源站 Nginx 也必须具备独立限流能力,防的不是正常用户,而是异常流量和爬虫。Nginx 自带的limit_req和limit_conn就够用。limit_req是基于令牌桶算法的请求频率限制,适合限制特定 URI 或特定 IP 的 QPS。

limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s; server { location /api/ { limit_req zone=api_limit burst=40 nodelay; proxy_pass http://backend; } }

这里rate=20r/s是每秒 20 个请求,超出后进入burst=40的队列,nodelay表示队列里的请求不额外等待,直接放行直到队列耗尽,之后的多余请求立刻返回 503。这套参数不是随意拍的,我会根据某个接口的“峰值合理率 + 20%~30% 余量”来配置,既不能影响正常用户,又能在爬虫大量请求时自动拦截。

此外还有一种场景:某个 IP 在短时间内建立了大量并发连接,占用连接池资源。这种情况用limit_conn更合适:

limit_conn_zone $binary_remote_addr zone=conn_limit:10m; server { location / { limit_conn conn_limit 20; } }

如果遇到更严重的攻击,比如某些恶意 IP 段反复刷流量,直接在 Nginx 层做封禁是最有效率的。可以用geo模块维护一个封禁列表:

geo $blocked { default 0; 10.0.0.0/8 1; 172.16.0.0/12 1; } server { if ($blocked) { return 403; } }

上面这一段我用的是私网段示例,实际场景中你需要把抓到的攻击 IP 段加进geo模块。配合日志分析,每周更新一次列表,就能挡掉很大一部分低级爬虫和扫端口工具。别小看这个土办法,它比很多商业 WAF 更灵活,毕竟你自己最清楚哪些 IP 该进黑名单。

3.4 proxy_cache 的一层缓存兜底

如果 CDN 因为某些原因回源量激增,或者业务有大量可缓存的动态请求,Nginx 还能再建一道缓存防线。proxy_cache的典型应用是:源站前面还有一个 Nginx 集群,对 CDN 回源的一些 GET 请求做短期缓存,比如 10 秒到 60 秒,从而削掉后端峰值的压力。

proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=cache_zone:500m max_size=20g inactive=60m; server { location / { proxy_cache cache_zone; proxy_cache_key "$scheme$request_method$host$request_uri"; proxy_cache_valid 200 10s; add_header X-Cache-Status $upstream_cache_status; } }

proxy_cache_key的粒度要特别小心,如果 URL 里带了用户标识或者时间戳参数,这个缓存就废了,命中率会低得可怜。我通常会把 URL 中无关参数在proxy_cache_key里过滤掉,或者干脆只保留 path 部分做 key。加add_header X-Cache-Status是为了验证命中率,线上调试阶段保留,稳定后去掉,避免泄露内部信息。Nginx 缓存这一层,我把它定位成“最后一公里缓冲”,它不是为了替代 CDN,而是为了在 CDN 失效的边缘时刻让源站有一个缓冲垫。

4. 网关层:承接 Nginx 与 Java 微服务的中间地带

流量过了 Nginx,下一步进入 Java 微服务体系。这里很多人会直接让 Nginx 把请求反代到各个微服务实例,跳过了网关层。小规模没问题,但当微服务数量超过二三十个、需要统一做鉴权、路由、灰度、限流的时候,没有网关会非常痛苦。我自己实践下来,Nginx 负责流量入口和基础转发,网关负责业务级路由和治理,两者分工是最高效的组合。

4.1 为什么 Nginx 后面还需要一个网关

Nginx 擅长的是 L4/L7 转发、连接管理、静态资源处理,但它不擅长做业务判断。比如“这个用户的 Token 是否有效”、“这个请求是否需要走灰度版本”、“这个接口当前的限流阈值是多少”,这些逻辑放在 Nginx 里要么要写 Lua,要么要频繁 reload 配置,维护成本很高。而 Java 微服务网关可以动态加载规则、对接注册中心、与配置中心联动,随时调整路由和限流策略,不用重启。

另一个关键点是异常处理。Nginx 转发到后端时,一旦后端全部不可用,Nginx 只能返回 502。但网关可以做 fallback,比如熔断后返回一个降级响应或者缓存数据。这种“业务兜底”能力是 Java 网关最显著的价值。我实际做过的方案是:网关层对接注册中心,感知服务实例上下线;Nginx 只负责把流量打到网关集群,网关再去分发到具体的微服务。

4.2 WebFlux 线程模型与阻塞调用的坑

网关选型,我用得最多的是 Spring Cloud Gateway。它是基于 WebFlux 和 Netty 的响应式模型,单线程能处理大量 IO 等待,理论上比传统 Servlet 网关吞吐更高。但这里有一个非常大的坑:在响应式链路里写了阻塞代码,会让整个网关性能归零。

举个例子,如果网关里做了一个 JDBC 查询,或者用 RestTemplate 调用下游服务,这些操作都是阻塞的,会占用 Netty 的 EventLoop 线程。Netty 的 EventLoop 线程数是 CPU 核数的两倍,一旦某个请求阻塞了 EventLoop,后续所有请求都在等这个线程释放。表现就是网关的 RT 突然上涨,连接堆积,最终雪崩。我的经验法则是:网关层只做路由、鉴权、限流、转发这些非阻塞操作;涉及数据库、外部服务的调用一律异步化或者下沉到业务微服务去做。

网关的全局超时配置也值得专门调整。Spring Cloud Gateway 的默认响应超时时间可能太长,生产环境我会压到 3 秒以内:

spring: cloud: gateway: httpclient: response-timeout: 3000 connect-timeout: 1000

connect-timeout 1000是连接超时 1 秒,response-timeout 3000是响应超时 3 秒。有了这个配置,后端某台机器假死时,网关不会无限等待,而是快速失败并触发重试或熔断逻辑。这些数字是经验值,具体要根据业务容忍度调整,但我强烈建议至少不要让网关超时比 Nginx 的proxy_read_timeout还长,否则 Nginx 那边已经断开了,网关这边还在等待响应,白白浪费资源。

4.3 网关限流与熔断:控制在 Java 应用的最后一层

网关层做限流,比 Nginx 更精细,因为它可以按用户维度、接口维度甚至参数维度来限流。Spring Cloud Gateway 的RequestRateLimiter配合 Redis 能实现比较灵活的限制,核心配置大概是这样:

spring: cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path=/api/order/** filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 100 redis-rate-limiter.burstCapacity: 200 key-resolver: "#{@userKeyResolver}"

replenishRate是每秒填充的令牌数,burstCapacity是桶容量,key-resolver决定按什么维度限流(按用户 ID、按 IP 等)。这一层限流和前几层不同,它管的是“业务逻辑能承受的最大速率”,比如某个下单接口数据库只能扛 100 QPS,那网关就得把超过的请求挡在前面,而不是让它们打到数据库。

网关熔断我一般用 Resilience4j CircuitBreaker。它与 Nginx 只能“转发或 502”的区别在于,熔断开启后可以返回一个预设的 fallback 响应,比如“系统繁忙,请稍后再试”,保证用户体验。这里我想多提一句:熔断的判定阈值要基于“错误比例 + 慢调用比例”综合判断,不要只看错误码。很多慢请求并没有报错,但它已经占满了线程池,同样能引发雪崩。设置慢调用阈值时要拿后端 P99 时延做基准,比如 P99 是 500ms,那慢调用阈值就设在 800ms 左右,留出一些余量。

5. Java 微服务高并发落地:JVM、缓存与异步

前面入口再好,流量最终要由 Java 微服务消化。这一节的三个主题是我在压测和线上问题排查中反复遇到的高频点:JVM 配置、缓存的三种经典故障、异步削峰。表象各不相同,但根子都在“资源边界认知不清”。

5.1 JVM 与线程池:先理解资源边界再调参

Java 服务能处理多少并发,首先取决于线程池。Spring Boot 内嵌 Tomcat 的默认max-threads是 200,也就是说每个实例同一时间最多并行处理 200 个请求。如果接口平均 RT 100ms,一台实例的吞吐上限约 2000 QPS;如果 RT 涨到 500ms,吞吐只剩 400 QPS。线程池参数要根据“CPU 核数、接口 IO 时长、目标 QPS”来计算,而不是网上抄一个固定值。

一个我常用的估算公式:线程数 = 目标 QPS × 平均 RT(秒),再乘以 1.5 到 2 的冗余系数。假设目标是单实例 2000 QPS,平均 RT 0.1 秒,那线程数至少需要 200×1.5=300。反过来,如果 QPS 上不去而响应时间飙升,优先排查的应该是线程池是否被打满,而不是急着加机器。

JVM 方面,我主要关注的指标是 GC 停顿时间,尤其是 Full GC。G1 是默认垃圾收集器,但默认堆大小往往不合适。堆设太小会导致频繁 Full GC,设太大又会导致单次 GC 时间过长。经验做法是:先用压测确定服务的存活对象大小,再以“2-3 倍的存活对象 + 缓冲”设置堆大小,并且把 G1 的MaxGCPauseMillis目标设为 100ms 到 200ms,让 JVM 自己调整新生代大小来逼近这个目标。线上观察 GC 日志,如果 Full GC 频率高于“每 8~12 小时一次”,就要考虑调堆或排查对象驻留问题。

5.2 缓存三件套:穿透、击穿、雪崩的应对

微服务的高并发,离不开缓存,但缓存的雷也特别多。缓存穿透、缓存击穿、缓存雪崩这三个名字听起来像绕口令,实际上对应的是完全不同的故障场景,应对手段也不一样。

缓存穿透指查询一个必然不存在的数据,缓存没有,数据库也没有,请求直接打到数据库。比如用一个不存在的商品 ID 反复查询,如果接口没做校验,数据库会被这些无效请求淹没。最简单的方案是:如果查不到的数据也缓存一个空值,并设短 TTL,比如 30 秒到 60 秒,挡住后续同样的查询。更彻底的做法是引入布隆过滤器,在查询前先判断 ID 是否存在,不存在的直接返回。

缓存击穿指某个热点 key 在缓存过期的一瞬间,大量请求同时穿透到数据库。比如一个秒杀商品的详情,缓存过期后,几万个请求同时去数据库查同一条数据。常见解法是互斥锁:当缓存失效时,只允许一个线程去数据库加载数据并回填缓存,其他线程等待或直接返回旧值。具体到 Java,可用 Redisson 分布式锁实现。另一个思路是“逻辑过期”——缓存里存的对象带一个过期时间字段,读取时发现过期,先返回旧值,后台异步去刷新缓存,这样任何时刻都不会出现空窗。

缓存雪崩指大量 key 在同一时间过期,数据库瞬间被打垮。触发原因往往是 set 缓存时统一设置了一个过期时间,比如“当晚零点全部失效”。解决办法很简单:过期时间加一个随机值,比如base + random(0~10) 分钟,让 key 的过期时间自然分散。更稳妥的方案是加一层本地缓存(如 Caffeine)做二级保护,本地缓存过期也不会全部同时失效。

5.3 异步削峰:消息队列在流量洪峰中的角色

有些链路没办法通过缓存解决,比如写操作、下单、支付回调。这时候消息队列就派上用场了。核心思想是:把同步的“高并发写入”变成异步的“平稳消费”,让后端的数据库压力从洪峰变成一段平滑的曲线。

实际落地时,我会把一个大型操作拆成“同步扣库存 + 异步做大事务”。比如下单接口,同步阶段只做库存预占、生成订单号和响应;然后发一条消息到 MQ,由消费者异步完成订单详情落库、积分发放、短信通知等操作。同步接口 RT 从几百毫秒降到十几毫秒,系统能承载的 QPS 就上去了。消费者侧再按数据库能力配置消费速率,比如单个消费者每秒最多处理 200 条消息,不够就加消费者实例,而不再依赖入口限流。

用 MQ 最大的错觉是“消息放进 MQ 就万事大吉”。我踩过的坑包括:消费者偶发异常导致消息积压、顺序消息在并发消费时乱序、重复消费导致脏数据。这些都是需要提前设计的。我个人经验是:生产端必须做好消息确认,消费端必须做好幂等,哪怕是同一个订单的重复下单事件,也得保证数据只写一次。幂等表、Redis 去重、业务唯一键,三选一,一定要有。

6. 一次线上突增的完整排查链路

前面讲的都是“怎么搭”,但架构再好,线上出问题时还是得会查。我之前带过一个做电商活动的项目,预热阶段流量半小时内翻了四倍,当时 CDN 命中率下降、Nginx 连接数飙升、Java 服务响应时间直接翻倍,整个链路都告警了。整个排查过程花了一个多小时,最后问题追溯到三层。我把这个过程完整还原出来,里面的排查思路应该能复用。

6.1 现象:CDN 命中率下降,后端 RT 飙升

那天晚上活动预热开始后,监控首先报警的是 CDN 命中率:从日常的 95% 掉到了 83%。接着 Nginx 的活跃连接数从 5000 涨到了 12000,接近容量阈值。再往后,订单微服务的 P99 时延从 180ms 涨到了 600ms,下单接口成功率开始出现波动。三张监控图摆在同一个时间轴上,几乎同时跳变,明显是同一件事引起的连锁反应。

第一步先看 CDN 为什么回源量增大。我打开 CDN 日志,筛选了回源请求最多的 URL,发现大量请求的 URL 带了一段活动参数,比如?from=channelA&user=xxx。而这个活动页之前是静态页面,CDN 配置的缓存 key 默认包含完整 URL 参数,这就导致同一个页面被拆成了几万个不同的缓存条目,每条都很难命中,于是疯狂回源。问题的根子在于,CDN 把动态参数也当成了缓存 key。

6.2 逐层排查:从 CDN 参数到 JVM 内存

找到 CDN 原因后,Nginx 连接数飙升就很好解释了:回源量从日均 2 万 QPS 突然涨到 12 万,Nginx 当然会堆积连接。但第二层的排查还没结束——为什么 Java 服务的 RT 也翻倍了?我顺手看了订单服务的 GC 日志,发现 Full GC 在活动开始后短短十分钟内出现了 6 次,每次停顿 400ms 左右。

进一步看堆内存,发现内存里有一个ActivityContent缓存对象,存储的是活动页配置数据,单个对象体量大,且缓存 key 是按“用户 ID + 活动 ID”维度创建的。活动开始后,大量用户进入页面,每个用户都会触发一个缓存条目,把整个活动页配置复制了一份,内存迅速膨胀,最终频繁 Full GC。这一层的问题和 CDN 是同一类——没有把公共数据从“每用户维度”提升到“全局维度”。

6.3 复盘教训:每一层都要有“超出预期”的预案

这次事故的处理方式并不复杂:CDN 配置里把活动页 URL 参数排除出缓存 key,Java 服务的缓存 key 改成活动 ID 维度,并限制活动页对象的过期时间。不到半小时,命中率回到 95%,GC 正常,RT 回落。但复盘的时候,我意识到更深层的问题是:整个链路里每一层都假设了“正常情况”,没有人预想过“CDN 命中率掉 10 个点会给后端带来 6 倍流量”这个连锁效应。

后来我们做了一个固定的容量演练动作:每个月挑一个低峰期,将 CDN 的缓存命中率人为压低 10% 到 20%,观察 Nginx 和后端扛不扛得住,扛不住就继续优化。我个人在这些事上最大的体会是——别相信任何一层是永远可靠的。CDN 会配置错,Nginx 会到极限,缓存会过期,服务会变慢。你能做的是让每一层都有降级的余地:CDN 失效时有 Nginx 限流,Nginx 被冲垮时有网关熔断,网关熔断后还有前端兜底页。把“层层防御”落到配置和代码里,比祈祷某个环节永远不出问题靠谱得多。

架构没有一劳永逸,百亿流量也不是一个数字,而是每个请求经过的每一跳。把每一跳的容量、限流、防御、监控都做好了,流量再大,也只是数字的跳动而已。

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

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

立即咨询