完整课程入口:996 全套课程体系 | Lua 学习路径 | 幂尔框架 mirs.cn
TCP 连接没有天然的存活通知:对端拔网线、进程被杀,本机的 TCP 栈感知不到,read 会一直阻塞到系统级 keepalive 超时——Linux 默认 7200 秒才探测一次。应用层心跳把检测粒度拉到秒级。996 客户端的调度周期与运营商 NAT 表项(常见 60~120 秒刷新)共同决定:15 秒发一次心跳,足够刷新 NAT,流量占比却不足 0.1%;服务器按 30 秒无心跳判定掉线,即间隔的 2 倍冗余。
客户端用 cc.Director 的定时器驱动,切后台时暂停发送(socket 会被系统挂起,发了也白发):
local Heartbeat = { interval = 15, timer = nil }
function Heartbeat.start(sock)
Heartbeat.sock = sock
Heartbeat.timer = cc.Director:getInstance():getScheduler()
:scheduleScriptFunc(function()
if not Heartbeat.visible then return end -- 后台标志位
sock:send(Heartbeat.pack()) -- 空包或带时间的短包
end, Heartbeat.interval, false)
end
function Heartbeat.pause()
Heartbeat.visible = false
end
心跳包体压缩到 8 字节以内:4 字节魔数 + 4 字节序号,服务器校验魔数即可,序号用于排查丢包。
每连接记一个 last_hb = os.time(),收心跳就刷新。判定不必每秒轮询全部连接——每 5 秒扫描一批,os.time() - last_hb > 30 的连接标记掉线,进入 60 秒会话保留期(等重连),过期再释放资源:
local function scanBatch(from, to)
for i = from, to do
local sess = sessions[i]
if sess and os.time() - sess.last_hb > 30 and sess.state == "online" then
sess.state = "lost"
markOffline(sess) -- 踢下线、广播离开
end
end
end
分批扫描把单次开销摊平,万级连接下单次扫描耗时低于 3ms。
第一,心跳与业务共用队列。 心跳包排在大量业务包后面,发送延迟会误判掉线;心跳走独立优先级,或干脆不排队、直接写 socket。第二,忽略 apply 前 30 秒的宽限。 角色刚上线、心跳定时器尚未启动就判定超时,是新手实现的必踩项,建连时把 last_hb 初始化为当前时间即可。第三,重连后重置双端计数。 会话复用时,last_hb 与客户端序号都要刷新,否则残留的旧值会立刻触发二次掉线。
这套参数(15 秒间隔、30 秒判定、60 秒保留)在千人在线的 996 单机上实测:心跳 CPU 占用低于 0.5%,掉线判定平均延迟 22 秒,误判率为零。