最近读到一篇很有意思的文章,作者 nolen royalty[1] 在开发一个基于 SSH 的高性能在线游戏时,发现了一个诡异的性能问题—— 一次按键竟然会触发 100 多个数据包。这背后的原因让我也学到了不少东西,记录分享一下。
作者在用 Go 的 bubbletea[2] + wish[3] 构建一个通过 SSH 运行的 TUI 游戏,性能目标是支持 2000+ 并发玩家。
为了做压力测试,作者写了个测试工具,用几百个 bot 通过 SSH 连接到服务器,模拟玩家每秒操作一次。正常情况下,服务端需要给每个 bot 渲染一个 80×60 的游戏画面,每秒更新 10 次——这是主要的 CPU 和带宽消耗。
有一天,测试工具出了个 bug:服务端没有给 bot 发送正常的游戏画面,而是只回了一条 "your screen is too small" 的错误信息就不再更新了。按理说,既然不用渲染游戏画面了,CPU 和带宽应该接近零才对。但实际上,它们只降了大约一半。
这就很奇怪了——游戏数据完全没发,CPU 用量为什么还有 50%?剩下的开销到底花在哪了?
用 tcpdump 抓一次按键的数据包,结果非常出乎意料:
$ ./summarize_pcap.sh single-key.pcap
Total packets: 270
36-byte msgs: 179 packets ( 66.3%) 6444 bytes
Other data: 1 packet ( 0.4%) 564 bytes
TCP ACKs: 90 packets ( 33.3%)
Data packet rate: ~90 packets/second (avg 11.1 ms between data packets)
按一个键,产生了 270 个数据包!其中 179 个是神秘的 36 字节消息,大约每 11ms 发送一个。真正的"有效数据"只有 1 个包(564 字节),其余全是 36 字节的"不知道干什么用的"包。
最开始怀疑的方向有几个:
关键线索是:这些 36 字节的包全部是由客户端发起的,而不是服务端。
用普通的 ssh 会话测试了一下,问题依然存在——说明和游戏框架无关,是 SSH 客户端本身的行为。
最终,ssh -vvv 的调试输出给出了答案:
debug3: obfuscate_keystroke_timing: starting: interval ~20ms
debug3: obfuscate_keystroke_timing: stopping: chaff time expired (49 chaff packets sent)
debug3: obfuscate_keystroke_timing: starting: interval ~20ms
debug3: obfuscate_keystroke_timing: stopping: chaff time expired (101 chaff packets sent)
2023 年,OpenSSH 引入了一个叫做 按键时序混淆(Keystroke Timing Obfuscation) 的安全功能。
原理很简单:攻击者可以通过分析 SSH 数据包的时序模式,推断出你按了哪些键。比如你输入密码的时候,每个字符之间的间隔时间是不同的,这个时序信息就是一种侧信道。
OpenSSH 的解决方案是:在你按键的同时,发送大量干扰包(Chaff Packets),把真实的按键时序淹没在噪声里。这些干扰包其实就是 SSH2_MSG_PING 数据包,每隔约 20ms 发送一次。
当服务端宣告支持 [email protected] 扩展时,客户端就会自动开启这个功能。
对普通 SSH 会话来说,这是非常有意义的安全特性。但是对于一个面向整个互联网、延迟至关重要的在线游戏来说,这些额外的包带来了巨大的性能开销。
客户端可以通过配置 ObscureKeystrokeTiming=no 来禁用这个功能。但这不现实——你不可能要求所有玩家都去改 SSH 配置。
那能不能从服务端禁用?
作者先问了 Claude Code,得到的回答是"不可能"。但通过阅读 Go 的 crypto/ssh 库源码,他发现:客户端是否开启这个功能,取决于服务端是否在握手阶段宣告了 [email protected] 扩展。也就是说,只要服务端不宣告这个扩展,客户端就不会发干扰包。
最终方案是 fork Go 的 crypto 库,去掉对 [email protected] 扩展的支持声明,然后用 go.mod 的 replace 指令替换依赖。
效果立竿见影:
Total CPU: 29.90% → 11.64%
Syscalls: 3.10s → 0.66s
Crypto: 1.6s → 0.11s
Bandwidth: ~6.5 Mbit/sec → ~3 Mbit/sec
CPU 直接砍半,syscall 开销降了 80%,带宽也几乎减半。
当然,fork 标准库的 crypto 包需要持续跟进上游更新,维护成本不低,这是一个需要权衡的点。
这篇文章里另一个有意思的点是作者对使用 LLM 辅助调试的反思:
LLM 在分析 tcpdump 输出、提供初步方向方面确实很有用,但在关键判断上,人的直觉和对问题的深入理解仍然不可替代。
[email protected] 扩展,可以让客户端自动关闭此功能[1]nolen royalty: https://blog.nolen.dev/
[2]bubbletea: https://github.com/charmbracelet/bubbletea
[3]wish: https://github.com/charmbracelet/wish
[4]原文: SSH sends 100 packets for every keystroke: https://blog.nolen.dev/posts/ssh-sends-100-packets-per-keystroke
[5]OpenSSH Keystroke Timing Obfuscation: https://www.openssh.com/releasenotes.html
[6]bubbletea - TUI 框架: https://github.com/charmbracelet/bubbletea
[7]wish - SSH server for Go: https://github.com/charmbracelet/wish