一个后端程序员在微服务启动时,照常写了 ToImmutableDictionary(),手指在键盘上停住了。一千个 feature flag 的映射表,建一次,之后每次请求都要读,从不改动——用“不可变”听起来天经地义。但他突然冒出 2023 年就该问的问题:“不可变”等于“读得快”吗?还是只是我喜欢这个词?
他拉出 .NET 里的三种容器,同样的 1,000 条类似 flags:checkout:variant-0042 的键值对,都用默认的序数字符串比较器,没使任何技巧:
- 普通的可变字典
var dictionary = new Dictionary(pairs); - 不可变字典
var immutable = pairs.ToImmutableDictionary(); - 冻结字典
var frozen = pairs.ToFrozenDictionary();(来自 .NET 8 新增的 System.Collections.Frozen)
测试循环用 4,096 个预置的乱序键反复查找,分支预测器没法偷背,再用求和校验让 JIT 编译器不能把活干掉。每轮 1 千万次查找,取五轮里最好的一轮。不在意纳秒的绝对值,只看比例。
所有键都存在时,结果让他倒吸一口气:
容器每次查找耗时 普通字典 Dictionary18.0 ns 不可变字典 ImmutableDictionary86.7 ns 冻结字典 FrozenDictionary11.9 ns
冻结字典比普通字典快了大约 1.5 倍,还不错。但中间那一行——他专门为只读数据用的不可变字典——比冻结字典慢了 7 倍以上,比最普通的可变字典慢了近 5 倍。他原本以为这是升级,结果反而倒退。
更扎心的是,这根本不是 bug,而是他从未去理解的底层设计。不可变字典不是一个恰好不可变的读优化字典,而是一棵散列特里树,建造成把这个字典“加一个条目后复制一份”变得便宜,且不会扰动原始结构。每次查找都要沿着指针在树节点间跳动,用户为“带修改的复制”能力支付了每次读取的成本,而这份能力在启动后就再也没用过。
冻结字典走了相反的取舍。ToFrozenDictionary() 在构造时会花时间检查实际的键集合——对字符串而言,它能找出区分整个键集的关键几个字符,只做最少量的散列运算——然后为这批特定数据挑选专门的内部布局。所有巧思只花一次,在建的时候。所以那个必须问的问题就是:这个“冻结费”到底多贵?
构造 1,000 个条目,平均做 2,000 次构建:
构造方式平均耗时 new Dictionary(pairs)21.8 µs ToImmutableDictionary()211.4 µs ToFrozenDictionary()166.8 µs
冻结 1,000 条记录的成本大约是直接 new 一个普通字典的 8 倍,约多花 145 微秒。与不可变字典的构造耗时相比,冻结反而还快一些。对于启动时只跑一次的操作,这点开销完全值得;而每请求上百万次的查找里,冻结节省的数十纳秒累积起来就非常可观。
同一个开发者后来在复盘时写道:他过去用不可变字典只是因为它叫“不可变”,听起来就是只读场景的正确答案。但性能测试说明,一个专门为“复制并加一条”做优化的结构,根本不应该用在“建一次、读一生”的静态表上。冻结字典把复杂度推到构造阶段,换来了最简单的读路径,这才是只读压力下的最优选择。
热门跟贴