做了几年 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 端接口返回userId=1024这种字段,表面上是“多了一个数字”,实际上至少暴露了三层信息:
- 业务规模泄露。注册类产品如果看到用户 ID 到了几百万,竞对基本能估算出你的用户量级;订单号如果是纯自增,日单量、峰值单量都藏不住。
- 可枚举抓取。自增 ID 天然连续,攻击者把
id=1到id=100000全跑一遍,不需要任何漏洞就能批量扒到公开接口里的数据。就算每个接口都做了鉴权,能遍历本身就是一种攻击面。 - 越权漏洞放大器。很多系统在写
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参数控制编码结果的最短长度,但这里有个容易误会的点:它不是固定长度。比如设置minLength=8,ID 小时编码结果是 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> <groupId>org.hashids</groupId> <artifactId>hashids</artifactId> <version>1.0.3</version> </dependency>Python 直接用 pip:
pip install hashids重点说说初始化时的参数配置。以 Java 为例:
Hashids hashids = new Hashids("这里填你的随机盐", 8, "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789");这里三个参数的顺序是salt, minLength, alphabet,很多人在 Python 版本里搞混了参数顺序,因为不同语言实现的历史原因,部分版本参数位置不一致。我踩过这个坑:Python 老版本Hashids(salt, min_length, alphabet),而某些新版本构造函数顺序没变但源码里命名是min_length,一眼看过去容易和别的语言搞混。建议用关键字参数初始化,避免踩坑。比如 Python:
hashids = Hashids(salt="你的盐", min_length=8, alphabet="abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789")3.2 在 Service 层封装统一的编解码服务
千万别在 Controller 里到处直接new Hashids(...),那样盐和配置散落得到处都是,以后改盐、换算法都是灾难。我习惯建一个独立的组件,集中管理编码和解码。
一个 PHP 版本的封装,你可以参考:
<?php declare(strict_types=1); 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 传userId=10086,Hashids 解码失败,但ctype_digit判断它是纯数字,直接返回 10086;新版本 App 传userId=jRkL3x,正常解码。等到老版本 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 userId=10086一眼定位。现在接口进来的是userId=jRkL3x,如果日志直接打 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 不再裸奔,让爬虫和竞对少一点顺藤摸瓜的便利,它确实是一个非常优雅、成本极低的答案。