去年帮一个做小程序商城的朋友看后台,订单号是自增数字。有客户在客服群里抱怨:上一单 100231,这一单 100237,再翻几个熟人的单号,一天出多少单基本摆出来了。老板拿着截图来问:能不能把对外那串号换成看不出东西的字符。本质不是加密,是不透明——编号只要单调递增,差值就是一个摆在明面上的统计口径。
换成随机串,最常见的是 UUID。我在他那个项目里最后没做全表迁移,只动了一列对外字段,原因在下面这三笔账里。
一、结构:128 位里有 6 位是固定的
UUID 是 128 位的标识,写成 8-4-4-4-12 分组,共 32 个十六进制字符加 4 个连字符:
f3c1a072-8ad4-4b6e-91cf-0d5a2e6b7c10
128 位全用满,总空间是 2 的 128 次方,约 3.4×10 的 38 次方。但 v4 的随机位不是 128 位,是 122 位:从左往右数第 13 个字符(第三个分组的首字符)固定写死为 4,版本位占 4 个二进制位;第 17 个字符(第四个分组的首字符)只能取 8、9、a、b,因为变体位的最高两位固定为 10,占 2 位。128 减 4 减 2 才是随机位。
写成"128 位全随机"是最常见的错误:空间被夸大成 64 倍(2 的 128 次方除以 2 的 122 次方正是 2 的 6 次方),后面的概率估算跟着偏。
二、碰撞概率只能按生日界算
要回答的不是空间大不大,而是生成 n 个之后出现一对重复的概率有多大。口径用生日界。
N = 2^122 = 5316911983139663491615228241121378304
p = 1 - exp( -n*(n-1) / (2*N) )
n 远小于 N 时:p ≈ n*n / (2*N)
这里的 N 请用整数运算取 pow(2,122),浮点会把尾数改掉;上面那串 37 位数就是精确值。反解 p = 0.5,需要的生成量约等于 1.1774 乘以 N 的平方根,也就是 1.1774 乘以 2 的 61 次方(2305843009213693952),得 2714899559048203264 个,量级 2.7×10 的 18 次方。流传的说法把它写成"几十亿",差了十个数量级。
再用极端口径核对:每秒 10 亿个跑满 100 年(一年按 365 天算 31536000 秒)是 3153600000000000000 个,比阈值还多 16%,同一公式代进去概率约 61%。所以"跑很多年也几乎不会重复"方向是反的,它跑到的是抛硬币,不是零。
我实际遇到的量级在另一头:一次灌 1000 万条,n 是 10 的 7 次方,p 约 9.4×10 的负 24 次方,当作不会发生是可以的。但口径要写清:10 的 7 次方这一档安全,10 的 18 次方那一档另说,两档不该塞进同一句话。
三、换成随机主键要付的三笔账
第一笔是索引写入。自增主键永远落在 B+ 树最右边;随机主键的落点由那 122 位决定,页分裂更频繁、缓存命中率下降。共识只能说到这儿:随机主键通常比自增慢、写放大更多。谁给出"慢 3 倍""索引大 4 倍",让他把表结构、数据库版本和压测数据一起拿出来。
第二笔是字节。v4 写成字符串是 36 字节,存成 BINARY(16) 是 16 字节。按他那张 2 亿行的订单表算:字符串主键 7200000000 字节,二进制主键 3200000000 字节,光一列差 4 GB。InnoDB 还把主键值复制进每个二级索引的叶子,4 个二级索引就是主键落 5 份:36 字节约 36 GB,16 字节约 16 GB。列若声明成 utf8mb4 的 CHAR(36),定长按每字符最多 4 字节计,2 亿行是 28.8 GB。
第三笔是人和系统的摩擦。36 个字符没法口头报修,日志里两三条刷满屏幕;随机串不带时间含义,"最近 10 单"要靠 created_at 排,别指望主键顺序可读。
| 项目 | UUID v4(随机) | UUID v1(时间戳) | 自增 ID |
|---|---|---|---|
| 位从哪来 | 122 位随机,6 位固定 | 时间戳加节点标识 | 表内计数器 |
| 主键写入形态 | 落点随机,页分裂多 | 前段随时间推进 | 顺序追加 |
| 单值字节 | 36 字符 / 二进制 16 | 同上 | 4 或 8 字节 |
| 陌生人能读出什么 | 读不出量级与时间 | 可能读出生成时间 | 做减法就知道出单量 |
| 顺序可用吗 | 不可读 | 跨机不保证单调 | 严格递增 |
| 本站工具 | 直接出 | 前端模拟,无真实 MAC | 不适用 |
四、工具只是这条链上的一环
决定用 v4 之后先要灌测试数据。我日常用 UUID生成器:出 v4(随机)或 v1(时间戳),一次批量 1 到 100 个,格式选项是大写或小写、带不带连字符、要不要花括号,单个复制或全部复制都行;要 5000 行就取 50 次。对端只认花括号大写的 GUID 写法时,格式那三项比版本更有用。
100 行 ID 粘进 文本行操作,给每行统一加前后缀,拼成 INSERT 语句的每一行。麻烦从来不在生成,在生成之后那几十次字符串处理。
也拿它做过幂等键:客户端为同一次提交复用同一个键,服务端记下见过的键,重复提交就不再执行第二次。它防的是重复提交,不解决并发正确性——两个请求真同时改同一行,还得靠锁或版本字段。
关于 v1 有一句必须交代:本站工具的 v1 是纯前端模拟,不读取真实 MAC 地址,因此不具备真实 MAC 派生的可追溯性,和库里拿网卡地址当节点标识的 v1 不是一回事。要追到产生它的那台机器,请用库实现。
五、这几个问题我被问过最多
Q:随机位到底是 122 还是 128?
122。版本位 4 位加变体位 2 位固定。记成 128 会把空间夸大约 64 倍,50% 阈值跟着错一档。
Q:想按时间排序,换成 v1 就够了吗?
前段确实随时间推进,但本站 v1 是前端模拟、不含真实 MAC,也不保证跨进程单调。真要有序,常见做法是在 ID 前面自己拼时间分段,别把期望放在随机串的顺序上。
Q:能直接拿 UUID 当口令、邀请码或密钥吗?
不建议。那 122 位随机是为标识唯一服务的,格式写死、生成源不承诺密码学强度,还常整条进日志。要口令就用 随机密码生成,长度和字符集(大小写、数字、符号)由你定,熵能明确算出来。生成源用途不同:一个给人鉴权,一个给机器对账。
Q:已经上线的自增主键要不要迁成 UUID?
先看这个 ID 会不会被陌生人看到。只在内部关联用的表我不动;出现在接口、URL、导出文件里的,加一列对外不透明编号、内部仍留自增,索引重建和外键连锁就只压在一列上。
Q:客户端生成的 UUID 会不会被伪造?
会,而且它本来就不证明任何事:它只保证这一串大概率独一份,不保证请求是谁发的、内容改没改。猜不到不等于可信,鉴权要靠签名。
回到开头那笔单子:朋友的表没改主键,内部 id 仍自增,对外多一列 order_no 存 v4,接口和导出只露它。不透明只需要在暴露面上成立,而索引、字节、摩擦这三笔账在一列上的代价远小于在 2 亿行主键上。要不要换,看你的 ID 会不会被陌生人看到;要换,就先接受随机性的写代价,再从二进制列、索引份数、排序字段这三处把它省回来。