普通dict在千万级字符串键下内存爆炸,因每个字符串键单独存储且无法复用前缀,cpython中每个str对象至少49字节开销,叠加哈希表冗余,导致1–2gb内存占用。

为什么普通 dict 在千万级字符串键下内存爆炸?
Python 的 dict 为每个键单独存储一份字符串对象,即使大量键共享相同前缀(比如 "user:123"、"user:456"),也无法复用底层字符数据。CPython 中每个 str 对象至少有 49 字节开销(含引用计数、类型指针、长度、哈希缓存等),加上字典自身的哈希表槽位(通常要预留 1/3 空闲),千万级键轻松吃掉 1–2 GB 内存——而这其中大量是重复的字符串结构开销。
用 intern() 强制字符串驻留,但必须满足三个前提
sys.intern() 能让相同内容的字符串共享同一对象,大幅减少重复字符串内存。但它不是万能的:
- 键必须是编译期已知或运行时稳定不变的字符串(不能是拼接中间结果、带变量格式化后的临时串)
- 所有键需在插入字典前统一调用
sys.intern(),否则后续查找会因对象不一致而失败("user:123"和intern("user:123")是两个不同对象) - 全局驻留表本身有锁,高频并发插入可能成为瓶颈;且驻留的字符串永不释放,适合长生命周期键,不适合频繁增删场景
示例:
import sys
keys = [f"user:{i}" for i in range(10_000_000)]
# 必须提前 intern 所有键
interned_keys = [sys.intern(k) for k in keys]
d = {k: i for i, k in enumerate(interned_keys)} # 内存可降约 30–50%
改用 marisa-trie 或 dawg 实现前缀共享压缩
当键具有强结构性(如 URL 前缀、ID 编号、路径模板),marisa-trie 或 dawg 能把千万级字符串压缩到百 MB 级别,同时支持 O(k) 查找(k 为键长度)。它们本质是有限状态机,共用公共前缀的字节存储。
-
marisa-trie更成熟,支持只读加载、序列化快,但不支持动态插入;适合配置类、词典类场景 -
dawg(如pydawg)内存略高但支持部分更新,对 Unicode 友好性稍差 - 注意:它们不兼容原生
dict接口,需用trie[key]或trie.get(key),且无法直接用in判断存在性(得用key in trie.keys()或trie.has_keys_with_prefix(key))
安装与基础用法:
pip install marisa-trie
import marisa_trie
keys = [f"user:{i}" for i in range(1_000_000)] # 注意:实际建议分批构建
trie = marisa_trie.Trie(keys)
print(trie["user:123"]) # 返回该键在原始列表中的索引,非值本身 —— 你得自己维护 value 数组
真正省内存的方案:放弃 dict,改用 key → offset 映射 + mmap 文件
如果键值对极少修改、查询为主,且 value 是定长或可序列化结构,最激进但最省内存的方式是:把所有键写入一个排序好的文本文件(每行一个键),用二分查找定位行号,再通过预存的偏移数组或 mmap 直接跳转到对应 value 的二进制位置。
- 键文件可 gzip 压缩后
mmap,内存占用≈解压后单行长度 × log₂(N),千万级约 30–40 MB - 避免 Python 对象开销,value 存为 struct.pack 格式,读取时
struct.unpack_from(buf, offset) - 难点在于构建阶段需要排序、生成偏移索引、处理变长 value 的对齐;且不支持任意顺序插入
关键提示:别试图用 __slots__ 或弱引用优化 dict 本身——这些对字符串键完全无效;真正起效的是从数据结构层面切断字符串对象冗余。
Python免费学习笔记(深入):立即使用
在学习笔记中,你将探索 Python 的核心概念和高级技巧!











