开篇:传统轮询为什么撑不住 10 万并发?
做智能客服最怕的不是用户问得刁钻,而是用户突然“消失”。
老项目里我们曾用最简单的setInterval每 500 ms 扫一遍内存 Map,结果上线第三天就炸了:
- 8 G 老年代堆内存被
TimerTask占满,FGC 每 30 秒一次; - 单核 CPU 空转 70 %,只为判断“现在距上次消息有没有 60 s”;
- 更惨的是,缩容时进程直接退出,内存里的会话状态全丢——客服同学只能盲打续上。
一句话:轮询 = 内存泄漏 + 无效 CPU 消耗 + 状态丢失。
要优雅地“提醒”沉默用户,先得把“检测”这件事从进程内存里挪出来。
技术选型:WebSocket、SSE 还是 Redis 过期?
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| WebSocket 长连接 + 心跳 | 双向实时,浏览器兼容好 | 需要自己做 pong 超时,连接池打满后 FD 耗尽 | 高频双向对话 |
| SSE(Server-Sent Events) | 基于 HTTP/1.1,复用连接,轻量 | 仅服务端推送,断线重连策略复杂 | 单向推送、H5 轻客服 |
进程级setTimeout | 编码简单 | 内存泄漏、分布式下重复触发 | Demo 级流量 |
| Redis 键过期事件 | 毫秒级通知、无状态、自带 TTL | 需开启notify-keyspace-events、对 Redis 负载有要求 | 高并发、弹性扩缩 |
结论:
高并发场景下,把“超时检测”外包给 Redis,再用消息队列解耦“引导消息”的发送,是目前性价比最高的组合。
核心实现:Redis 过期事件驱动
1. 开启 Redis 键空间通知
# redis.conf 或 CONFIG SET notify-keyspace-events ExE表示键事件,x表示过期事件。- 只监听
__keyevent@*__:expired可显著降低 Redis 内部 Pub/Sub 流量。
2. 会话 TTL 设计
key 格式:session:{userId} value:任意非空(节省内存) TTL:60 s每次收到用户消息:
# Python 示例(redis-py 4.x) pipe = redis.pipeline() pipe.set(f"session:{uid}", 1, nx=True, ex=60) # 新建会话 pipe.expire(f"session:{uid}", 60) # 续期 pipe.execute()时间复杂度:
SET+EXPIRE合并指令 = O(1) 网络 RTT,用 pipeline 打包后 1 次 RTT。- 内存占用:value 仅 1 字节,10 M 会话 ≈ 10 MB,可完全放入单节点 64 G 内存。
3. 监听过期事件(Python)
import redis, json, logging, os from kafka import KafkaProducer r = redis.Redis(host='rds', decode_responses=True) producer = KafkaProducer( bootstrap_servers=os.getenv("KAFKA_BROKERS").split(","), value_serializer=lambda v: json.dumps(v).encode()) def listen_expired(): pubsub = r.pubsub() pubsub.psubscribe('__keyevent@*__:expired') for msg in pubsub.listen(): if msg['type'] != 'pmessage': continue key = msg['data'] # session:123456 if not key.startswith("session:"): continue uid = key.split(":", 1)[1] # 幂等 key:用 userId + 分钟级时间窗口 dedup_key = f"dedup:{uid}:{int(time.time())//60}" if r.set(dedup_key, 1, nx=True, ex=120): # 2 分钟过期 producer.send("timeout-guide", {"uid": uid, "ts": time.time()})异常处理:
redis.ConnectionError→ 指数退避重连,最大 5 次。- Kafka 生产失败 → 本地磁盘日志兜底,后续脚本补偿。
4. 监听过期事件(Node.js)
// ioredis 5.x const Redis = require('ioredis'); const { Kafka } = require('kafkajs'); const redis = new Redis({ host: 'rds' }); const kafka = new Kafka({ brokers: process.env.KAFKA_BROKERS.split(',') }); const producer = kafka.producer({ maxInFlightRequests: 1, idempotent: true }); (async () => { await producer.connect(); const sub = new Redis({ host: 'rds' }); sub.psubscribe('__keyevent@*__:expired'); sub.on('pmessage', async (pattern, chan, key) => { if (!key.startsWith('session:')) return; const uid = key.slice(8); const dedupKey = `dedup:${uid}:${Math.floor(Date.now()/60000)}`; const ok = await redis.set(dedupKey, 1, 'NX', 'EX', 120); if (ok) { await producer.send({ topic: 'timeout-guide', messages: [{ value: JSON.stringify({ uid, ts: Date.now() }) }] }); } }); })();连接池:
- ioredis 默认自带连接池;
- 另起一个
sub实例专门订阅,避免与正常命令复用产生背压。
5. 消费端发引导消息
以 Python Faust 为例:
import faust app = faust.App("guide", broker="kafka://kafka:9092") guide_topic = app.topic("timeout-guide") @app.agent(guide_topic) async def send_guide(stream): async for event in stream: uid = event["uid"] await im_api.send(uid, "还在吗?小扣子等你继续提问哦~")解耦好处:
- 若引导文案需要调用 NLP 生成,可独立扩容消费者组;
- 出现消息堆积时,Kafka 自带背压控制,不会打爆客服后台。
性能优化:10 万并发压测报告
测试环境:
- Redis 6.2 集群,8 分片,每片 4 核 8 G;
- Kafka 3 节点,版本 2.8,副本因子 2;
- 客户端 8 台 4 核 8 G Pod,单进程 1000 协程。
结果:
- 同时在线 100 k 会话,心跳续期 QPS ≈ 8 万(每用户 60 s 一次续期);
- Redis CPU 峰值 42 %,内存 1.2 G;
- 过期事件 Pub/Sub 消息量 1.6 k/s,无可见延迟;
- Kafka 端到端平均延迟 7 ms,P99 28 ms;
- 消费端 3 副本,CPU 占用 0.4 核,即可保持 0 积压。
结论:Redis 键过期事件在 10 万级并发下,瓶颈首先出现在网络带宽而非 CPU;
只要给 Redis 10 Gb/s 网卡,轻松撑到 50 万会话。
分布式时钟同步:别让 NTP 误差坑你
当用户跨机房漂移时,可能出现:
- 客户端时间跳变,导致本地心跳 58 s 被误判为 62 s;
- 多节点 Redis 过期事件重复投递。
解决思路:
- 所有 TTL 续期以 Redis 服务器时钟为准,业务节点不本地计算;
- 幂等键
dedup:{uid}:{分钟级时间窗口}已天然防重; - 若业务对“绝对 60 s”敏感,可在 value 里写入服务器
unix_ts,消费端再校验偏差 >2 s 则丢弃。
避坑指南
重复触发
- 过期事件在 Redis Cluster 里由主节点发出,failover 后可能重放;
- 幂等键
SET NX必须设置合理过期(≥ 2 分钟),否则网络抖动会出现“双推”。
移动端网络抖动
- 心跳包可能 3 s 才到,导致 TTL 被续得“太晚”;
- 解决:客户端发送应用层心跳(如
ping帧),间隔 15 s;服务端只要 60 s 内收到任意帧即续期,不依赖 TCP 是否活跃。
Redis 负载飙高
- 如果同一模板大量设置 60 s TTL,会集中过期造成expire cascade;
- 给 TTL 加随机 jitter(60–65 s),可把峰值摊平 8 % 左右。
背压控制
- Kafka 生产者
max.in.flight.requests=1+ 幂等,避免乱序; - 消费端用
pause_partitions动态降速,防止把 IM API 打挂。
- Kafka 生产者
可扩展:动态超时阈值怎么玩?
把 60 s 写死显然不够灵活:
- 新用户可能 30 s 就流失,老用户能等你 3 分钟;
- 大促高峰,客服人手紧,阈值想统一调成 90 s。
思路:
- 将会话维度标签(新/老、VIP/普通)写入 Redis Hash;
- 过期前 10 s 用 Redis Lua 脚本读取标签,动态计算
adaptive_ttl; - 若决定“再等等”,调用
EXPIRE重新设 30 s,否则让 key 自然过期触发引导。
Lua 伪代码:
-- KEYS[1] = session:uid local ttl = redis.call("TTL", KEYS[1]) if ttl < 10 then local tag = redis.call("HGET", KEYS[1] .. ":meta", "tag") local extra = tag == "new" and 30 or 0 redis.call("EXPIRE", KEYS[1], ttl + extra) end通过把“是否再续”前置到 Redis,避免无效引导,也减少 Kafka 消息量 15 %。
小结
用 Redis 键过期事件替换轮询,我们让“1 分钟无交互提醒”从业务代码里彻底解耦:
- 检测精度 1 ms 级,CPU 下降 90 %;
- 状态挪出进程,水平扩容无状态;
- 通过 Kafka 消费组,引导文案可实时灰度。
如果你也在为客服系统的“沉默用户”发愁,不妨把超时这件事交给 Redis,让代码只关注“说什么”,而不是“什么时候说”。
思考题:
当业务需要“千人千面”的超时阈值时,你会把自适应算法放在 Redis Lua、消费端,还是独立规则引擎?
欢迎留言聊聊你的方案。