ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python hashlib 完整指南:哈希算法、文件校验与密码存储

Python hashlib 完整指南:哈希算法、文件校验与密码存储 我见过不少 Python 开发者写了几年代码天天和字符串、列表、字典打交道但一提到 hashlib 就只记得哦用来算 MD5 的。说实话这个模块远不止这么简单。我第一次真正重视它是因为要给一个几十 GB 的数据库备份文件做完整性校验——当时要是把整个文件一次性读进内存机器直接卡死而用 hashlib 分块计算哈希内存占用几乎可以忽略不计。从那时起我就把这个标准库当成自己工具箱里的常备成员。hashlib 是 Python 自带的标准库不需要 pip install也不需要额外配置环境装好 Python 就能用。它能做的是把任意长度的数据字符串、文件、二进制流通过哈希算法计算成固定长度的摘要值。你可能在很多场景里已经间接用到过它下载安装包时校验 SHA256、在爬虫里去重 URL、做接口签名、给 DataFrame 的行生成唯一标识。这篇内容我会站在实际开发的角度把 hashlib 的 API、算法选型、安全注意事项和典型实操场景完整梳理一遍既适合刚入门 Python 的新手也能帮有经验的开发者避开几个我踩过的坑。1. hashlib 是什么先搞清楚它到底解决什么问题1.1 从一次文件校验说起很多人的第一个哈希计算需求其实都来自下载了一个安装包怎么确认它没被篡改、没下载损坏。我前几年负责维护一批内部工具的分发团队同事经常反馈解压报错程序起不来。排查到最后绝大多数情况是文件在传输过程中丢了几 KB或者磁盘写入时出了问题。后来我统一在所有下载页面里附上 SHA256 摘要同时写了个小脚本让同事自行校验。这个脚本的核心就两行import hashlib with open(package.tar.gz, rb) as f: digest hashlib.sha256(f.read()).hexdigest() print(digest)这就是 hashlib 最基础的用法。它接收二进制数据计算出一个固定长度的十六进制字符串拿这个字符串和官方提供的摘要比对一致就说明文件完好。这个场景背后其实是一个通用需求如何快速判断一段数据是否与原始数据一致。哈希算法就是干这个的。1.2 哈希算法与加密算法的本质区别很多人会混淆哈希和加密我要先把这层窗户纸捅破。加密算法如 AES、RSA是可逆的。加密后的数据可以通过密钥解密还原出原始明文。哈希算法如 MD5、SHA256是不可逆的。输入任意长度的数据输出固定长度的摘要从摘要无法反推出原始数据。生活化类比就是哈希是一台碎纸机文件进去之后变成一堆碎屑你不可能把它们拼回原样加密是一个保险柜锁上之后只有钥匙能打开。哈希有一个重要特性叫雪崩效应哪怕输入只改一个比特输出的摘要也会面目全非。这个特性决定了它可以用来做完整性校验、数字签名、数据去重等等。另一个重要特性是固定输出长度。无论你算的是空字符串还是几个 TB 的虚拟机镜像SHA256 的输出永远是 256 位也就是 32 个字节转成十六进制就是 64 个字符。这点在数据库设计时特别有用你存储哈希值的字段长度是可以预先确定的。1.3 适用场景梳理结合我自己的项目经验hashlib 的典型应用场景有这么几类。数据完整性校验下载文件后比对摘要备份文件的定期抽检日志文件有没有被改动过。密码存储不直接保存明文密码而是保存密码加盐之后的哈希值。用户登录时重新计算哈希再和数据库里的比对。这个场景后面我会详细介绍普通哈希其实不够安全要用 PBKDF2 或 scrypt 这类慢哈希。数据去重与唯一标识在爬虫系统里对 URL 或页面内容取哈希可以作为去重指纹。在做数据分析时可以用哈希给拼接出来的复合字段生成一个稳定 ID。接口签名前后端联调时把请求参数、时间戳、密钥拼在一起算哈希防止参数被篡改。这类应用通常配合 hmac 模块而不是直接用哈希拼接密钥。分布式任务幂等用哈希给任务内容生成指纹调度系统很容易判断这个任务是不是已经处理过。2. 核心 API 与基本用法常用方法彻底搞明白2.1 如何创建哈希对象new() 与直接构造hashlib 里创建哈希对象有两种方式结果完全等价。import hashlib # 方式一直接调用具体算法的构造函数 h1 hashlib.md5() h1.update(bhello) print(h1.hexdigest()) # 方式二通过 new() 传入算法名 h2 hashlib.new(md5) h2.update(bhello) print(h2.hexdigest())我用 h1 的写法更多因为代码更短IDE 还能自动补全。但hashlib.new()有一个优势——算法名可以用变量传入。import hashlib algorithm sha256 h hashlib.new(algorithm) h.update(bhello) print(h.hexdigest())这在做配置化工具的时候特别有用。比如有一个配置文件里面写死了hash_algorithm sha256程序启动时读取这个值再调用new()这样就不用根据算法名写一堆 if-else。顺便提醒一句hashlib.new()传入不存在的算法名会直接抛出ValueError。所以如果你允许用户配置算法名一定要先把配置值和hashlib.algorithms_available比对一下。import hashlib alg sha256 if alg not in hashlib.algorithms_available: raise ValueError(fUnsupported algorithm: {alg})2.2 update() 的累加机制容易忽略的细节这是 hashlib 里最容易出问题、但也最强大的一个特性。update()方法是累加的不是覆盖。import hashlib h hashlib.sha256() h.update(bhello ) h.update(bworld) d1 h.hexdigest() d2 hashlib.sha256(bhello world).hexdigest() print(d1 d2) # True也就是说多次调用update()的效果等价于把拼接后的完整数据一次性传入。这有什么用对于大文件你可以分块读取、分块喂给哈希对象不需要把整个文件加载进内存。比如一个 5 GB 的文件你按 8 KB 一个块读入内存占用基本可以忽略。这一点我后面在实操部分会详细展开先记住这个结论流式处理是 update() 存在的根本理由。2.3 hexdigest() 与 digest()展示与存储该怎么取舍哈希对象算完之后有两种方式取出结果。import hashlib h hashlib.sha256(bhello) print(h.hexdigest()) # 十六进制字符串长度 64 print(h.digest()) # 原始字节长度 32hexdigest()返回的是十六进制字符串方便打印、存入文本型数据库、写进接口返回的 JSON。它的每个字节被拆成两个十六进制字符所以输出长度是原始字节长度的两倍。digest()返回的是原始二进制 bytes。它的优点是节省空间——32 字节的 SHA256 摘要如果用 hexdigest 存就是 64 个字符。但缺点是不方便直接阅读和传输存进数据库还要用 BLOB 类型打印出来也是一堆乱码。我个人的习惯是面向人展示用 hexdigest面向机器比较用 digest。比如两个哈希值做相等性判断直接用digest()比较字节既快又避免字符串大小写问题。注意hexdigest()是小写字符串如果你手头有全大写的 SHA256 摘要需要先lower()再比对。另外有个性能细节digest()比hexdigest()快因为后者需要在内部做一次字节到字符串的转换。如果是在循环里对大量短数据做哈希这个差异还是能体现出来的。2.4 编码问题为什么 hashlib 不接收字符串新手最常见的报错之一hashlib.sha256(hello) # TypeError: Unicode-objects must be encoded before hashing原因很简单哈希算法定义在二进制数据上它处理的是字节不是字符。中文字符串更是没法直接处理。所以传入之前必须把字符串编码成 bytes。import hashlib text 你好世界 h hashlib.sha256(text.encode(utf-8)) print(h.hexdigest())这里有几个细节要特别注意。第一编码方式必须统一。同一段文字用 UTF-8 编码和用 GBK 编码算出来的哈希值完全不同。如果服务端用 UTF-8客户端用 GBK两边签名永远对不上。跨系统、跨语言合作时我一般会在接口文档里明确写上字符串统一使用 UTF-8 编码后再计算哈希。第二文件读取要用二进制模式。如果open()里面用了rPython 会按文本模式去解码遇到编码不一致会报错或改变数据内容。正确做法是with open(file.bin, rb) as f: data f.read()我之前就因为读文件时用了r模式导致文件里的换行符在 Windows 上被自动转换算出来的哈希怎么都不对。折腾了半天才发现是模式的问题。第三字符串拼接要小心。如果你在拼接多个字符串时混进了 int 或者 Noneencode()会直接崩。一个稳妥的习惯是先全部转成 str 再拼payload f{app_id}:{timestamp}:{user_id}}.encode(utf-8)3. 算法选型MD5、SHA1、SHA256、SHA512 到底选哪个3.1 安全等级与破解现状每次聊到哈希算法都绕不开哪个更安全这个问题。我从实用角度给一个结论性建议MD5不要再用于任何安全场景。它的碰撞攻击在 2004 年就已经被公开2008 年前后已经能做到在普通电脑上快速构造碰撞。目前在正经项目里MD5 唯一还算合理的用途是非对抗性的数据指纹——比如爬虫去重、内容寻址、内部缓存 key因为在这些场景里攻击者不太会刻意构造碰撞。即便如此我在新代码里也更倾向用 SHA256。SHA1同样不推荐。Google 在 2017 年公开了 SHA1 碰撞的攻击演示已经证明它不再安全。如果你的代码里还在用 SHA1我的建议是尽快迁移。SHA256 / SHA512目前的主流选择。SHA-2 系列还没有已知有效的攻击方式广泛应用于数字证书、区块链、文件校验。BLAKE2 系列hashlib 里也内置了 blake2b、blake2s。它比 SHA256 更快安全性相当但生态兼容性不如 SHA-2。我在需要高性能哈希的时候会用它。下面的表格可以帮你快速决策算法输出长度安全状态典型用途MD5128 位不安全已碰撞内部去重、非安全指纹SHA1160 位不安全已碰撞老系统兼容SHA256256 位安全文件校验、数字签名、密码摘要载体SHA512512 位安全安全要求更高的场景BLAKE2b1~64 字节可选安全高性能哈希3.2 性能与长度的取舍SHA512 输出更长、理论上更安全但它并不是在所有场景下都比 SHA256 快。在 64 位系统上SHA512 用的是 64 位运算反而可能比 SHA256 更快因为 SHA256 用的是 32 位运算。但这个差异通常只有百分之几十的量级不影响绝大多数场景。我在实际做技术选型时遵循三条原则默认 SHA256因为硬件支持好、生态兼容广OpenSSL 层面对它做了很多优化。如果哈希对象是海量短字符串比如每秒几百万次优先考虑 blake2s它在短数据上性能优势明显。如果是为了满足某些审计或合规要求直接看对方要求什么算法不用自己纠结。另外提一句Python 的 hashlib 底层是调用 OpenSSL 的。当你执行hashlib.sha256()时本质上是在调 C 库里的实现因此很多处理器上的 AES-NI 和 SHA 硬件加速指令会被自动利用起来。单独在 Python 里做性能优化往往不如让底层帮我们做来得快。3.3 慢哈希PBKDF2 与 scrypt 什么时候用这节的内容很重要尤其是做 Web 开发的同学。普通哈希SHA256计算速度非常快。快在文件校验、接口签名里是好事但用在密码存储里就是灾难——因为攻击者也可以用同样的速度暴力猜测密码。普通显卡每秒能跑几十亿次 SHA256任何六位数字密码在这种算力下都是形同虚设。所以存储密码时必须用慢哈希算法。它的设计理念就是故意让计算变慢消耗更多的 CPU 和内存从而限制攻击者的猜测速度。hashlib 里提供了两种import hashlib password bmy_password salt brandom_salt_value # PBKDF2-HMAC-SHA256 dk hashlib.pbkdf2_hmac(sha256, password, salt, iterations600000) print(dk.hex()) # scrypt dk2 hashlib.scrypt(password, saltsalt, n16384, r8, p1) print(dk2.hex())iterations越大计算越慢安全性越高但用户体验也越差。OWASP 在 2023 年之后给出的建议是 PBKDF2-HMAC-SHA256 至少 600,000 次迭代你可以用这个作为起点。scrypt 的参数 n、r、p 会在计算时占用内存这也让攻击者更难用专用硬件批量破解。我自己的建议是新项目直接上 scrypt生态成熟的项目如果已经在用 PBKDF2 就继续用但把迭代次数调高。这两个算法才是密码存储的正确答案后面实操部分我会给完整代码。4. 实操文件完整性校验与密码存储4.1 大文件分块计算哈希的完整实现回到我开头的场景——给几十 GB 的文件计算 SHA256。如果直接写with open(big_file.bin, rb) as f: data f.read() hashlib.sha256(data).hexdigest()文件会被整个读进内存。几十 GB 的文件在这样的代码下要么内存爆掉要么系统卡死。正确的做法是分块读取。import hashlib def sha256_file(file_path, chunk_size8192): h hashlib.sha256() with open(file_path, rb) as f: while True: chunk f.read(chunk_size) if not chunk: break h.update(chunk) return h.hexdigest()chunk_size 的选择其实有讲究。太小比如 256 字节循环次数太多CPU 上下文切换和函数调用开销增大太大比如 64 MB内存占用会高但对速度提升也不明显。我试过不少配置8192 到 65536 字节之间是比较均衡的范围。官方文档里常用 8192一般场景我直接用它。对比一下两种方式的内存占用1 GB 文件一次性读取要占至少 1 GB 内存分块读取只占几 KB。这个差距在服务器上尤为明显。如果你需要同时算多个文件的哈希还可以稍作扩展返回一个字典import hashlib from pathlib import Path def hash_directory(directory, chunk_size8192): result {} h hashlib.sha256() for path in Path(directory).rglob(*): if path.is_file(): result[str(path)] sha256_file(str(path), chunk_size) return result4.2 密码存储的安全姿势我见过很多初学者把密码直接用sha256(password.encode()).hexdigest()存进数据库。这个做法的问题有两个第一相同的密码会产生相同的哈希攻击者可以通过彩虹表直接反查第二SHA256 计算太快暴力破解成本太低。正确的姿势是每个用户密码都加一个随机盐再用慢哈希算法计算最后把盐和哈希一起存下来。下面是我实际项目里用到的一个简化版实现import hashlib import hmac import secrets def hash_password(password: str) - str: salt secrets.token_hex(16) dk hashlib.pbkdf2_hmac( sha256, password.encode(utf-8), salt.encode(utf-8), iterations600000, dklen32, ) return fpbkdf2_sha256${iterations}${salt}${dk.hex()}验证时从存储串里解析出盐、迭代次数和哈希重新计算后比对def verify_password(password: str, stored: str) - bool: algorithm, iterations, salt, expected stored.split($) dk hashlib.pbkdf2_hmac( algorithm.replace(pbkdf2_, ), password.encode(utf-8), salt.encode(utf-8), iterationsint(iterations), dklen32, ) actual dk.hex() return hmac.compare_digest(actual, expected)注意我用了一个非常有讲究的细节比对时用的是hmac.compare_digest()而不是普通的。普通字符串比较在遇到第一个不同字符时就会提前返回攻击者可以通过响应时间差异逐步猜出正确的哈希。compare_digest()无论输入差异在哪耗时都保持一致从时间侧封死了这个旁路攻击。这个函数里存下来的字符串格式很清晰pbkdf2_sha256$600000$salt$hash。这个格式有很强的兼容性和可迭代性。如果哪天想升级算法或者增加迭代次数解析字符串就能做平滑迁移。4.3 在数据分析与自动化脚本中的实战用法哈希不只是信息安全专属它在数据工程里也很常用。我在做报表自动化和数据处理时经常用它做几件事。第一给 DataFrame 的行生成稳定 ID。比如把多个字段拼成字符串后做哈希得到一个定长唯一键天然适合做分区键、关联键import hashlib import pandas as pd df pd.DataFrame({ user_id: [1, 1, 2], date: [2025-01-01, 2025-01-02, 2025-01-01], amount: [100, 200, 300], }) def row_hash(row): payload f{row[user_id]}|{row[date]}|{row[amount]} return hashlib.sha256(payload.encode(utf-8)).hexdigest()[:16] df[row_id] df.apply(row_hash, axis1)第二判断数据是否发生变化。我在做数据同步脚本时会对每张表的某几列拼接后取哈希存一张数据指纹表。跑批之前先比较指纹没变化的表直接跳过能省下不少计算资源。第三构建邻接矩阵时做节点映射。如果你在做图算法相关的事比如把文本类型的节点名转换成定长哈希就可以直接用哈希值或它的前 N 位作为矩阵索引避免维护一份手动的字符串到整数的映射表。哈希在这些场景下不是安全工具而是压缩、定长、去重、指纹的通用工具。5. 常见问题与踩坑记录5.1 两次算出来的哈希不一样这是我在社区里看到最多的问题通常有几种原因。第一个原因是编码不一致。前面已经说过UTF-8 和 GBK 算出来的摘要不同。同一个字符串如果一边用了utf-8另一边用了gbk或者干脆没 encode 直接报错结果自然对不上。第二个原因是文件读取模式。文本模式open(..., r)下Python 会自动处理换行符Windows 的\r\n会被转成\n。如果比对双方一个用了文本模式、一个用了二进制模式哈希永远不一致。统一改成rb就对了。第三个原因是文件内容真的变了。哈希对任何一位的变化都极其敏感下载中断、磁盘坏道、压缩包里有注释字段都会导致摘要不同。这种时候先别怀疑代码先重新下载一次再说。第四个原因是算法名写错。hashlib.new(sha-256)和hashlib.new(sha256)可不是一回事前者大概率直接抛 ValueError后者才是正解。一定要用hashlib.algorithms_available里列出的名字。5.2 update 调用顺序与结果因为 update 是累加的所以调用顺序会影响最终结果。import hashlib h1 hashlib.sha256() h1.update(ba) h1.update(bb) r1 h1.hexdigest() h2 hashlib.sha256() h2.update(bb) h2.update(ba) r2 h2.hexdigest() print(r1 r2) # False这个特性本身不是 bug但容易造成逻辑隐患。比如你分块读取文件时如果某些块被跳过或者重复读取最终哈希就可能不符合预期。所以我在分块逻辑里会加上一个计数器还可以打印进度import hashlib import os def hash_file_with_progress(file_path, chunk_size8192): h hashlib.sha256() file_size os.path.getsize(file_path) processed 0 with open(file_path, rb) as f: while chunk : f.read(chunk_size): h.update(chunk) processed len(chunk) # 这里可以接日志或进度条 return h.hexdigest()另一个比较隐蔽的问题是有人会把同一个哈希对象传给多个函数然后每个函数都执行 update导致最终结果混进了不想要的数据。我在早期做爬虫去重时就踩过这个坑——两个 URL 的指纹算出来一模一样排查了半天发现是共用哈希对象造成的。5.3 hashlib 对象的复制与序列化哈希对象是有内部状态的。有些场景需要基于同一份数据派生出多个哈希这里有两个工具copy()和重新构造。import hashlib h hashlib.sha256() h.update(bprefix_) h_copy h.copy() h_copy.update(bpart1) r1 h_copy.hexdigest() h.update(bpart2) r2 h.hexdigest()copy()复制的是当前状态相当于把已经 update 的数据全部带过去了。这在处理流式数据、需要并行派生时非常有用。关于 pickle标准的 hashlib 哈希对象能不能 pickle这个行为在不同 Python 版本和平台上并不完全一致。我的建议是不要把哈希对象直接存进 Redis 或者写进 pickle 文件如果非要持久化直接存hexdigest()的结果就好。因为我见过在某个 Python 小版本上能够 pickle换了版本后导入失败的情况。5.4 哈希性能优化与硬件加速哈希整体很快但大数据量下仍要注意几个优化点。第一避免一次性读入大文件。这不仅是内存问题也是对 GC 的折磨。大批量读入产生的临时字节对象会显著拉高 GC 压力。分块读取在这条上依然适用。第二减少 encode 的重复调用。如果你在循环里对几百个字段分别算哈希每个字段都调一次encode()这部分开销会被放大。更好的做法是把需要拼接的内容先合成一个字符串一次性编码payload |.join(parts) hashlib.sha256(payload.encode(utf-8)).hexdigest()第三了解底层硬件加速。CPython 的 hashlib 已经接入了 OpenSSL 和常见指令集加速所以你在 Python 层几乎不需要再优化。如果对性能有极致要求可以考虑用 blake2b它的纯软件实现效率很高。不过大多数场景下瓶颈根本不在哈希计算本身而在数据读取和网络传输。6. 一些使用习惯与经验总结6.1 封装自己的哈希工具类这几年的项目做下来我在代码库里沉淀了一个很轻量的工具封装推荐你也这样做。好处是以后换算法、调参数只改一个地方不用到处找散落的 hashlib 调用。import hashlib class HashUtil: DEFAULT_ALGORITHM sha256 staticmethod def text_hash(data: str, algorithm: str DEFAULT_ALGORITHM, encoding: str utf-8) - str: h hashlib.new(algorithm) h.update(data.encode(encoding)) return h.hexdigest() staticmethod def file_hash(file_path: str, chunk_size: int 8192, algorithm: str DEFAULT_ALGORITHM) - str: h hashlib.new(algorithm) with open(file_path, rb) as f: while chunk : f.read(chunk_size): h.update(chunk) return h.hexdigest() staticmethod def row_hash(*fields, algorithm: str DEFAULT_ALGORITHM) - str: payload |.join(str(field) for field in fields) return HashUtil.text_hash(payload, algorithm)这个工具类里的row_hash我很常用。无论是做数据去重还是生成行标识都只要调用一个方法代码干净很多。6.2 记住用 hmac 而不是手动拼接哈希如果你在做接口签名或者需要给消息做消息认证码有个原则一定要记住不要在哈希之前把密钥简单地拼接在数据上。SHA256 这类 Merkle-Damgård 结构的哈希存在长度扩展攻击的风险不恰当的拼接方式可能会被攻击者利用。正确做法是用 Python 标准库的hmac模块import hmac import hashlib key bsecret_key message bmessage signature hmac.new(key, message, hashlib.sha256).hexdigest()hmac 使用了一种先异或填充、再分两次哈希的结构从根本上规避了长度扩展问题。凡是涉及消息认证、接口签名的场景我都会直接用 hmac而不是把密钥拼在明文后面做哈希。6.3 我在实际项目中最常用的几个组合最后分享几个我反复使用的组合都是经过项目验证的。文件下载校验requests 下载时边下载边计算哈希最后和官方摘要比对。这样不需要把整个文件落盘后再去读内存可控流程也顺。配置指纹把配置文件内容做 SHA256启动时比对指纹决定是否要重新加载缓存。这个在微服务配置中心里很实用。Redis 幂等键用多个业务字段拼接后取 SHA256 前 32 位作为 Redis key既保证了键长度可控又避免过长的拼接串。数据血缘追踪给上游表的关键字段做哈希下游任务启动时校验及时发现数据源变更。哈希作为一种基础工具理解它的特性后能在大量场景中创造出很优雅的方案。我从一开始只会f.read()然后算 md5到现在能顺手处理各种大小文件、能正确设计密码存储逻辑中间踩过的坑不算少但都成了经验。如果你在项目里遇到哈希值怎么也对不上、签名字段被篡改、文件校验慢得离谱的问题相信我答案多半都在上面这些细节里。
返回列表