
做了几年 C 端后端有个问题几乎每次评审都会被翻出来——接口里直接返回自增 ID。用户 ID 是 1、2、3订单号是 10001、10002看一眼 URL 就知道平台体量有多大改一下参数就能遍历别人的数据。你跟他讲性能他说我就是要个不暴露业务的 ID你跟他讲 UUID他说主键太长影响索引。Hashids 就是在这个夹缝里长出来的方案把自增 ID 编码成一段无规律的短字符串业务侧还能解回来既不影响主键策略也不牺牲查询性能。这篇文章就围绕这个方案聊聊我是怎么在 C 端项目里落地 Hashids 的以及它解决了什么问题、在哪些场景下千万别用。我最早接触 Hashids 是因为一个用户邀请码的需求产品要求邀请码必须短、好读、不能是连续数字。当时调研了一圈发现 Hashids 在各语言都有官方实现API 几乎一样老项目迁过去成本很低。后来在多个 C 端服务里都用了它慢慢摸清了它的脾气哪些参数必须调哪些场景容易踩坑以及它跟真正的加密算法之间那堵绝对不能越过的墙。下面把完整经验拆开讲希望对正在做类似方案选型的人有帮助。1. 为什么 C 端自增 ID 是个问题Hashids 又能解决什么1.1 自增 ID 直接暴露的三个真实风险先把问题说透。C 端接口返回userId1024这种字段表面上是“多了一个数字”实际上至少暴露了三层信息业务规模泄露。注册类产品如果看到用户 ID 到了几百万竞对基本能估算出你的用户量级订单号如果是纯自增日单量、峰值单量都藏不住。可枚举抓取。自增 ID 天然连续攻击者把id1到id100000全跑一遍不需要任何漏洞就能批量扒到公开接口里的数据。就算每个接口都做了鉴权能遍历本身就是一种攻击面。越权漏洞放大器。很多系统在写update或delete逻辑时只校验登录态没校验资源归属比如“修改他人订单”这类越权。如果 ID 连续这种漏洞被利用的成本极低——随便改个数字就能试一次。就算你把归属校验做得滴水不漏ID 连续也让竞对和爬虫的日子过得太舒服了。与其赌“攻击者没耐心遍历”不如从源头让 ID 变得不可预测。1.2 常见的替代方案为什么各有各的别扭面对这个问题业界常用的手段大概有四种我一个个对比过UUID 主键。全局唯一、无需中心化生成但 36 位字符串做索引会让 B 树变得又宽又矮插入时随机写入导致页分裂频繁。在千万级数据量表上实测写入性能下降明显。最尴尬的是UUID 虽然不暴露自增关系但它本身也是个可枚举的字符串只是枚举难度大一点。雪花 ID 或自研分布式 ID。解决了性能问题但雪花 ID 的 64 位整数通常是个超大数字直接对外暴露比自增 ID 还容易推算——时间戳部分直接告诉别人你的系统什么时候上线的。数据库加一个随机字符串业务号字段。查询时要有唯一索引等于每张表多存一列多维护一套生成逻辑多一次索引查找。对称加密。对自增 ID 做 AES 加密再转字符串安全等级确实高但密文普遍很长而且前端拿到后需要特殊处理URL特殊字符可读性差。Hashids 走的是另一条路线它不是加密而是“编码”目的是让 ID 从“可预见的纯数字”变成“无规律的短字符串”同时支持反向解码回原 ID。后面我会详细讲它为什么在性能和安全之间找到了一个合适的平衡点。1.3 Hashids 到底解决了什么又没解决什么先给 Hashids 一个准确的能力边界。它能做三件事把1编码成类似jR的短字符串把2编码成K5肉眼完全看不出规律也看不出顺序。不同 ID 编码结果长度不固定进一步打乱规律性。通过内置解码方法把字符串还原成原始数字业务侧拿到数字 ID 走原有逻辑对数据库零侵入。但它不是万能的。它不提供真正的机密性——如果你把盐salt泄露了任何人写三行代码就能批量解码全部 ID。它也不解决越权——攻击者拿到jR后虽然不容易猜出下一个 ID 是什么但只要能通过某种途径拿到一个合法 ID该做的归属校验一样要做。我在项目里把 Hashids 定位成“防枚举、防泄露、防分析”的第一道防线而不是唯一防线。2. Hashids 的核心机制哈希、加盐与编码的细节2.1 Hashids 不是哈希是一种可逆编码很多第一次接触的人会望文生义以为 Hashids 是哈希。实际上哈希是不可逆的而 Hashids 必须能解出原始 ID所以它本质上是“带盐的进制转换 字符重排”。理解它不用太复杂记住一个类比你可以把 Hashids 理解为“洗牌后的数字进制”。普通的十进制转六十二进制是把 ID 从10进制转成0-9a-zA-Z表示的字符串。Hashids 在此基础上做了两件事先把字母表用加盐的方式“洗乱”让字符顺序变得不可预测。编码时通过对 ID 分组、分段和字符替换让相邻的 ID 输出完全不同的字符串。所以结果就是1001和1002这两个数字编码后的字符串没有任何公共前缀看起来完全无关。这也是它最核心的产品价值——破坏 ID 的顺序可预测性。2.2 加盐对输出结果的影响Hashids 的默认构造函数是Hashids(salt, minLength, alphabet)三个参数里最关键的就是盐。盐的作用是参与洗牌算法它决定了最终字母表的排列顺序。同一个 ID用不同的盐编码结果完全不同。比如Hashids(my-salt)编码1得到jR换Hashids(other-salt)编码同一个1得到可能是8x。所以盐要满足几个条件保密。盐一旦泄露整个编码体系就等于透明。不要把它写进前端代码、Git 仓库或公开文档里。我们项目里盐是放在配置中心走环境变量下发。稳定。盐一旦定了就不要改。改了之后所有对外的历史编码全部失效老用户手里的邀请码、收藏链接全部打不开。我见过一个项目上线三个月后改盐结果一堆历史分享链接直接 404只能临时写兼容逻辑。足够随机。别用123456或者项目名当盐。用随机字符串生成工具生成一长串长度建议 16 位以上。2.3 最小长度、字母表与碰撞概率的关系minLength参数控制编码结果的最短长度但这里有个容易误会的点它不是固定长度。比如设置minLength8ID 小时编码结果是 8 位当 ID 数值大到一定程度会自动变成 9 位、10 位。所以这个参数只能“保底”不能严格限制长度上限。字母表参数就更有意思了。默认字母表是大写字母小写字母数字共 62 个字符你可以按业务需要去掉容易混淆的字符比如0/O、1/l/I。我们做邀请码时就把字母表精简成了abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789去掉0O1lI避免用户在手机端手输时看错。这种定制在某些对可读性要求极高的场景印刷、口头播报非常实用。说到碰撞概率这里要给个真实结论Hashids 保证在“相同盐、相同字母表”下不同的数字 ID 会编码成不同字符串不会发生碰撞。但这个保证有个前提——你的 ID 范围别超过编码空间。如果你把数字范围压到 32 位约 21 亿用 62 字符字母表生成 2 位编码理论上只有 3844 种组合超过就有碰撞风险。这个情况在真实业务里几乎不会出现因为你的 ID 到不了几万亿的量级所以不用过度担心但心里要有数。3. 实操落地从依赖引入到接口改造的完整过程3.1 引入依赖与基础配置的正确姿势以我用的几个语言为例。Hashids 官方在 GitHub 上维护了十几种语言的实现包括 JavaScript、PHP、Java、Python、Go、C# 等API 设计基本一致。PHP 用 Composer 装composer require hashids/hashidsJava 用 Maven 引dependency groupIdorg.hashids/groupId artifactIdhashids/artifactId version1.0.3/version /dependencyPython 直接用 pippip install hashids重点说说初始化时的参数配置。以 Java 为例Hashids hashids new Hashids(这里填你的随机盐, 8, abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789);这里三个参数的顺序是salt, minLength, alphabet很多人在 Python 版本里搞混了参数顺序因为不同语言实现的历史原因部分版本参数位置不一致。我踩过这个坑Python 老版本Hashids(salt, min_length, alphabet)而某些新版本构造函数顺序没变但源码里命名是min_length一眼看过去容易和别的语言搞混。建议用关键字参数初始化避免踩坑。比如 Pythonhashids Hashids(salt你的盐, min_length8, alphabetabcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789)3.2 在 Service 层封装统一的编解码服务千万别在 Controller 里到处直接new Hashids(...)那样盐和配置散落得到处都是以后改盐、换算法都是灾难。我习惯建一个独立的组件集中管理编码和解码。一个 PHP 版本的封装你可以参考?php declare(strict_types1); namespace App\Service; use Hashids\Hashids; use RuntimeException; final class IdEncoder { private Hashids $hashids; public function __construct() { $salt getenv(HASHIDS_SALT); if (false $salt || $salt) { throw new RuntimeException(HASHIDS_SALT is not configured); } $this-hashids new Hashids($salt, 8, abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789); } public function encode(int $id): string { return $this-hashids-encode($id); } public function decode(string $encoded): ?int { $ids $this-hashids-decode($encoded); // 解码失败或结果为空返回 null 交给上层处理 if (empty($ids)) { return null; } return (int) $ids[0]; } }这里有几个细节值得解释decode()返回的是数组。因为 Hashids 支持一次编码多个 ID比如把[1, 2, 3]编码成一个字符串所以解码结果天然是数组。我们业务里只编码单个 ID就取第一个元素。盐必须从环境变量读取硬编码在代码里是重大安全事件。这个封装里没有默认值配置缺失直接抛异常宁可启动失败也不能带着空盐跑。编码结果异常时返回null让上层决定是返回 404 还是参数错误不要在编解码层直接抛业务异常。3.3 接口出入口的改造请求进来解码响应出去编码接下来就是把编解码服务接进接口层。核心原则只有一句话内部统一用原始数字 ID只在对外边界转换。拿用户信息接口举例。改造前接口接收userId直接按数字查库。改造后public function profile(Request $request, IdEncoder $encoder): JsonResponse { $userId $request-route(userId); // 这里拿到的是 hash 后的字符串 $id $encoder-decode((string) $userId); if (null $id) { throw new NotFoundException(用户不存在); } $user $this-userRepository-find($id); if (null $user) { throw new NotFoundException(用户不存在); } // 返回给前端的 id 重新编码 return response()-json([ userId $encoder-encode($user-getId()), nickname $user-getNickname(), ]); }注意一个容易忽略的点前端传过来的userId是字符串但 Hashids 的字母表里可能包含数字所以路由定义时要确保它不会被 Laravel 之类的框架自动转成 int 类型。我们曾经踩过坑——框架隐式绑定把{userId}转成 int结果所有 hash ID 都变成了 0解码直接失败。解决方案是在路由参数里显式声明为字符串。另外关联查询也要同步处理。比如查询用户订单列表接口返回的数据里如果包含orderId、shopId、couponId等外键这些字段全部要做编码。一个小技巧是写一个通用的 JSON 响应类在响应序列化时对指定字段统一做编码转换而不是在每处 Controller 手动调。这样新接口只要在注解或配置里声明哪些字段需要编码自动套用避免漏掉。3.4 为历史数据和多环境做好准备老项目接入 Hashids 之前线上可能已经存在大量明文 ID 在流转老版本 App 缓存的 URL、收藏夹里的链接、历史推送通知里的跳转地址。如果不做兼容升级后这些入口全部失效。我们当时的做法是“新旧共存逐步切换”。具体方案是解码失败时尝试把原字符串当成纯数字 ID 直接使用。public function decode(string $encoded): ?int { $ids $this-hashids-decode($encoded); if (!empty($ids)) { return (int) $ids[0]; } // 兼容历史纯数字 ID if (ctype_digit($encoded)) { return (int) $encoded; } return null; }这个兼容逻辑很实用。老版本 App 传userId10086Hashids 解码失败但ctype_digit判断它是纯数字直接返回 10086新版本 App 传userIdjRkL3x正常解码。等到老版本 App 自然淘汰后再把这段兼容逻辑删掉也不迟。多环境的配置也要注意开发、测试、生产的盐必须独立配置。如果所有环境共用同一个盐等于测试环境的开发人员也有了生产环境的解码能力。我们通过配置中心按环境注入不同的盐这本身不需要额外代码但需要你在部署配置里做好隔离。4. 常见问题与排查技巧实录4.1 解码结果与预期不符先排查参数顺序我在技术社群里看到最多的求助帖标题几乎都是“Hashids 编码后解不出来”。排查下来大概率是下面三个原因之一编码和解码时用了不同的盐。这是最常见的尤其多环境配置时改了一个忘了另一个。不同语言实现的参数顺序不一致。比如某个库是(salt, alphabet, minLength)另一个是(salt, minLength, alphabet)照抄示例代码就出错。字母表配置不一致。编码用的字母表包含0O解码用的字母表去掉了0O导致字符映射错位。排查技巧写一个单元测试定义常量盐和字母表编码几个已知 ID把预期输出写成断言。这样无论换环境还是换语言跑一遍测试就知道配置对不对。4.2 编码结果太长怎么压缩Hashids 输出长度和输入数值大小有直接关系ID 大了以后编码也会变长偶尔会遇到 URL 长度或二维码容量受限的尴尬。我的经验是先确认minLength不要设置过大8 位足够。就算 ID 到几千万实际输出也只增加一两位不会失控。如果确实需要更短可以调整字母表。参数里把字母表扩展到 64 个字符比如加上-_理论上同长度下能表示的 ID 范围更广。极端情况下可以分层编码业务主键不变另建一张“短码表”预生成一批短码与 ID 建立映射。但这等于又引入了一张表能用 Hashids 顶住的情况不必上这个复杂度。4.3 Hashids 与性能批量编码场景怎么办曾经有个活动需求用户邀请好友要一次性返回一批被邀请人的短 ID 列表一页 50 条。如果每调一次encode()都重新构建一个 Hashids 实例在高并发下会有明显性能损耗。Hashids 的编码过程本身并不重但重复构造对象、重复洗牌会产生无谓的开销。正确做法是把 Hashids 实例做成单例或长生命周期对象编码时直接复用。上面 PHP 封装里用依赖注入容器管理单例就是这个目的。说到性能我们做过分压测试单实例每秒编码几万次完全没压力瓶颈反而在数据库查询和网络开销上。所以只要不做“每次循环都 new”性能这个事基本不用焦虑。4.4 哪些场景千万不要用 Hashids这个必须单独讲因为把它用错地方的代价很高不要用来保护敏感数据。用户的手机号、身份证号、支付金额这类信息绝不能只靠 Hashids 隐藏。它不是加密盐一旦泄露等于明文。真要传输敏感信息请用它后面的那层 TLS 和真正的加密算法。不要把它当防越权的依据。前面提过Hashids 防的是“枚举”不是“越权”。资源归属校验该写还得写不能因为 ID 不可猜就放松后端校验。不要频繁更换盐。一次更换等于让所有存量链接失效对线上影响极大。换个角度说这也意味着初始配置时就要把盐当成生产环境的一等配置项严肃对待不要临时拍脑袋定一个。我见过一个反例某团队把用户的优惠券 ID 用 Hashids 编码后放进二维码以为安全了结果还是被批量扫出来原因是盐被硬编码进了前端包逆向就能翻出来。这类问题的根因不是 Hashids 不行而是把它用在了错误的信任边界上。4.5 日志与监控里的 ID 转换C 端上线 Hashids 后日志系统会面临一个隐蔽问题以前全链路用数字 ID 关联日志排查问题时grep userId10086一眼定位。现在接口进来的是userIdjRkL3x如果日志直接打 hash ID业务同学根本不知道这个用户是谁。我的做法是日志里同时记录两种 ID——入口处接收到的 hash ID 和经过解码后的原始 ID。这样运营同学拿着用户的数字 ID 能搜到链路技术同学也能通过 hash ID 回溯前端的完整调用过程。虽然日志会多两个字段但对排查效率的提升非常明显。另外监控告警的规则也要同步调整。如果某个接口的告警检测的是“数字 ID 连续变化”那切成 hash ID 后规则也要改这个细节很容易漏。5. 从 Hashids 延伸出去短链、邀请码与更多编码思路5.1 把同一个思路用到短链和邀请码上Hashids 能做的远不止接口 ID 隐藏。我最初就是因为邀请码需求认识它的后来发现短链场景同样适合邀请码注册时把用户 ID 编码成 6-8 位短码新用户填码时解码回数字 ID直接建立上下级关系。这里对字母表的要求很苛刻必须去掉易混淆字符同时 minLength 不能太短否则用户量大时容易被暴力试出来。短链把 URL 映射表的主键 ID 编码成短字符串拼到域名后面作为一个短链。这里不需要解码回原始 URL 时再查库而是用编码结果做映射比随机字符串省去一套存储。活动码某些一次性活动码要求防猜测Hashids 可以作为一个基础层再配合服务端状态校验来保证“每码只能用一次”。这些场景的共通点是底层有一个连续的数字主键对外又需要一个不暴露连续性的字符串标识。Hashids 正好填这个空。5.2 如果后续要换掉 Hashids怎么平滑迁移任何一个技术方案都要考虑寿命Hashids 也不例外。万一以后遇到更强的需求比如需要带过期时间、需要支持签名校验你得有退路。我建议从一开始就给编解码层留好接口不要在业务代码里直接依赖 Hashids 的类。上面封装的IdEncoder就是这个用途——所有业务方只依赖IdEncoder接口将来的迁移就是替换IdEncoder的实现而不需要改所有调用方。迁移时可以做一个“双读双写”的方案新方案生成新格式的 ID老方案继续解码旧 ID解码逻辑里先试新方案失败再试老方案。等到老格式自然淘汰后删掉兼容代码。这和 3.4 节里兼容纯数字 ID 的思路完全一致风险可控。6. 实操总结基于我个人经验的最终建议做了几个项目的 Hashids 接入后我的体会是它属于那种“低成本、高收益”的改造但前提是你要把它们都当成一个工程来对待而不是简单搞个编码解码工具类。结合实际操作我有几条比较实用的建议盐的生成和管理要像数据库密码一样严格配置中心统一管理按环境隔离禁止进 Git。字母表一定要按业务场景定制。如果是 App 内跳转用默认 62 字符没问题如果用短信下发或需要用户手动输入的码务必去掉0OIl这类歧义字符。接入老项目时先做兼容解码逻辑纯数字 ID 旧方案再逐步灰度切换别想着一次到位线上事故往往就出在“全量一刀切”。在线压力测试不需要太担心 Hashids 本身的性能它不会是瓶颈。真正要注意的是日志、监控、响应的全链路 ID 一致性。最后再分享一个小技巧如果你在做一个上线后需要长期维护的系统建议在所有接口的返回值里都把 hash ID 与原始数字 ID 的对应关系单独打一条 DEBUG 日志。平时没人看但一旦线上出现“用户反馈分享链接打不开”这类问题这日志就是救命稻草。Hashids 不是银弹它不能替你完成权限校验也不能保护真正的机密数据。但如果你只是想让 C 端的自增 ID 不再裸奔让爬虫和竞对少一点顺藤摸瓜的便利它确实是一个非常优雅、成本极低的答案。