为什么 C 端系统不能直接暴露自增 ID?
在后端系统中,我们习惯使用数据库自增 ID,并习惯性的直接返回给C端交互使用,例如:
登录接口在登录成功后返回的用户基础信息
{
"userId": 100,
"username": "tom",
"gender": 1,
"birthday": "2000-10-12 00:00:00"
}URL上直接有自增ID:GET /api/user/100
可被枚举
只要有人发现这是自增 ID,例如:GET /api/user/100,自然可以被枚举
/api/user/101
/api/user/102
/api/user/103
......
发现了问题没:
- 可以轻松遍历接口、爬虫可以批量扫库
- 哪怕你有登录态、鉴权,只要权限校验有一个点没兜住,后果极其严重:表数据被批量抓取,隐私数据被遍历、爬光
- 这类攻击成本极低,甚至不算“攻击”
越权
越权的风险被无限放大,你“以为”你做了鉴权,其实不一定,现实情况往往是:
- 接口 A 做了用户校验
- 接口 B 忘了
- 新接口临时加的,校验漏了
- 某个内部接口被误暴露
一旦 ID 是可预测的:
- 攻击者只需要找到 一个没校验的入口
- 就可以“横向移动”访问所有数据
例如,URL中存在自增ID,在C端非常典型的场景是用户分享链接给朋友,如果朋友修改URL中的ID,就会跳转到本不属于自己能看到的数据内容。
业务信息全暴露
通过 ID 就能看穿你的业务信息,例如:
📈订单量增长速度
👥用户规模
⏱️业务峰值时段
🧮是否删过数据(ID 是否断层)
这种在C端用户看来没有意义的数据,如果让用户“看不懂”的 ID,反而更专业。
👉 纯数字 ID,看起来像“内部系统”
👉混淆 ID, 更像“产品设计的一部分”
解决方案
根据以上问题,我们期望有这样一种解决方案可以混淆自增ID
- 唯一不可重复:数据量内都必须唯一,不能重复
- 支持可逆:ID可以编码为一个看不出规律的串,也可以解码为原ID,不影响数据库ID字段
- 高效生成与解析:生成、验证的算法必须保证效率,不能占用太多系统资源
- 不可预测与安全:无规则混淆,规律性不能很明显,不能轻易被人猜测到,防止爆刷
- 工程成本低:不改表、不迁数据
常见但不够优雅的解决方案
- UUID
- 字符串过长
- URL、二维码不友好
- 调试体验差
- Snowflake / Base64
- 仍然可能暴露时间信息
- 前后端实现不统一
- AES / RSA 加密 ID
- 性能与复杂度成本高
- 对“只是隐藏 ID”来说属于过度设计
这个解决方案就是Hashids。
Hashids的核心功能:把一个或多个整数(int / long)转换成一个不可预测、可逆的短字符串。
基本属性
- 输入是整数(支持long型),输出是字符串(只包含:a-z A-Z 0-9,无其他特殊字符)
- 可以自定义编码字符
- 可逆,但不可猜
- 支持多个ID编码为一个字符串
- 可控制最小长度,不支持“最长长度”限制,实际长度是不固定的,随输入数字大小变化
典型用途:
数据库自增 ID ,对外展示用字符串,防止 ID 枚举
短链接 / 邀请码 / 兑换码
URL / 小程序参数更友好
- 将多个数字(数组)进行混淆,防止参数被篡改
注意:它是“混淆(obfuscation)”,不是“加密(encryption)”,不能作为密码学类的场景使用。
Hashids内部原理
编码(encdoe)流程:
原始数字 --> 打乱字符表(依赖 salt) --> 选取 guard / separator --> 进制转换(base-N) --> 按规则拼接 --> 输出字符串
核心组成元素
- 核心组成元素
- 字符表(alphabet):指定那些字符是输出的结果集
- 默认字符集:abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890
- 你也可以自定义(例如只用大小写 + 数字,排除 0/O/I/l),注意字符不能重复。
- 字符集长度 = 进制 base N
- salt
- 决定字符表如何被洗牌
- 同一个 ID,在不同 salt 下,结果完全不同
- 不保存 salt,就无法反解
- separators & guards
- Hashids 会从 alphabet 中分出两类特殊字符:separators,分隔多个数字。guards,控制最小长度、增强不可预测性。
- 这一步主要是为了:避免输出模式过于规则,同时支持编码多个数字的场景(encode(1,2,3))
怎么实现可逆性的?
- 字符表洗牌(consistent shuffle)
- 在相同 alphabet + salt下,编码同一ID,结果永远相同
查看代码
for i from alphabet.length-1 downTo 1: j = (salt_char_code + i + previous) % i swap(alphabet[i], alphabet[j])怎么转换为字符串的?
- 假设字符表(alphabet)的长度为62
- 数字先模62,得到的余数作为下标从字符表中取得一个字符
- 再除62,直到数字小于0时停止
- 得到一个字符串
如何支持同时编码多个数字?
例如:encode(1, 2, 3)
1 → abc
2 → k9
3 → z
中间用 separator(也是来自 alphabet,但经过专门筛选)隔开,最终输出:abcXk9Yz
怎么保证最小长度?
在头尾插入 guard
再次洗牌 alphabet
重复直到满足长度,这一步是伪随机填充,不影响 decode。
decode的工作流程?
去掉 guards
用 separators 切分
复原 alphabet 洗牌
每一段做 base-N → long
decode失败怎么处理?
- salt 不一致 → decode 失败或得到错误值
- alphabet 不一致 → decode 失败
代码实现
终于到了激动人心的代码实现环节,撸起袖子,敲键盘。
在pom中导入依赖
<dependency> <groupId>org.hashids</groupId> <artifactId>hashids</artifactId> <version>1.0.3</version> </dependency>简单用法
import org.hashids.Hashids; import org.springframework.stereotype.Service; import java.util.Arrays; @Service public class HashidsService { //Bean单例,不存在线程安全问题 private final Hashids hashids = new Hashids(); public String encode(int code) { return hashids.encode(code); } public String encode(long code) { return hashids.encode(code); } public long decode(String decoded) { long[] decodes = hashids.decode(decoded); if (decodes.length == 0) { throw new IllegalArgumentException("非法ID"); } return decodes[0]; } public String encodeArr(int[] codes) { long[] codeArr = Arrays.stream(codes).asLongStream().toArray(); return hashids.encode(codeArr); } public String encodeArr(long[] codes) { return hashids.encode(codes); } public long[] decodeArr(String decoded) { long[] decodes = hashids.decode(decoded); if (decodes.length == 0) { throw new IllegalArgumentException("非法ID"); } return decodes; } }加盐
加盐混淆
- 防止别人用同样库解你 ID
- salt 一旦上线 绝对不能改
# application.yml hashids: salt: kjsdfiaosudkskldjfa #混淆用的盐 min-length: 8 #最小长度查看代码
package com.ks.demo.uc.hashids; import org.hashids.Hashids; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import java.util.Arrays; /** * 加盐混淆 * * salt的作用 * 防止别人用同样库解你 ID * salt 一旦上线 绝对不能改 */ @Service public class HashidsSaltService { @Value("${hashids.salt}") private String salt; @Value("${hashids.min-length}") private int minLength; //new在@Value注入之前 //解决方案:后构造器,在构造器的入参使用@Value,使用@ConfigurationProperties单独注入 //private Hashids hashidsSalt = new Hashids(salt); private Hashids hashidsSalt = null; private Hashids hashidsMinLen = null; @PostConstruct public void init() { hashidsSalt = new Hashids(salt); hashidsMinLen = new Hashids(salt, minLength); } public String encode(int code) { return hashidsSalt.encode(code); } public String encode(long code) { return hashidsSalt.encode(code); } public long decode(String decoded) { long[] decodes = hashidsSalt.decode(decoded); if (decodes.length == 0) { throw new IllegalArgumentException("非法ID"); } return decodes[0]; } public String encodeArr(int[] codes) { long[] codeArr = Arrays.stream(codes).asLongStream().toArray(); return hashidsSalt.encode(codeArr); } public String encodeArr(long[] codes) { return hashidsSalt.encode(codes); } public long[] decodeArr(String decoded) { long[] decodes = hashidsSalt.decode(decoded); if (decodes.length == 0) { throw new IllegalArgumentException("非法ID"); } return decodes; } public String encodeMinLen(int code) { return hashidsMinLen.encode(code); } public String encodeMinLen(long code) { return hashidsMinLen.encode(code); } public long decodeMinLen(String decoded) { long[] decodes = hashidsMinLen.decode(decoded); if (decodes.length == 0) { throw new IllegalArgumentException("非法ID"); } return decodes[0]; } public String encodeMinLenArr(int[] codes) { long[] codeArr = Arrays.stream(codes).asLongStream().toArray(); return hashidsMinLen.encode(codeArr); } public String encodeMinLenArr(long[] codes) { return hashidsMinLen.encode(codes); } public long[] decodeMinLenArr(String decoded) { long[] decodes = hashidsMinLen.decode(decoded); if (decodes.length == 0) { throw new IllegalArgumentException("非法ID"); } return decodes; } }自定义参与编码的字符
字符集规则:
- 至少 16 个字符
- 不允许重复字符
# application.yml hashids: salt: kjsdfiaosudkskldjfa #混淆用的盐 min-length: 8 #最小长度 #至少 16 个字符,不允许重复字符 #参与编码的字符,可以剔除调0/O/o,1/I/l等字符,增强可读性 base-char: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz