
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 进制位数99932123456641,000,000741,000,000,000106int64 最大值1913所以你会发现短链接、邀请码、优惠券号这类场景里用 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[i1:]) } // 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换成了DivModfunc 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 i1, 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就能生成对用户更友好的体验码第三在编码结果里拼一个校验位比如用所有字符的数值总和取模生成最后一位就能在解码时快速识别人工输入抄错的情况。我个人最后体会最深的一件事是进制转换这种东西网上一搜一大把标准库也能直接调但亲手写一遍之后的收获远远不止“能用”这两个字。你能清楚地知道每一步数值变化能够在出问题时脱口说出原因也能在面对别人“为什么不用库”的问题时给出有理有据的回答。这套源码量不大但它是我后来写短码生成、哈希摘要缩写、数据脱敏这些功能时最常翻出来参考的基础模块。需要的时候拿过来改一改字符表一个新业务编码方案十几分钟就能落地。