背景痛点:大促“三杀”——连接暴涨、消息乱序、服务雪崩
去年双十一,我们团队把智能客服从外包 SDK 切到自研,结果 0 点刚过,QPS 直接翻 40 倍:
- 连接暴涨:单实例 4C8G,TCP 连接数 30 s 内从 2 k→18 w,直接把
ulimit -n顶满,新用户进不来。 - 消息乱序:HTTP 轮询模式,客户端 200 ms 刷一次,结果因 CDN 节点回源延迟,先发后至,用户看见“已发货”在前、“付款成功”在后,疯狂投诉。
- 服务雪崩:老架构是“无状态+本地缓存”,一台宕机,网关重试打爆剩余实例,CPU 100 % 又触发新一轮宕机,差点把整条链路带走。
痛定思痛,我们决定把“多多智能客服 API”彻底重构,目标只有一个:百万级在线、99.9 % 消息时延 < 200 ms、宕机秒级自愈。下面把全过程拆给你看。
架构对比:轮询、SSE、WebSocket 实测数据
实验室环境:
- 4 台 16C32G 压测机,一台 8C16G 目标服务器,千兆网卡。
- 指标定义:吞吐量=成功回包数/秒,延迟=RTT P99,资源=单连接平均内存。
| 方案 | 吞吐量 (万/秒) | P99 延迟 (ms) | 单连接内存 (KB) | 备注 |
|---|---|---|---|---|
| REST 轮询 | 2.1 | 380 | 6.8 | 大量 304,空转严重 |
| SSE | 4.7 | 220 | 8.2 | 半双工,浏览器兼容坑 |
| WebSocket | 9.3 | 98 | 4.5 | 全双工,内核零拷贝友好 |
结论一目了然:WebSocket 延迟减半、吞吐翻倍、内存更省,于是拍板“长连接 + 事件驱动”。
核心实现:三条代码搞定百万连接
1. Netty 服务端连接池(Java)
public final class WsServer { // 防御性:构造器私有,避免随意 new private WsServer(){} public static void main(String[] args){ int port = 9888; EventLoopGroup boss = new NioEventLoopGroup(1); EventLoopGroup worker = new NioEventLoopGroup(0); // 0=2*CPU try{ ServerBootstrap b = new ServerBootstrap(); b.group(boss, worker) .channel(NioServerSocketChannel.class) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, false) // 自研心跳更灵活 .childHandler(new ChannelInitializer<SocketChannel>(){ @Override protected void initChannel(SocketChannel ch){ ch.pipeline().addLast( new HttpServerCodec(), new HttpObjectAggregator(65536), new WebSocketServerProtocolHandler("/ws"), new IdleStateHandler(20, 15, 0, TimeUnit.SECONDS), new WsFrameHandler() // 业务逻辑 ); } }); ChannelFuture f = b.bind(port).sync(); f.channel().closeFuture().sync(); } catch (InterruptedException e){ Thread.currentThread().interrupt(); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); } } }连接池管理靠DefaultChannelGroup,内部用ConcurrentHashMap,时间复杂度 O(1);下线时channelGroup.flushAndClose()保证批量推送离线消息。
2. 消息 ID 生成——雪花算法改进版
public class IdGen { private final long workerIdBits = 10L; // 支持 1024 实例 private final long seqBits = 12L; private final long maxWorkerId = ~(-1L << workerIdBits); private long workerId; private long sequence = 0L; private long lastTs = -1L; public synchronized long nextId(){ long ts = System.currentTimeMillis(); if (ts < lastTs) throw new IllegalStateException("Clock moved backwards"); if (ts == lastTs) { sequence = (sequence + 1) & ((1 << seqBits) - 1); if (sequence == 0) ts = tilNextMillis(lastTs); // 自旋 } else sequence = 0L; lastTs = ts; return (ts << (workerIdBits + seqBits)) | (workerId << seqBits) | sequence; } // 防御性:阻塞到下一毫秒,避免重复 private long tilNextMillis(long last){ long ts; do { ts = System.currentTimeMillis(); } while (ts <= last); return ts; } }时间复杂度 O(1),单实例 1 ms 可生成 4096 个 ID,百万并发也扛得住。
3. 分布式会话:Redis + 本地二级缓存
- 热数据(最近 5 min)放 Caffeine,本地命中 < 50 µs。
- 冷数据降级到 Redis Cluster,一致性用
Lua脚本保证GET+SET原子。 - 下线时先写 Redis,再广播
UserOffline事件,其他节点消费后清本地缓存,实现最终一致。
避坑指南:掉进去一次,再也不想回忆
心跳超时 ≠ TCP Keepalive
- Keepalive 默认 2 h,对秒级故障无感;
- 自研心跳 20 s 无响应即触发
channel.close(),配合IdleStateHandler零额外线程。
消息重试与幂等
- 客户端带
msgId去重,服务端用Redis SETNX做去重窗口 30 s; - 防御性:即使重复推送,也回
ACK但不落库,避免“重复发货”舆情。
- 客户端带
灰度迁移
- 新集群预热 30 % 连接后,在网关层按
userId % 100切流; - 旧连接不下线,待其自然心跳超时,全程用户无感知。
- 新集群预热 30 % 连接后,在网关层按
性能优化:把硬件榨到最后一滴
Off-Heap 内存
- Netty 默认
PooledByteBufAllocator,开启-Dio.netty.allocator.type=pooled+-XX:MaxDirectMemorySize=8g,把缓冲区搬到堆外,GC 停顿从 120 ms→20 ms。
- Netty 默认
Linux 内核调优
ulimit -n 1000000打开文件句柄;net.core.somaxconn = 32768扩大全连接队列;net.ipv4.tcp_tw_reuse = 1快速回收 TIME_WAIT。
编解码
- 使用
Protobuf替代 JSON,包大小降 60 %,CPU 降 25 %; - 对超大消息(>64 KB)开启
CompressionHandler,阈值动态可配。
- 使用
延伸思考:Serverless 时代,连接怎么管?
Serverless 的“实例随时冻结”天然与长连接冲突,但也不是无解:
- 把连接状态外置到 Redis Stream,函数实例只负责计算,不 hold 连接;
- 用 Event-Bridge 做事件总线,WebSocket 网关独立常驻,函数按需弹缩;
- 冷启动提前预热 + 池化 Proxy,可将“连接漂移”降到 5 s 内。
目前我们还在 PoC 阶段,欢迎一起踩坑交流。
写完回头再看,这套“多多智能客服 API”已稳定跑过 618、双 11 两场大促,峰值 120 万在线,P99 延迟 180 ms,全年可用性 99.97 %。代码和压测脚本都开源在内部 GitLab,如果你也在做高并发长连接,希望这份笔记能帮你少走点弯路。