开篇传统轮询为什么撑不住 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任意非空节省内存 TTL60 s每次收到用户消息# Python 示例redis-py 4.x pipe redis.pipeline() pipe.set(fsession:{uid}, 1, nxTrue, ex60) # 新建会话 pipe.expire(fsession:{uid}, 60) # 续期 pipe.execute()时间复杂度SETEXPIRE合并指令 O(1) 网络 RTT用 pipeline 打包后 1 次 RTT。内存占用value 仅 1 字节10 M 会话 ≈ 10 MB可完全放入单节点 64 G 内存。3. 监听过期事件Pythonimport redis, json, logging, os from kafka import KafkaProducer r redis.Redis(hostrds, decode_responsesTrue) producer KafkaProducer( bootstrap_serversos.getenv(KAFKA_BROKERS).split(,), value_serializerlambda 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 fdedup:{uid}:{int(time.time())//60} if r.set(dedup_key, 1, nxTrue, ex120): # 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, brokerkafka://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 GKafka 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 msP99 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 加随机 jitter60–65 s可把峰值摊平 8 % 左右。背压控制Kafka 生产者max.in.flight.requests1 幂等避免乱序消费端用pause_partitions动态降速防止把 IM API 打挂。可扩展动态超时阈值怎么玩把 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、消费端还是独立规则引擎欢迎留言聊聊你的方案。