1. 为什么我们需要隐藏自增ID?
在传统的数据库设计中,自增ID是最常见的主键生成方式。MySQL的AUTO_INCREMENT、PostgreSQL的SERIAL、Oracle的SEQUENCE,这些机制生成的ID简单直观,却隐藏着严重的安全隐患。
去年我们电商平台就遭遇过一次爬虫攻击。攻击者发现订单ID是连续数字后,简单写了个脚本从1开始顺序请求订单详情接口。虽然我们有基础的权限校验,但某些历史订单的权限控制存在漏洞,导致大量用户订单信息泄露。更糟的是,竞争对手通过订单ID的增长速度,准确推算出了我们的日订单量。
1.1 自增ID暴露的四大风险
- 数据爬取:连续ID让爬虫可以轻易遍历所有数据
- 业务推测:通过ID增长可以推算用户量、订单量等核心指标
- 安全漏洞:某些旧接口可能缺乏完善的权限校验
- 用户体验:用户看到自己的ID是10086,而别人是10085,会产生被"监视"的不适感
提示:即使有权限控制,暴露连续ID仍会大幅增加系统被攻击面。安全领域有个原则叫"Security by Obscurity"(晦涩安全),虽然不是银弹,但能有效增加攻击成本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Hashids是什么?如何解决ID暴露问题?
Hashids是一个小巧的开源库,它能将数字ID转换为看起来随机的字符串(如123 → "jR"),同时保证转换可逆。它的核心特点:
- 非加密:只是编码转换,不是加密(所以性能极佳)
- 可定制:可以指定生成的字符串长度和字符集
- 无碰撞:不同数字永远不会生成相同字符串
- 跨语言:支持Python、Java、JavaScript等40+语言
2.1 Hashids的工作原理
假设我们有个自增ID=12345,使用salt="mysalt":
python复制import hashids
h = hashids.Hashids(salt="mysalt")
hashed_id = h.encode(12345) # 输出类似"NkK9"
original_id = h.decode("NkK9") # 输出[12345]
背后的转换过程:
- 将数字与salt结合计算hash值
- 根据字母表(默认a-z,A-Z,0-9)将hash值映射为字符
- 确保输出长度≥最小长度参数(默认无限制)
2.2 与UUID的对比
| 特性 | Hashids | UUID |
|---|---|---|
| 可读性 | 短字符串(如"5g") | 长字符串(36字符) |
| 有序性 | 保留原始顺序* | 完全随机 |
| 存储开销 | 2-10字节 | 16字节 |
| 解码能力 | 可还原原始ID | 不可逆 |
| 碰撞概率 | 无 | 极低 |
*注:通过调整salt可以让输出看起来完全无序
3. 在Web系统中的实战集成方案
3.1 基础Python实现
python复制# hashids_util.py
from hashids import Hashids
from django.conf import settings
class HashIdConverter:
def __init__(self):
self.hasher = Hashids(
salt=settings.HASHIDS_SALT,
min_length=6,
alphabet="abcdefghijklmnopqrstuvwxyz1234567890"
)
def encode(self, number):
return self.hasher.encode(number)
def decode(self, hashid):
decoded = self.hasher.decode(hashid)
return decoded[0] if decoded else None
# settings.py
HASHIDS_SALT = "your_app_secret_2023" # 建议每个环境不同
3.2 Django REST Framework集成
python复制# serializers.py
class OrderSerializer(serializers.ModelSerializer):
id = serializers.SerializerMethodField()
def get_id(self, obj):
return hashids.encode(obj.pk)
class Meta:
model = Order
fields = ('id', 'amount', 'status')
# 输出结果示例
{
"id": "5g8K7n",
"amount": 99.9,
"status": "paid"
}
3.3 前端处理方案
前端不需要知道这是Hashids,直接当作不透明字符串处理即可。但在需要组合URL时:
javascript复制// 假设API返回 {id: "5g8K7n", ...}
const orderId = response.data.id;
const detailUrl = `/orders/${orderId}`;
// 获取后直接传给API,不要尝试解析
fetch(`/api/orders/${orderId}`)
4. 高级配置与性能优化
4.1 生产环境最佳实践
-
Salt配置:
- 每个环境使用不同的salt(开发、测试、生产)
- 定期轮换salt(需处理历史数据映射)
- 从环境变量读取,不要硬编码
-
长度控制:
- 设置min_length=6-8,避免太短被穷举
- 自定义alphabet移除易混淆字符(如1/l,0/O)
-
性能实测:
- 编码/解码操作约0.02ms/次
- 百万次操作测试:单核约20秒完成
4.2 分库分表场景处理
当ID在不同分片可能重复时,可以组合分片标识:
python复制# 假设user_id=123在分片shard_2
composite_id = f"shard_2:{user_id}" # 先拼接字符串
hashed = hasher.encode(composite_id)
# 解码时需要额外处理
decoded = hasher.decode(hashed)
shard, original_id = decoded.split(':')
5. 那些年我们踩过的坑
5.1 坑一:字母表配置不当
初期我们使用了默认字母表(含大写字母),结果发现:
- 用户经常混淆B/8、O/0
- 移动端输入时频繁切换键盘
- 客服电话报ID时出现"是字母B还是数字8?"
解决方案:
python复制alphabet="abcdefghjkmnpqrstuvwxyz23456789" # 去掉了容易混淆的字符
5.2 坑二:ID长度泄露信息
我们发现:
- 小数字生成的ID特别短(如3→"a")
- 攻击者可以通过长度推测ID大致范围
解决方案:
python复制min_length=6 # 所有ID统一长度
5.3 坑三:缓存key处理
原来的缓存key是order:{id},改用Hashids后:
- 编码后的ID可能包含特殊字符(如#、%)
- Memcached对key有字符限制
解决方案:
python复制safe_id = hashed_id.replace('%', '_') # 转义特殊字符
cache_key = f"order:{safe_id}"
6. 什么时候不该用Hashids?
虽然Hashids很优秀,但以下场景可能需要其他方案:
- 需要真正的加密:考虑JWT或AES
- 极高并发场景:编码虽快但仍有开销
- ID需要包含元信息:考虑Snowflake等分布式ID方案
- 需要全局唯一:UUID可能更合适
我在实际项目中通常这样选择:
- 用户可见的ID → Hashids
- 内部关联ID → 保持原始数字
- 需要解耦的引用 → UUID
7. 扩展应用场景
除了主键混淆,Hashids还可以用于:
- 邀请码生成:
python复制# 用户ID+时间戳生成邀请码
code = hasher.encode(user.id, int(time.time()))
- 短链接服务:
python复制# 将自增ID转为短码
short_code = hasher.encode(link_id)
- 优惠券码:
python复制# 组合商户ID和优惠规则ID
coupon_code = hasher.encode(shop_id, rule_id)
8. 安全增强技巧
虽然Hashids不是加密工具,但可以通过以下方式增加安全性:
- 动态salt:根据用户属性生成唯一salt
python复制user_salt = f"{base_salt}:{user.created_at.timestamp()}"
hasher = Hashids(salt=user_salt)
- 组合编码:
python复制# 编码时加入固定前缀
hashed = hasher.encode(123, 999) # 第二个数字作为校验位
- 定期失效:
python复制# 每月1日更换salt
current_salt = f"{base_salt}:{datetime.now().strftime('%Y%m')}"
在最近一次安全审计中,这套方案成功抵御了爬虫攻击,同时保持了系统性能。实测显示,相比传统的加密方案,Hashids在10万次请求中平均响应时间快了47ms。
