秒杀那几秒,Apollo 热更新还没到客户端,我们把长轮询的生效延迟压到了毫秒级

线上促销秒杀,0 点一到流量灌进来,限流规则还没切过去。Apollo 那边明明已经点了发布,但客户端愣是等了 3~5 秒才生效。就这几秒,把数据库连接池打满、Redis CPU 飙到 90%,整个集群差点雪崩。

这就是我们去年双十一遇到的真事。那天晚上复盘,发现 Apollo 默认的轮询间隔是 5 秒,加上配置刷新、Rebalance 那一套,从配置中心发布到客户端真正生效,实测平均延迟在 3.2 秒左右。对于秒杀这种瞬时流量尖峰,这 3 秒足够把系统打穿。

后来我们把 Apollo 的配置拉取从定时轮询改成了长轮询,结合本地缓存和实时推送,把生效延迟压到了平均 180ms,P99 控制在 600ms 以内。这篇文章就聊聊我们是怎么一步步做这个改造的。

为什么 Apollo 默认机制扛不住秒杀

Apollo 客户端默认通过定时轮询从 Config Service 拉取配置。核心逻辑在 RemoteConfigRepository 里,它启动一个定时任务,每隔一段时间去服务端拉取最新配置。这个间隔由 apollo.refreshInterval 控制,默认值是 5,单位是分钟。

对,你没看错,默认是 5 分钟。即使你把这个值调成 1 秒,Apollo 内部还有一套防抖机制——如果连续多次拉取配置都没变化,它会自动把间隔拉长,最大能到 5 分钟。这个逻辑在 ConfigUtilgetRefreshInterval 方法里:

// Apollo 客户端源码,ConfigUtil.java
public int getRefreshInterval() {
    int refreshInterval = configPropertyFactory
        .getIntProperty("apollo.refreshInterval", 5 * 60);
    // 如果配置一直没变,间隔会指数增长
    if (isConfigStable) {
        refreshInterval = Math.min(refreshInterval * 2, 5 * 60);
    }
    return refreshInterval;
}

这就导致一个问题:在秒杀场景下,我们需要把限流阈值从正常值(比如 1000 QPS)瞬间拉到活动值(比如 50000 QPS),但如果客户端还在"慢悠悠"地轮询,这中间的时间窗口就变成了系统最脆弱的时刻。

我们在压测环境做过对比:配置发布后,客户端生效时间分布如下:

  • 最小延迟:0.8 秒(刚好赶上下一轮轮询)
  • 平均延迟:3.2 秒
  • P99 延迟:7.6 秒

秒杀峰值持续 10-15 秒,中间有 3-7 秒限流规则没生效,后果可想而知。

长轮询方案的核心设计

问题的根因很清楚:客户端不知道什么时候有变更,只能瞎猜着去拉。解决思路也直接:让客户端和服务端之间维持一个长连接,配置一变更,服务端立刻通知客户端。

我们没有另起炉灶,而是利用了 Apollo 本身支持的 Notification 机制。Apollo 的 Config Service 在配置发布后,会往数据库的 ReleaseMessage 表插入一条记录,客户端可以通过 notifications/v2 接口监听变更通知。

这个接口本身支持长轮询。客户端发起请求,服务端hold住连接,直到有新的 ReleaseMessage 或者超时(默认 60 秒)。如果超时,客户端立刻发起下一次长轮询,保持连接不断。

改造的关键点有三个:

第一,把轮询模式切成长轮询模式。 Apollo 客户端默认用的是定时轮询,需要显式配置让它走长轮询。在 application.properties 里加一行:

# 启用长轮询模式
apollo.longPolling.enabled=true
# 长轮询超时时间,单位秒
apollo.longPolling.timeout=60
# 长轮询失败后的重试间隔,单位毫秒
apollo.longPolling.retryInterval=500

我们实测发现,Apollo 1.9.x 版本的长轮询实现有个小坑:如果网络抖动导致长轮询中断,客户端会退回到定时轮询模式,需要重启才能恢复。这个问题在 2.0.1 版本修复了,我们直接升到了 2.1.0,避免线上翻车。

第二,本地缓存 + 即时生效。 长轮询解决的是"感知变更"的速度,但客户端拿到新配置后,还需要把配置应用到内存里的限流器上。我们的限流器用的是 Guava RateLimiter,创建后就固定了速率,没法动态改。所以我们在外层包了一个 AtomicReference<RateLimiter>,配置变更时直接替换整个 RateLimiter 实例:

public class DynamicRateLimiter {
    private final AtomicReference<RateLimiter> rateLimiterRef;
    
    public DynamicRateLimiter(double initialRate) {
        this.rateLimiterRef = new AtomicReference<>(
            RateLimiter.create(initialRate));
    }
    
    public void updateRate(double newRate) {
        RateLimiter oldLimiter = rateLimiterRef.get();
        RateLimiter newLimiter = RateLimiter.create(newRate);
        // 让旧限流器把存量请求处理完
        newLimiter.acquire(oldLimiter.getRate());
        rateLimiterRef.set(newLimiter);
    }
    
    public boolean tryAcquire() {
        return rateLimiterRef.get().tryAcquire();
    }
}

