1. 为什么需要自己写 36 进制算法
1.1 标准库能做的事,为什么还要自己动手
先说一个很多人第一次听到 36 进制时的疑问:Go 标准库不是已经有strconv.FormatInt(n, 36)了吗?直接传一个 int64,就能输出一串包含 0-9 和 a-z 的字符串,为什么还要自己写一套 36 进制算法?
我当初也是这么想的,直到实际需求里出现了几个标准库不太好搞的点。第一,FormatInt只接受 int64,遇到 uint64 或者更大的数就得先绕一步;第二,字符集被固定在 0-9a-z,有些业务场景希望用大写字母,有些场景甚至希望替换掉容易看错的字符;第三,标准库的解码入口不算统一,对非法字符、溢出、空字符串的处理比较宽松,放到对外接口里容易被钻空子。还有一个更实际的原因:面试和内部技术分享经常拿这种小算法当题目,光是“能跑”不够,你得能讲清楚每一步在做什么,边界条件在哪里,性能瓶颈在哪里。
所以我决定用 Go 从零写一套 36 进制编解码算法,附带源码。这套算法做完之后,我顺手加到了内部的一个短码生成系统里,用来把流水号转换成更短的字符串,再存回数据库做索引,效果一直很稳。
1.2 36 进制能省多少长度
36 进制的“36”,来源于 10 个数字加 26 个英文字母,正好凑出 36 个字符。相比二进制和十六进制,它的字符集更“密”,同样一个数值,位数更少;相比 62 进制(0-9a-zA-Z),它又避免了大小写混用带来的排序和输入问题。对于业务编码来说,位数的减少是实打实的收益。
拿数据说话。假设一个数值是 123456,十进制写出来是 6 位,36 进制是“2N9C”,4 位;数值到 999,十进制 3 位,36 进制只需要 2 位;数值到 10 亿,十进制 10 位,36 进制是 6 位;对于 int64 的最大值,十进制要写 19 位,36 进制最多 13 位。
| 数值 | 十进制位数 | 36 进制位数 |
|---|---|---|
| 999 | 3 | 2 |
| 123456 | 6 | 4 |
| 1,000,000 | 7 | 4 |
| 1,000,000,000 | 10 | 6 |
| int64 最大值 | 19 | 13 |
所以你会发现,短链接、邀请码、优惠券号这类场景里,用 36 进制比直接用自增 ID 写出去整整短三分之一以上。这还没算上另一个隐藏好处:字符串里不带特殊符号,复制到 Excel、URL 参数、手机短信里都不会被转义或者截断。
2. 算法核心拆解
2.1 十进制转 36 进制:取余逆序
从十进制转 36 进制,核心只有一句话:不断除以 36,记录余数,最后把余数逆序排列。
以 123456 为例:
- 123456 除以 36,商 3429,余数 12,对应字符是“C”
- 3429 除以 36,商 95,余数 9,对应字符是“9”
- 95 除以 36,商 2,余数 23,对应字符是“N”
- 2 除以 36,商 0,余数 2,对应字符是“2”
从下到上读余数:2、23、9、12,映射成字符是“2N9C”。注意这里的顺序,第一次取模得到的是最低位,所以必须把生成过程反着读,这就是“取余逆序法”。反向验证一下:2 乘以 36 的 3 次方,加上 23 乘以 36 的 2 次方,加上 9 乘以 36 的一次方,加上 12,正好回到 123456。
这个思路所有进制通用。把 36 换成 2 就是二进制,换成 16 就是十六进制,代码结构完全不用动。这也是我建议大家不要死背实现,而是理解“进制就是按权展开”这件事的原因。
2.2 36 进制字符串转十进制:权值累加
反向解析更直接,从高位开始,每读到一个字符,先拿到它对应的数值,然后让结果乘以 36 再加上这个值。
还是拿“2N9C”验证:从最高位“2”开始,结果是 2;读“N”,2 乘以 36 等于 72,加 23 等于 95;读“9”,95 乘以 36 等于 3420,加 9 等于 3429;读“C”,3429 乘以 36 等于 123444,加 12 等于 123456。每一步都和人肉手算的权重展开一模一样。
这个写法还有个好处:边读边算,不需要预先知道字符串长度,不需要额外开数组,一个变量从头滚到尾。数据量小的时候看不出来,数据量大了,这种一次遍历的写法对缓存和分配都更友好。
2.3 字符集与查表法
36 进制需要一个约定好的字符表。最常见的表是“0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ”,前 10 位给数字,后 26 位给大写字母。表的关键点在于:字符的下标就是这个字符代表的值,比如“A”的位置是 10,“Z”的位置是 35。
实现时可以直接用 ASCII 算,也可以查表。查表的好处是逻辑集中、可读性好,后续如果要换成 Base62 或者去掉易混字符,只改一张表就够了。我实际项目里就碰到过产品要求把“0”和“O”去掉,避免用户肉眼分不清,当时就是靠抽出一个字符表变量,改一行就全搞定了。
3. 完整源码实现与逐行解析
3.1 完整源码
我用一个独立的base36.go文件来实现,包名取base36,对外暴露两个核心函数:EncodeUint64和DecodeToUint64。完整代码如下:
package base36 import ( "errors" "math" ) const digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" var ( ErrInvalidChar = errors.New("base36: invalid character") ErrOverflow = errors.New("base36: overflow") ) // EncodeUint64 将非负整数编码为 36 进制字符串 // 字符集为 0-9A-Z,输出不带前导零 func EncodeUint64(n uint64) string { if n == 0 { return "0" } // uint64 在 36 进制下最多 13 位,按最大值分配缓冲区 buf := make([]byte, 13) i := len(buf) - 1 for n > 0 { buf[i] = digits[n%36] n /= 36 i-- } return string(buf[i+1:]) } // EncodeInt64 是 EncodeUint64 的带符号版本 // 负数统一在前面加负号,正数行为与 EncodeUint64 一致 func EncodeInt64(n int64) string { if n < 0 { return "-" + EncodeUint64(uint64(-n)) } return EncodeUint64(uint64(n)) } // DecodeToUint64 将 36 进制字符串解码为 uint64 // 兼容大写和小写字母,遇到非法字符或溢出会返回错误 func DecodeToUint64(s string) (uint64, error) { if len(s) == 0 { return 0, ErrInvalidChar } var result uint64 for i := 0; i < len(s); i++ { c := s[i] var v uint64 switch { case c >= '0' && c <= '9': v = uint64(c - '0') case c >= 'A' && c <= 'Z': v = uint64(c-'A') + 10 case c >= 'a' && c <= 'z': v = uint64(c-'a') + 10 default: return 0, ErrInvalidChar } // 溢出检查必须放在累加之前 if result > (math.MaxUint64-v)/36 { return 0, ErrOverflow } result = result*36 + v } return result, nil }3.2 编码函数的产品级细节
EncodeUint64里有一处跟很多人写的不一样:我先按 13 的长度一次性分配了字节数组,而不是用append边算边追加,最后再反转。原因很简单,uint64 最大值对应的 36 进制位数,我已经在开发前算好了,是 13 位。既然长度上限是确定的,直接分配整块数组,从尾部向前填,填完直接切出有效部分,既省掉了反转循环,也避免了几次容量扩容的分配操作。
代码里的n % 36拿到余数后,用这个余数作为下标去访问digits字符串,正好取到对应字符。这就是查表法在代码里的体现。buf[i] = digits[n%36]一行,同时完成了“取余、查表、存字符”三件事。
要提醒一点:如果你直接写buf = append(buf, digits[n%36]),那么得到的是从低位到高位排列的字符串,比如 123456 会得到“C9N2”,最后必须手动反转。我的版本从数组尾部倒着写,天然不用反转,但代价是逻辑稍微绕一点。这块特别容易在复制代码时改出 bug,建议自己动手跟一遍数值流程。
3.3 解码函数的产品级细节
DecodeToUint64里有一个极其关键的地方:溢出检查要在累加之前做。很多人写完result = result*36 + v之后才想起来要做溢出判断,但在 Go 里,uint64 溢出之后不会报错,而是会静默绕回 0,你再判断就已经晚了,数据已经丢了。
溢出检查的写法是:
if result > (math.MaxUint64-v)/36 { return 0, ErrOverflow }原理是:我们要执行的是result*36 + v,这一步结果不能超过math.MaxUint64。反过来想,就是result不能大于(math.MaxUint64-v)/36。如果超过了,说明后面无论怎么乘、怎么加,都会爆掉。这个判断只会让极端场景多付出一次比较代价,性能上几乎可以忽略。
另外,解码函数我故意兼容了大小写字母。c >= 'A' && c <= 'Z'处理大写,c >= 'a' && c <= 'z'处理小写。这样别人如果是小写字母输入“2n9c”,也能正确解析回 123456。实际接口里经常遇到用户键盘输入小写的情况,提前兼容能省掉大量的脏数据清洗代码。
4. 边界条件与健壮性处理
4.1 零、负数与空字符串
EncodeUint64对 0 单独返回了字符串“0”,不能走循环,否则函数直接返回空字符串。这个坑我在第一版就踩过,当时拿 0 去调用,发现变成空字符串,存到数据库里做唯一索引时差点出问题。
负数处理稍微麻烦一点。EncodeUint64的参数类型是uint64,本身传不进负数,所以我把带符号的逻辑封装成EncodeInt64:负数先取绝对值转成字符串,再在前面拼一个负号。这里要注意,uint64(-n)只有在n是math.MinInt64时也成立,因为 -(-9223372036854775808) 本身在 int64 里也装不下,但转换成 uint64 是能表示的。实际业务如果确认不会出现极端负数,这层封装足够了。
空字符串解码时,要直接返回错误。我第一次实现时,空字符串循环体一次都不执行,结果默认返回 0,这在业务上是个隐性风险:比如用户传了个空参数,你以为是合法 ID 0,实际是参数缺失。现在改成显式返回ErrInvalidChar,语义更清晰。
4.2 大小写、非法字符与溢出
解码函数对非法字符一律返回ErrInvalidChar。判断顺序是:数字、大写字母、小写字母,最后default分支兜底。这样一来,空格、逗号、下划线、中文、UTF-8 多字节字符都会被拒绝。
有人可能会问,为什么不直接先strings.ToUpper(s)再遍历?可以,但那样每个字符都要先走一遍大写转换,还会额外产生一次字符串分配。直接用 ASCII 范围判断,写起来稍微啰嗦,性能上是零拷贝的,而且在遍历过程中发现第一个非法字符就能立即返回,不用等整个字符串处理完。
4.3 用 big.Int 支撑更大的数
uint64上限大约 1844 亿亿,很多业务场景其实够用,但如果你要做的是高并发发码系统,ID 增长到超过 uint64 也不是不可能。遇到这种情况,就要上math/big了。
用big.Int实现 36 进制编码的思路完全一样,只是把n % 36和n / 36换成了DivMod:
func EncodeBig(n *big.Int) string { if n.Sign() == 0 { return "0" } zero := big.NewInt(0) base := big.NewInt(36) mod := new(big.Int) buf := make([]byte, 0, 64) tmp := new(big.Int).Set(n) for tmp.Cmp(zero) > 0 { tmp.DivMod(tmp, base, mod) buf = append(buf, digits[mod.Int64()]) } // 反转 buf for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 { buf[i], buf[j] = buf[j], buf[i] } return string(buf) }解码侧同理,每次乘以 36 再加低位,全部由big.Int处理,就不存在溢出的概念了。扩展代码大约二十行,但能让整个工具的使用范围从 uint64 直接跳到“任意大整数”。我在模拟项目里试过用 256 位的大数生成模拟数据,编码出来的字符串长度也就 20 个字符左右,整体表现很稳。
5. 性能测试与优化
5.1 基准测试怎么设计
算法写完不能只凭直觉说快,最好用 Go 内置的 benchmark 跑一下。新建一个base36_test.go,写入两个用例:
package base36 import "testing" func BenchmarkEncodeUint64(b *testing.B) { for i := 0; i < b.N; i++ { _ = EncodeUint64(uint64(i)) } } func BenchmarkDecodeToUint64(b *testing.B) { s := "AZF12QKZ9" for i := 0; i < b.N; i++ { _, _ = DecodeToUint64(s) } }跑测试用go test -bench=. -benchmem,就能看到每次操作的耗时和内存分配次数。我在普通开发机上实测,EncodeUint64大概在几十纳秒级别,DecodeToUint64比编码略慢一点点,但两者都在纳秒区间,远快于一次网络请求或者数据库查询。内存分配方面,编码和解码每调用一次会产生一次结果返回所需的空间,这是不可避免的。
5.2 几个优化空间
第一个优化点是去掉反转。很多人用的写法是append收集字符,最后反转。我的实现直接按 13 长度分配、从尾部倒着填,省掉了反转这一步。要是你还想更彻底,可以连分配都省,用一个固定大小的数组放在栈上,代码就变成:
func EncodeUint64Fast(n uint64) string { if n == 0 { return "0" } var buf [13]byte i := 13 for n > 0 { i-- buf[i] = digits[n%36] n /= 36 } return string(buf[i:]) }这里[13]byte数组在大多数情况下会被编译器放在栈上,不触发堆分配。转成 string 时仍然有一次拷贝,但已经比原来的分配加拷贝省了一截。不过我实际用下来,性能差异对普通业务影响很小,更重要的反而是代码是否容易理解。
第二个优化点是解码时减少分支。现在的switch每个字符都有三次比较,如果把字符表做成一个长度为 128 的数组,下标直接映射成数值,循环里就是一次数组访问加一次非零判断,代码更整齐,性能也更稳定。代价是初始化数组要多写几行。取舍下来,我用字符表方案写了一个版本,但最后还是换回了 ASCII 比较。原因是这个算法本身就是 IO 路径上的一个小环节,90% 的时间开销在别处,不如把代码的可读性保住。
6. 常见问题与坑
6.1 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 编码结果顺序反了 | 取余后直接拼接,忘记反转 | 使用尾部倒填法,或循环结束后统一反转 |
| 输入 0 输出空字符串 | 没有单独处理 0 | 在进入循环前判断n == 0 |
| 解码超长字符串变成错误结果 | uint64 溢出被静默丢弃 | 在累加前做result > (MaxUint64-v)/36判断 |
| 小写字母无法解码 | 只按大写字母处理 | ASCII 比较时同时兼容 a-z |
| 空字符串被当成 0 | 循环体没执行就返回默认值 | 函数开头判断长度 |
| 解码结果与标准库不一致 | 用了不同字符集 | 确认用的是“0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ” |
| Base62 场景下解码串号 | 把 36 进制表直接套 62 进制 | 换表时同步替换解码映射 |
6.2 我踩过的几个实战坑
第一个坑是大小写混用。早期版本解码只认大写,结果线上从手机端传过来一串小写字母,解码失败率直接拉高。改完之后我才意识到,对外服务永远不要假设用户会按你的文档输入。
第二个坑是编码结果里出现了 0。某次功能联调,对方的接口用字符串做排序,我还很自信地跟人家说,这串字符按字典序排就等于按数值排。结果被当场打脸,因为“0”和“00”编出来的字符串语义相同,但字典序不同。后来我规定所有编码结果不保留前导零,格式统一。
第三个坑是并发安全。我在内部系统里把这个工具当公共函数用,后来代码评审时有人问,digits这个常量字符串被多个 goroutine 同时读会不会有问题。Go 里的常量字符串本来就是只读的,多个 goroutine 并发访问完全安全。但如果你图省事,把它改成var digits = []byte("0123456789...")并且后续代码里还有原地修改,那并发场景就会出大问题。建议坚持用 const 声明,别给自己留这个隐患。
第四个坑是只测了正常值,没测边界值。我第一次提交代码时,只测了 123456、999999 这种“看起来合理”的数,后来加了一个测试用例用math.MaxUint64编码再解码,结果发现解码端的溢出检查处理得不够干净,直接抛了个错误。从那以后,任何进制算法的测试用例,我都会带上 0、1、35、36、math.MaxUint64这几个临界值,因为它们最容易把循环、取模、溢出判断的漏洞暴露出来。
6.3 后续扩展思路
如果你已经掌握了这套 36 进制算法,再往前的路其实很好走。第一,改一张字符表就能升级成 Base62,适合需要尽量短、不介意大小写混用的外部邀请码场景;第二,在字符表里去挑出易混字符,比如去掉 0 和 O、1 和 I,就能生成对用户更友好的体验码;第三,在编码结果里拼一个校验位,比如用所有字符的数值总和取模生成最后一位,就能在解码时快速识别人工输入抄错的情况。
我个人最后体会最深的一件事是:进制转换这种东西,网上一搜一大把,标准库也能直接调,但亲手写一遍之后的收获,远远不止“能用”这两个字。你能清楚地知道每一步数值变化,能够在出问题时脱口说出原因,也能在面对别人“为什么不用库”的问题时,给出有理有据的回答。
这套源码量不大,但它是我后来写短码生成、哈希摘要缩写、数据脱敏这些功能时最常翻出来参考的基础模块。需要的时候拿过来改一改字符表,一个新业务编码方案十几分钟就能落地。