别拿内置 hash 做持久化:字符串哈希跨进程通常对不上
一份隔天就对不上的记录
把一批字符串的内置 hash 值算出来存进库,第二天拿新算的值去比对,结果全军覆没。不是时间的问题,是进程的问题:换个进程就对不上,哪怕只隔一秒。结论先放这儿:内置 hash 不该用于持久化,要跨进程复现的值得用摘要算法。
解释器给字符串和字节的哈希掺了随机盐,由环境变量 PYTHONHASHSEED 决定,不设就随机生成。哈希随机化自 Python 3.3 引入,PEP 456(3.4)改用 SipHash 并沿用密钥化盐,目的是防哈希碰撞拒绝服务:哈希可预测的话,攻击者能造出一批键撞同一个桶,把插入拖成平方时间。
实测环境是本机 CPython 3.13.14 / win32,sys.hash_info 显示 algorithm 为 siphash13,sys.flags.hash_randomization 为 1。
三次独立运行的实测
字符串 hello world,三个独立进程各算一次,再固定随机种子各跑两次:
默认运行 A/B/C 三个值互不相同(随机盐产物,每次都变,不列具体数值)
固定种子 0 两次 -5642461784034726774 / -5642461784034726774
固定种子 1 两次 4697380503286254714 / 4697380503286254714
默认种子下三次结果互不相同,内置 hash 不能跨进程指望一致;种子固定后两次运行完全一致。要注意默认那组值任何人都复现不出来,能当证据的只是「互不相同」这个关系,以及固定种子与后面 sha256 那两组可复现的值。
落库场景端到端复现
把三个业务键的 hash 写进文件,模拟第二天换个进程再比对:默认种子下命中 0 / 3;两端都固定成 PYTHONHASHSEED=0 重做,命中 3 / 3;只固定读端仍是 0 / 3。写入端就该用稳定摘要,而不是靠两端都固定种子来兜。
哪些会变,哪些不会
常见类型都过一遍,三个独立进程各算一次:
字符串 "hello world" / 字节 b"hello world" / 含字符串的 frozenset 每次不同
整数 123456 恒为 123456
浮点 1.5 恒为 1152921504606846977
浮点 nan 每次不同
盐只注入字符串和字节的哈希(以及少数日期时间类型);含字符串的容器是盐顺着元素传播,整数、布尔这些按值派生的类型跨进程恒定,元组里塞了字符串就又随机化。
浮点是例外:1.5 和 inf 恒定,nan 不行。nan 每次都不等于自己,浮点哈希落到按对象身份算的那条路上,三次各不相同,这是另一个来源,不是字符串盐。
None 也是特例,哈希恒为 4238894112(十六进制 0xFCA86420),是 CPython 写死的常量。对照 hash(object()),那个才真由对象地址派生(取 id 移位再混淆,不等于直接返回 id()),三次都不同。判断空值用 is None 就行。
整数哈希:取模之外还有一处特判
整数这边,hash 不总是等于值本身,超出范围会折返:
hash(2**61 - 1) = 0
hash(2**61) = 1
hash(2**62) = 2
hash(2**64) = 8
整数哈希被折到 2 的 61 次方减 1 的范围内,超出就取模。负数不是 Python 的取余语义,而是先取绝对值、对该模数取模再取负号:
hash(-3) = -3
hash(-1) = -2
hash(-(2**61)) = -2
区别很实在:按 Python 的 % 算,-3 取余是 2305843009213693948,而实测 hash(-3) 就是 -3。再看 -1:C 层的 PyObject_Hash 用 -1 作错误哨兵,所以这个合法哈希值被改写成 -2(Python 层 hash() 出错是抛异常,并不返回 -1)。hash(-(2**61)) 取模后也落到同一处,同样改写成 -2。这两处不是取模能解释的,是单独做的特判。
这个范围只管整数。字符串走 siphash13,输出完整 64 位有符号数。
字符串和字节:按内部表示而非 UTF-8
一种常见误解是字符串按 UTF-8 编码后再算哈希,实测对 ASCII 成立、对非 ASCII 不成立:
hash("hello world") == hash(b"hello world") True
hash("中文") == hash("中文".encode("utf-8")) False
hash("中文") == hash("中文".encode("utf-16-le")) True
hash("😀") == hash("😀".encode("utf-32-le")) True
规律是按字符串的内部存储表示走的,整串统一一种宽度,由串里最大的码位决定:字符都在 U+00FF 以内按单字节算(等价于 latin-1);出现 U+0100 到 U+FFFF 之间的字符按双字节算(这一档严格说是 UCS-2 表示,字节等价于 utf-16-le);出现 U+10000 及以上按四字节算(等价于 utf-32-le)。实测 "a中" 走 utf-16-le 而非 latin-1,说明整串统一而非逐字符切分。
所以拿字符串和字节的哈希做比对,条件不是是不是 ASCII,而是字节串恰好等于该字符串的内部表示字节。ASCII 只是恰好同时等于 latin-1 和 utf-8 两档。
顺序漂不漂,取决于键是不是字符串
哈希随机化最直接的连带影响是集合迭代顺序。含字符串的集合每次运行顺序都可能不同,用 PYTHONHASHSEED=0 再跑两次就一致(具体顺序同样是随机产物,这里不列)。基准元素集是 alpha 到 theta 八个,字典保持这些键的插入顺序,而这个顺序是从集合继承来的。
但漂移不是集合的通病,键的类型才是关键。换成纯整数集合 {1,3,5,7,9},三次迭代都是 1, 3, 5, 7, 9;整数字典字面量三次也一致。小整数的哈希就等于它本身,落桶确定,加上插入历史相同,顺序才确定。直接写字典字面量同样稳定,插入顺序在源码里写死了。
落到工程上,常见症状是文件内容变了对不上:把集合转成字典再序列化成 JSON,同一份输入两次产出的字节流不一样,diff 全是噪音。要抓的是数据来源,往往不是字典有问题,而是上游喂进来的是集合。
需要稳定摘要就用摘要算法
要一个能跨进程、跨天复现的值,别用内置 hash,用标准库里的摘要算法:
import hashlib
h = hashlib.sha256("hello world".encode("utf-8")).hexdigest()
print(h)
三次独立运行结果一致,得到 b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9。编码成字节再算,任何机器上都是同一串十六进制。
想短一点可以截断 sha256,位数按数据量和生日界估算碰撞概率。md5 有已知碰撞弱点,只在输入可信、不存在对抗性构造的场景下用于分片去重这类非安全用途。
需要固定内置哈希顺序时也可以显式设 PYTHONHASHSEED,但那只适合测试里复现问题;若字符串键来自不可信输入,生产环境固定它会失去这层碰撞攻击防护。
内置 hash 服务进程内的字典查找,要的是快和防攻击;摘要算法服务持久化和校验,要的是稳定可复现。名字都叫哈希,用途完全不同,混用起来就会出现开头那种存进去好好的、换个进程对不上的怪事。
- 点赞
- 收藏
- 关注作者
评论(0)