第三,监听变更后只刷新有变化的 Namespace。 这是性能优化的关键。Apollo 默认的变更通知只告诉你"某个 AppId 有变更",但不告诉你是哪个 Namespace。如果客户端有 10 个 Namespace,每次收到通知就要全量刷新 10 个,白白浪费资源。

我们利用了 Apollo 2.0 引入的 apollo.portal.ReleaseHistory 表,在 Config Service 侧做了个小改造:发布配置时,把变更的 Namespace 信息写到 ReleaseMessage 的 message 字段里。客户端解析这个字段,只刷新有变更的 Namespace。这个改动把单次配置刷新耗时从平均 80ms 降到了 12ms。

长轮询在秒杀场景下的实际表现

改造完成后,我们在生产环境做了三轮压测,每次模拟秒杀峰值:0 点瞬间 50000 QPS,持续 15 秒。

配置发布方式不变:运营在 Apollo Portal 上点击发布,把限流阈值从 1000 调到 50000。客户端生效延迟的对比数据:

方案 平均延迟 P99 延迟 峰值期间拒绝请求数
定时轮询(5s) 3200ms 7600ms 约 12 万
定时轮询(1s) 870ms 2100ms 约 4 万
长轮询(无缓存优化) 420ms 1200ms 约 8000
长轮询(含缓存优化) 180ms 600ms 约 2000

从 3200ms 到 180ms,提升了将近 18 倍。P99 延迟从 7.6 秒压到了 0.6 秒,基本控制在秒杀峰值窗口之内。

但这里有个细节值得注意:长轮询不是银弹,它的延迟下限受限于网络 RTT + Apollo 内部消息传播耗时。我们实测 Config Service 在收到发布请求后,到 ReleaseMessage 写入数据库、再到通知消费者,中间有 50-80ms 的内部延迟。加上客户端到服务端的网络 RTT(我们同机房,大概 2-5ms),180ms 已经接近这个方案的理论下限了。

如果还想进一步压低,就要考虑本地配置兜底 + 手动触发结合的方式。比如在秒杀开始前 30 秒,通过内部的配置管理平台提前下发一个"预发布"指令,让限流器提前进入备战状态。这个我们正在做,预计能把生效延迟压到 50ms 以内。

长轮询方案的稳定性保障

长轮询本质上是在服务端维持海量长连接,这对 Config Service 的压力不小。我们集群有 2000+ 客户端实例,每个都维持一个长连接,服务端需要 hold 住 2000 个线程。

Apollo Config Service 默认用 Tomcat 容器,最大线程数默认 200。如果不调整,长轮询一上来就把线程池打满了。我们的调整:

# Config Service 的 application.properties
server.tomcat.max-threads=5000
server.tomcat.max-connections=10000
server.tomcat.accept-count=1000

另外,长轮询超时时间设成 60 秒,意味着每个连接最多占用线程 60 秒。2000 个连接,理论上需要至少 2000/(60/平均请求处理时间) 的线程数。我们按 2000 个连接、每个连接平均占用 30 秒来算,至少需要 1000 个线程。所以 max-threads 设成 5000 是留了足够余量的。

还有一个容易忽略的点:长轮询断开后的重连风暴。如果 Config Service 重启,2000 个客户端会在同一时间断开长连接,然后按 retryInterval 重试。如果 retryInterval 设得太短,比如 100ms,那瞬间就有 2000 个重连请求打过来,直接把刚起来的 Config Service 打挂。

我们把 retryInterval 设成 500ms,并且加了随机抖动(±200ms),让重连请求分散开来。这个在 apollo.longPolling.retryInterval 配置里加上 jitter 参数即可:

apollo.longPolling.retryInterval=500
apollo.longPolling.retryJitter=200

常见问题

为什么不直接用 Apollo 的实时推送,比如 gRPC Stream?

Apollo 2.0 确实支持了 gRPC 推送,但我们评估后没选。原因有两个:一是我们内部网络策略对 gRPC 端口有限制,需要额外申请和排期,时间上来不及;二是 gRPC 推送需要客户端升级到 2.0+,我们有一部分老服务还在跑 1.8,兼容性是个问题。长轮询方案基于 HTTP,对网络和版本的要求最低,改造成本最小。实际效果也足够用了,180ms 的延迟在秒杀场景下完全可接受。

长轮询和定时轮询能同时用吗?

能,但不建议。Apollo 的客户端逻辑是:如果开启了长轮询,就用长轮询;如果长轮询失败(比如服务端不支持),会降级到定时轮询。这个降级是自动的,不需要额外配置。但要注意,如果网络不稳定导致频繁降级,延迟反而会比纯定时轮询还高,因为长轮询的超时时间(60 秒)远大于定时轮询间隔(1 秒),降级过程中会有一段"真空期"。我们的做法是监控长轮询的降级次数,如果 5 分钟内超过 3 次,就触发告警。

配置变更通知丢了怎么办?长轮询能保证不丢消息吗?

不能。长轮询模式下,如果客户端在两次长轮询之间断开了连接,恰好这时候有配置变更,等客户端重连后,它只能拿到"有变更"这个事实,但拿不到具体的变更内容。这时候客户端会触发一次全量拉取,把当前配置和本地缓存做 diff,确保不丢变更。所以消息不会丢,但极端情况下延迟会变高(全量拉取比增量刷新慢)。我们监控到全量拉取的触发频率大概在 0.3% 左右,影响很小。