1. 为什么会有 t3code:被自增 ID 和假短码反复折磨之后
先说明一下,t3code 不是加密算法,也不是 T3 Stack 生态里的新脚手架。它就是我在项目里沉淀出的一套三段式编码工具,名字取的是“三段式编码”的意思:One step 归一化,Two step 混淆,Three step 校验码。如果你是在找 TypeScript / Tailwind / tRPC 那一套 T3 的东西,那大概率不是这一篇,可以跳过。
做过后端的人应该都有这种经历——数据库主键 12345 直接出现在 URL、订单号、邀请码里,运营觉得丑,用户觉得能猜到规律,安全评审还盯着你问“这 ID 能不能枚举”。早期我图省事,直接用自增 ID 拼业务单号,结果活动上线第二天,就有技术爱好者通过改 URL 里的订单号把全站订单 ID 遍历了一遍。虽然接口有权限校验,没造成数据泄露,但那次的评审意见直接把我摁住:对外暴露的主键 ID,必须做变形。
1.1 一个让我下定决心写它的线上场景
当时我们做一个裂变活动系统,每张优惠券在数据库里是自增主键,运营需要把券码发给用户,用户拿券码去兑换。用户体验上的诉求有三个:码尽量短,方便手输;码看着“没有规律”,不能一眼看出这是第几万张券;输错一位要能立刻发现,而不是等接口返回“券码不存在”。
最开始我试过把主键原样塞给前端,被否了。然后试了 Base64,结果输出“MTIzNDU2Nw==”,用户一看就知道是编码结果,而且带等号、带加号的部分还得 URL 转义,体验稀烂。后来用了一段时间 Hashids,核心功能确实有,但业务那边抱怨码长不固定,同一个表里有的券码是 6 位,有的是 9 位,做 Excel 导入导出对不齐。
那段时间我意识到,我真正需要的不是一个现成库,而是一套完全可控的编码规则:输入一个整数主键,输出一个固定长度、可逆、带校验、字符干净的字符串。t3code 就是在这种背景下被逼出来的。
1.2 候选方案逐个过一遍:UUID、Base64、Hashids
我把项目里出现过的同类方案拉了个对比,从“可逆性、防肉眼猜出、可校验、是否需要存储映射、实现成本”五个维度看:
| 方案 | 可逆 | 防肉眼猜出 | 可校验 | 需要存储映射 | 实现成本 |
|---|---|---|---|---|---|
| 明文自增 ID | 是 | 否 | 否 | 否 | 0 |
| UUID | 是 | 是 | 否 | 一般不需要 | 低 |
| Base64(原始 ID) | 是 | 弱 | 否 | 否 | 极低 |
| Hashids | 是 | 中 | 否 | 否 | 低 |
| t3code | 是 | 中高 | 是 | 否 | 中 |
UUID 的问题是太长,32 个十六进制字符,用户根本不会手输。Base64 的问题在于特征太明显,等号一出来,技术同学都知道这是编码。Hashids 各方面都不错,但它没有内置校验位,码长也不是固定的,并且在自定义盐值之后,输出质量完全取决于盐值选得好不好。
我自己的判断是:这类工具的核心价值不是“防破解”,而是“把自增序列的规律抹掉 + 让错误在入口处暴露”。t3code 的所有设计都围绕这两点展开。
1.3 t3code 的定位:三段式、可逆、可校验、不需要映射表
t3code 有一个很明确的定位:它是“数字主键的变形器”,不是“随机码生成器”。
这句话怎么理解?如果你给它同一个整数,它永远返回同一个字符串。这种确定性带来两个好处:第一,不需要单独建一张映射表来存“原始 ID 对应哪个短码”,省掉一次查询;第二,幂等,同一个订单号在列表页、详情页、推送文案里出现的形式是一致的,不会这次生成 A 码、下次生成 B 码。
而它输出的码具有三个结构特征:固定长度、可逆、带校验。固定长度靠归一化阶段补齐,可逆靠置换表的双射性质,校验靠末尾一位校验码。三段各管一件事,合起来才是一个完整的工具,而不是花架子。
2. t3code 核心算法拆解:三段式编码各管一件事
很多开发者看到短码工具,第一反应是“不就是转进制吗”,但真的直接用 62 进制转出来,你会发现两个问题:一是 1 和 1000000 转出来的长度不一样,界面难看;二是只要知道进制规则,任何人都能在脑子里把码反推回数字。t3code 的三段式就是为这两个问题设计的。
2.1 归一化:为什么一切先转成 62 进制
归一化做的事情很简单:把十进制整数转成 62 进制字符串,然后按固定长度左侧补 0。
为什么是 62?因为 26 个小写字母 + 26 个大写字母 + 10 个数字,这是英文字符集下能用的最大进制基数。进制越大,同样长度能表示的数字上限越高。10 进制 6 位最多表示 100 万,62 进制 5 位就能表示约 9.16 亿,差距非常悬殊。
补零的意义在于固定长度。假设我们需要最终码长为 6,那么有效载荷位是 5 个字符,原始 ID 转成 62 进制后不足 5 位就左侧补 0。如果原始 ID 特别大,超过 5 个 62 进制字符的容量,就直接报错并提示换更长的码。这一步保证了所有输出要么是准确的 6 位,要么是明确的失败,不会出现长度参差不齐的情况。
2.2 混淆:一个仿射置换背后的数学底线
如果只做进制转换,输出字符串里真实信息量是暴露的。比如 ID=1 输出“000000”之类,ID=62 输出“000010”之类,规律一眼看穿。混淆阶段要做的就是破坏这种可见规律。
我用的是仿射置换:new_index = (old_index * multiplier + offset) mod 62。这里有一个数学底线——multiplier必须和 62 互质,否则这不是一一映射,会出现两个不同的原始字符映射到同一个新字符,解码时就无法还原。
比如multiplier=2,索引 0 和索引 31 都映射到同一个位置,整个置换就废了;multiplier=5就可以,因为gcd(5,62)=1。对应的逆运算需要用到 5 在模 62 下的逆元 25,验证一下:5 * 25 = 125 = 62 * 2 + 1,所以old_index = (new_index - offset) * 25 mod 62。
你可能已经看出来,这本质上是一个字符级的仿射密码。它的强度不算高,但对于“防肉眼猜出、防随手枚举”这个目标来说完全够用。真正想高强度防篡改的人,请直接上 AES,不要为难这种设计。
2.3 校验码:加权校验位到底挡得住什么
我把最后一位留给校验码。校验码的计算方式是:对混淆后的字符串逐字符取索引值,乘以位置权重,累加后对 62 取模。为什么带位置权重?因为如果不带权重,“ab”和“ba”的校验值一样,交换两个字符的错误就发现不了;带上位置权重之后,绝大多数错位都会被捕获。
我建议一个比较直观的校验码公式:
def _checksum(s: str) -> str: total = 0 for pos, ch in enumerate(s): total += ALPHABET.index(ch) * (pos + 1) return ALPHABET[total % BASE]这个校验位不是密码学意义上的 MAC,它挡不住有意的攻击者。但它能挡住非常大的日常错误:用户手输券码把“xK8mQz”输成“xK8mQy”,我们根本不用查数据库,在入口处直接判定“码无效”,省掉一次业务查询,也避免了误报“券码不存在”给用户造成的困惑。
2.4 三段顺序为什么不能乱
我一开始也想过能不能先混淆再补齐,或者把校验码放在最前面,但推演之后发现顺序是有讲究的。
归一化必须先做,因为补齐和混淆都依赖“确定长度的 62 进制字符串”。混淆必须在补齐之后,原因是如果先混淆再补零,那么补进去的零是“混淆前”还是“混淆后”的零,解码逻辑会变成一团乱麻。校验码必须放最后一位,这样解码时可以直接取最后一个字符做校验,不需要知道码长之外的额外信息。
换一句话说:归一化确定格式,混淆隐藏规律,校验兜底错误。它们像一个流水线,每一步的输出是下一步的输入,顺序一旦打乱,整个解码逻辑就要引入额外的状态参数,工程上完全没必要。
3. 最小可用的 t3code:完整代码与边界验证
光讲原理不下代码等于白说。下面这套实现我尽量控制在 70 行左右,纯 Python 标准库,没有第三方依赖,Python 3.8 以上直接能跑。它不是一个玩具,而是可以直接嵌入到项目里的核心模块。
3.1 核心实现
# t3code 核心实现:整数主键 <-> 固定长度短码 ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" BASE = len(ALPHABET) # 62 MULTIPLIER = 5 # 置换乘数,必须与 BASE 互质 OFFSET = 7 # 置换偏移量 INVERSE = 25 # 5 在模 62 下的逆元 def _to_base62(n: int) -> str: if n == 0: return ALPHABET[0] chars = [] while n > 0: n, r = divmod(n, BASE) chars.append(ALPHABET[r]) return "".join(reversed(chars)) def _from_base62(s: str) -> int: n = 0 for ch in s: n = n * BASE + ALPHABET.index(ch) return n def _permute(ch: str) -> str: idx = ALPHABET.index(ch) return ALPHABET[(idx * MULTIPLIER + OFFSET) % BASE] def _unpermute(ch: str) -> str: idx = ALPHABET.index(ch) return ALPHABET[((idx - OFFSET) * INVERSE) % BASE] def _checksum(s: str) -> str: total = 0 for pos, ch in enumerate(s): total += ALPHABET.index(ch) * (pos + 1) return ALPHABET[total % BASE] def encode(n: int, length: int = 6) -> str: if n < 0: raise ValueError("encode() 只接受非负整数") payload_len = length - 1 if n >= BASE ** payload_len: raise ValueError( f"数字 {n} 超过了 {length} 位码的容量上限 {BASE ** payload_len - 1}" ) raw = _to_base62(n).rjust(payload_len, ALPHABET[0]) mixed = "".join(_permute(ch) for ch in raw) return mixed + _checksum(mixed) def decode(code: str, length=None) -> int: if len(code) < 2: raise ValueError("短码长度至少为 2") if length is not None and len(code) != length: raise ValueError(f"期望长度 {length},实际长度 {len(code)}") mixed, ck = code[:-1], code[-1] if _checksum(mixed) != ck: raise ValueError("校验位不匹配:短码无效或已被篡改") raw = "".join(_unpermute(ch) for ch in mixed).lstrip(ALPHABET[0]) return _from_base62(raw) if raw else 0这段代码里有几个细节值得单独说一下:
rjust补齐用的是ALPHABET[0],也就是字符“0”。混淆之后,这些“0”会变成别的字符,解码时先逆混淆再lstrip掉原始意义上的前导零,逻辑上不会冲突。- 校验码只对“混淆后的字符串”计算,不参与混淆,所以解码时先拆最后一位出来做校验,再对剩余部分做逆置换。
- 如果你改了
MULTIPLIER或ALPHABET,必须重新计算INVERSE,不能拿 25 硬套。后面我会专门展开这个坑。
3.2 跑通三个关键用例:普通 ID、0、边界最大值
我测试时通常固定跑三组数据:普通 ID、最小 ID、边界最大 ID。
- encode(12345) 输出
777MAgu - encode(0) 输出
77777h - encode(916132831, length=6) 输出
22222U
反解码decode("777MAgu")得到 12345,decode("77777h")得到 0,decode("22222U")得到 916132831。
边界值这组很有意义:916132831 正好等于62^5 - 1,也就是 6 位码(有效载荷 5 位)能表示的最大整数。这个值在 62 进制下是 5 个最大字符“zzzzz”,经过MULTIPLIER=5, OFFSET=7置换后恰好变成 5 个“2”,所以说它不是随机结果,而是置换规律的必然。如果你没跑过这个用例,都不敢确信自己的工具在极限输入下不翻车。
3.3 篡改与长度校验的异常路径
我故意把777MAgu最后一个字符u改成v再解码,立刻抛ValueError: 校验位不匹配。再把777MAgu传成777MAg,长度不对,也抛错。
这两个异常路径是整套设计里最值钱的部分。在真实业务里,用户输入券码多一位、少一位、换一个字符,我们都能在入口统一拦截,返回“券码格式不正确”,而不是去数据库里查一轮再告诉用户“券码不存在”。前者是格式问题,后者是业务问题,体验差异非常大。
4. 参数调优、并发场景和三个最容易踩的坑
代码跑通只是第一步,真正让人头疼的是“放到项目里用”这一步。这一节我把调整参数时踩过的坑一次性列出来,每一条都是真金白银换来的。
4.1 字符集选择:62 字符还是剔除易混淆字符
标准 62 字符集0-9A-Za-z实现最方便,但有一个非常实际的问题:0/O、1/I/l在字体下很容易看混。尤其用户拿着纸质券码手输时,O和0就是事故高发区。
如果要规避,可以把字符集改成:
23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz这个字符串去掉了0/O/1/I/l,只保留 57 个字符。注意,一旦改了字符集,BASE、MULTIPLIER的互质关系、INVERSE全部要跟着重新算。gcd(57, 5) = 1,所以MULTIPLIER=5仍然合法;57 不是质数,但这不影响仿射置换,只要乘数与模数互质就成立。
这里有个很容易踩的坑:有人只改了ALPHABET,忘了改BASE,结果程序里一部分代码还在用旧的 62 做除法,编码出来的码自己能编不能解,那感觉简直是灾难。
4.2 码长容量账:62^(n-1) 到底够不够用
码长和容量之间的关系是62^(length-1),为什么减一?因为有一位被校验码占了。我整理了一张常用码长的容量表:
| 总码长(含校验位) | 有效载荷位数 | 最大可编码整数 |
|---|---|---|
| 6 | 5 | 916,132,831(约 9.16 亿) |
| 7 | 6 | 56,800,235,583(约 568 亿) |
| 8 | 7 | 3,521,614,606,208(约 3.5 万亿) |
| 10 | 9 | 13,532,717,278,400,511(约 1.35 亿亿) |
一个直观的判断方法:数据库自增主键千万级,6 位码勉强够用;亿级、十亿级,直接上 7 位;如果输入是雪花 ID 这种 64 位大整数,10 位码都不一定装得下,需要认真评估。
我在设计首批方案时把默认码长定成 6 位,上线三个月后主键逼近 7000 万,吓得赶紧评估迁移方案。后来发现可以在encode上调length=7解决,但历史已生成的 6 位码全部要兼容处理——这种问题是越早想清楚越省事。
4.3 key 和乘数的关系:不是随便填个数字就行
有人可能想:既然MULTIPLIER和OFFSET是参数,那我随便填几个大数是不是更“安全”?千万别这么想。
OFFSET只要落在 0 到 61 之间都合法,问题不大。MULTIPLIER则必须先和 62 求一下最大公约数,确保互质。常用的合法值是 3、5、7、9、11 这些与 62 互质的奇数。选了不互质的乘数,后果是不同 ID 可能编出同一个短码,也就是碰撞,而且这种碰撞在解码时才暴露,排查难度极高。
另外,不要频繁更换MULTIPLIER和OFFSET。一旦换了,所有历史短码全部失效。我建议把这两个参数写进项目配置,当作生产环境的“永久契约”,除非做一次性全量迁移,否则不要动。
4.4 不同唯一性来源的处理:自增主键、雪花 ID、随机邀请码
t3code 是确定性编码,它的唯一性完全依赖输入的唯一性。所以你必须想清楚:输入从哪里来?
- 数据库自增主键:最简单,主键唯一,编码结果天然唯一,直接
encode(primary_key)即可。 - 雪花 ID:雪花 ID 本身是 64 位大整数,想编码成短码,得先确认你的码长够不够。如果业务场景只需要短码可逆回雪花 ID,把码长调大到 10 位以上一般可行;如果强制 6 位短码,那基本不可能,容量不够是数学问题。
- 随机邀请码:如果你希望同一张券每次生成的邀请码不同,那不能只把主键塞进去,因为主键不变输出就不变。这时通常的做法是额外生成一个随机盐值,把
random_part和主键拼接后再编码,或者干脆用一张映射表来管理“主键和随机码之间的关系”。
这里有个非常常见的误解:拿 t3code 当随机码生成器用,结果发现同一个 ID 永远生成同一个码,以为程序出 bug 了。真不是 bug,是定位从一开始就错了。
4.5 三个实际踩过的坑:逆元写死、前导零被吃掉、把编码当加密
- 逆元写死:我最初把
INVERSE=25当作“魔法数字”写死在代码里,后来调整字符集去掉易混淆字符后,BASE变成了 57,25 直接失效。解决办法是实现一个通用的逆元计算函数,或者在配置里留注释说明“这个数字来自 5 在 mod 62 下的逆元,改参数必改此值”。 - 前导零被吃掉:有一版解码代码先
lstrip再去逆混淆,结果是所有真实前导零和混淆后的前导字符全部被错误剥离,大量短码解不出来。正确顺序必须是:先逆混淆,再lstrip原始进制的前导零。一时大意,排查了两小时。 - 把编码当加密:有同事在评审会上问“这个码能不能直接当优惠券防伪凭证”,我当场否了。它的混淆强度只够防普通用户好奇试一下,不够防恶意用户批量枚举。真正防伪请上 HMAC 签名或者服务端验签,不要指望一个字符级置换能承担密码学职责。
5. 把 t3code 落进真实业务:订单号、邀请码与短链
工具再好看,最终还是要服务业务。下面几个场景是我实际用过的组合方式,可以直接参考。
5.1 订单号:对外展示码与内部主键解耦
订单表的主键依然用自增 ID,但对外展示一律用 t3code 编码后的短码。前端页面、客服系统、用户短信里的订单号都是这个短码,用户拿它找客服时,客服后台先decode还原成主键再去查库。
好处有两个:第一,订单号不再暴露“今天第几单”这种业务量信息;第二,客服输入订单号搜索时,少一次“用 token 查主键”的映射查询,短码直接解码就是主键。
这里要配套一个约定:数据库里给原主键建唯一索引是废话,但要在订单表里额外加一列order_code并建唯一索引。这样万一出现脏数据写入,唯一索引会兜底。虽然理论上主键唯一编码就唯一,但多一层数据库约束,我睡得更安稳。
5.2 优惠券与邀请码:确定性编码配合 Redis 防重
优惠券场景我采用“主键编码 + Redis 防重”的组合。
用户在兑换页输入 6 位券码,接口先decode还原主键,再去数据库查这张券是否存在。这里有一个体验优化点:校验位不匹配的码,直接返回“券码输入有误”,不走数据库;校验通过但主键查不到的码,返回“券码不存在”。两个错误文案分开,运营通过日志就能区分是用户输入错还是券码真的有问题。
邀请码类似,唯一要注意的是:如果你希望邀请码“一人一码”且不能通过枚举规律批量生成有效码,光靠 t3code 不够。我当时的做法是:先给邀请任务生成一个随机唯一字符串(比如用 UUID),再把它和任务主键一起做一次编码,最后配合数据库唯一索引防重。这样即使有人看穿了 t3code 的规律,也绕不过随机部分的不可预测性。
5.3 短链场景:用主键编码减少一次映射查询
短链服务的经典做法是存一张“原始长链接 -> 短码”的映射表,用户访问短链时查表还原长链接。如果直接用自增主键做短码,相当于把“查表”省了一半心思,但短码暴露了访问量级,热点链接容易被盯着刷。
把 t3code 用在短链上时,我建议这样:短链表主键自增,对外短码是encode(主键)。用户点击短链时,先decode还原主键,再按主键直接查到长链接,省掉“短码查映射”这一步。同时把长链接的访问计数和主键一起走 Redis,热点判断、限流逻辑都围绕主键展开,代码结构清晰很多。
这个方案的前提仍然是:接受确定性编码带来的可逆性风险。如果短链本身被要求“不可枚举”,那还是要引入随机部分或独立短码表,不能把所有希望放在 6 位码上。
5.4 团队落地时需要约定的三件事
最后说三个我在团队内部定下的约定,它们比代码本身更能避免未来踩坑:
第一,t3code 的ALPHABET、MULTIPLIER、OFFSET、默认码长一旦定下来,就列入配置冻结,任何改动必须触发一次全面的短码迁移评审。第二,所有对外接口在解码失败时返回统一的错误码,不要让调用方自己去猜“为什么这个码不对”。第三,不要在日志里打印原始主键和短码的对应关系,尤其是订单类业务,日志脱敏要跟上。
我自己这几套约定跑下来,团队新成员接手时基本没有任何认知负担。他们不需要理解仿射置换的数学原理,只需要记住:encode是主键变形,decode是变形还原,校验失败的码不要查库。
最后补两句使用体会
如果非要总结,我不会说“t3code 很安全”,我只会说它解决了一个很具体的问题——让自增主键在对外场景里变得不那么扎眼,同时让输入错误在入口处暴露。真正需要防伪造、防暴力枚举的业务,我建议直接上 HMAC 或专业的签名服务,t3code 不承担这个职责。
有一个小技巧值得分享:我在很多项目里会额外写一个命令行脚本,批量把一批主键编码成短码导出给运营。这个脚本平时不起眼,但活动上线前做券码预生成、数据校验时,能省掉一大半沟通成本。你把 t3code 的encode和decode做成最朴素的 CLI,团队成员会真心感谢你。