“字元组合”这个东西我第一次认真琢磨它是在做一个工单系统的时候。需求很简单给每一张工单生成一串短码用户报修时念给客服听要求短、好记、不容易输错、还不能让人顺着规律猜到业务量。结果团队里第一批人直接拿 UUID 截了 8 位上线第一天就撞码第二批人用时间戳转字符串码是够短了可稍微推两下就能算出前后单量第三批人搞了个随机数生成倒是安全了结果每次都要查库去重表一大查询就开始拖慢整体写入。后来回头看大家其实都在做同一件事——字元组合。说得直白一点就是从一套基础字符集里按照某种规则抽取并排列字符生成一个满足特定约束的字符串。而“CNSH”这个缩写我习惯把它理解成一套处理范式CCharacter选字符源NNormalize做归一化SShape定结构规则HHash做散列编码。这篇文章就把这套范式从头到尾拆开讲适合后端开发、数据工程、运维以及所有被“短码、邀请码、优惠码”折磨过的人参考。1. 字元组合到底在解决什么问题1.1 先从翻车案例说起先说说我见过的那三类典型翻车你大概率也遇到过。第一类UUID 截断。UUID 本身是全局唯一的但你截成 8 位之后唯一性依赖的是概率不是数学保证。短码空间如果是 62^8 ≈ 2.18×10^14看着很大可一旦业务量到百万级别生日悖论就会找上门——碰撞概率远比你直觉里高得多。我见过一个内部会议系统参会码用的就是 UUID 前 8 位上线第一周就出现了两个不同会议生成同一个入会码的情况当场事故。第二类时间戳编码。把秒级或毫秒级时间戳转成 base62 或 base36 输出看起来短实际上致命。码本身就是时间顺序攻击者只要拿到一个码就能反推生成时间进而推算前后单量。更尴尬的是如果同一秒内生成多个码还需要额外加随机后缀加了后缀又回到了碰撞问题。第三类纯随机 查重。每次生成一个随机字符串然后去数据库里查重。这个方案在量小的时候没问题但你会发现两个问题一是写入链路多了一次查询延迟上去了二是随着存量码越来越多空余空间变小碰撞率上升重试次数变多最坏情况下生成一个码可能要循环几十次。我把这三种常见做法整理了一下感受会更直观方案优点致命问题适合场景UUID 截断实现简单、零依赖碰撞靠概率量一大必出事不推荐时间戳编码无碰撞、可排序可被遍历、泄露业务量内部非敏感场景随机 查重不可预测重试开销高、索引压力大小规模场景字元组合方案短、稳定、可控需要设计没法拿来就用业务短码/邀请码/凭证码1.2 字元组合问题的共性模型把上面这些翻车案例抽象一下字元组合问题其实有非常固定的结构一共三个要素第一个是源空间。你要对什么东西做组合通常是一个内部编号、数据库自增 ID或者一个业务对象标识。源空间决定了输入的范围和量级比如一千万用户和一亿订单对组合方案的要求完全不同。第二个是字元空间。你允许输出里出现哪些字符是纯数字、小写字母、还是大小写混合字元集的大小直接决定输出长度。字元集越大同样长度下能容纳的组合数越多但可读性和输入便利性可能越差。第三个是映射规则。源空间里的值如何映射到字元组合这部分是最核心的设计空间要不要可逆、要不要防遍历、要不要带校验。映射规则就是字元组合的“算法灵魂”很多人把精力全花在选字元集上却忽略了映射规则才是决定方案好坏的关键。理解这个模型之后你会发现所有短码系统的本质都是一样的一句话就是“在给定源空间和字元空间的前提下找到一条既满足约束、又满足业务语义的映射规则”。1.3 适用与不适用的边界字元组合不是万能的我建议你在动手前先明确边界。适合的场景包括邀请码、优惠券码、工单号、短信凭证码、短链 Key、会议室入会码。这些场景的共同特点是字符串要被人在某处抄写、输入、传播短且好认是第一诉求同时业务要求它有一定的唯一性保障和防猜测能力。不适合的场景也要说清楚如果是高安全等级的一次性令牌比如登录 Token、支付确认码就不要用可逆的字元组合方案。这种场景需要的是不可预测的熵标准做法是用 CSPRNG密码学安全伪随机数生成器生成足够长的随机串服务端存摘要或做短时有效校验而不是追求“能反解出原始 ID”。字元组合解决的是“好看、好输、好用”的问题不是“绝对安全”的问题。另外一个常见误用是把字元组合当加密用。我之前见过有人用 base62 编码用户手机号以为这就是加密。其实编码只是表达形式的转换没有密钥参与解码就是公开的。你要的“不让别人猜出来”靠的是混淆和算法保密这在安全模型里是非常脆弱的假设后面我会具体讲。2. CNSH 四步法字符源、归一化、结构模板、散列编码2.1 CCharacter先挑一套不会给自己惹麻烦的字元源很多人第一步就随便写了一个字符串ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789当字符集。这个字符串看起来没什么问题实际上埋了巨坑。最大的坑是易混淆字符。0和O1和l、I在短信字体、手写体、部分 UI 字体里几乎无法区分。用户输错一次就要重新来找你客服成本蹭蹭上涨。我在实际项目中用的字符集是23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ这个集合去掉了0、1、I、O、l一共 58 个字符和比特币地址用的 Base58 字符集基本一致。去掉几个字符带来的组合损失完全可以接受却能省下大量人工纠错成本。另一个容易忽略的问题是字符集里的字符是否适合朗读和电话播报。如果你需要客服在电话里逐字念码建议进一步限制为“易读字母表”比如 NATO 音标字母表里发音差异较大的那部分字符把B、P、D、T这类近音字也考虑剔除。还有大小写的问题。有些系统为了省事生成时用混合大小写但用户输入时习惯性全部转小写如果后端做精确匹配就会直接失败。这里我建议在生成端就决定好要么全大写输出要么全小写输出并且在后端做归一化匹配。不要搞“输出大写、入库小写、查询再转大写”这种绕来绕去的链路迟早出幺蛾子。2.2 NNormalize所有输入先在入口处做归一化归一化这一步是新手最容易忽略、但线上问题最多的环节。字元组合的输入往往不是用户直接输入的而是经过了复制粘贴、OCR 识别、语音转录、客服手动录入等重重关卡。我遇到过的最典型场景用户从短信里复制验证码粘贴进 App 时莫名其妙带了一个不可见字符或者 OCR 把8识别成了B还有用户在输入框里手输大写字母手机自动纠错给改成了小写。如果后端拿原始字符串直接比对大概率就失败了。所以我的习惯是在所有入口做一套标准归一化函数至少包括这几步全角转半角用户可能在中文输入法状态下输入了全角数字和字母。统一大小写按业务约定全部转小写或全部转大写。剔除空白字符去空格、去连字符、去不可见控制字符。Unicode 规范化这一步很多人会忽略但非常重要。同一个“é”可能是 U00E9 这个预组合字符也可能是e U0301 的组合序列两者视觉一致但字节完全不同。字元组合场景建议统一走 NFKC 规范化。白名单过滤清洗后再次确认字符串只包含允许的字元其余一律判为非法输入。归一化不只是入口要做出口也要做。生成端生成一个码之后建议立即对它跑一遍归一化确认没有任何字符在传输过程中可能被改变。我曾经遇到过一个码里包含了小写l在部分字体下渲染出来和I几乎一样用户反复输入失败最后发现是我们自己选的字符集有问题而不是用户的锅。2.3 SShape组合结果的“长相”要提前设计字元组合的第三个环节是确定输出结构我称为 Shape。这里主要考虑三个问题定长还是变长、要不要分段、要不要校验位。定长几乎是必须的。变长的码虽然也能用但用户在不知道长度的情况下输入会很困惑而且 UI 上的体验很差。定长的码可以直接用输入框的maxlength约束从交互层就避免用户漏输。定长也方便客服电话指导“8 位码我念一句你输一句。”分段是可选的。如果码比较长比如 12 位以上可以用连字符分成几段比如8A3F-2K9D-4Q7W这样记忆负担会小很多用户抄写时也不容易错位。但你也要考虑分段后用户输入时会不会把连字符也带进来如果会那后端归一化函数里就必须去掉连字符再做校验。校验位是一个被低估的设计。一个简单的校验位可以让 80% 的输入错误在本地就被发现不用等后端查询报错。实现方式有很多种最简单的是把所有字符的序号按权重相加对某个质数取模把余数映射成一个校验字符放在末尾更正统的方案是 Luhn 算法或者 Damm 算法。校验位不增加多少复杂度却能把客服从“重新输入一遍试试”里解放出来。我的一个实际项目里用过的结构是这样2 位业务前缀 4 位日期信息 6 位字元组合 1 位校验字符完整码长 13 位。业务前缀用来区分码的用途日期信息用来做数据冷热分层字元组合段承载核心信息校验位放在最后。这个结构上线之后无论是排查问题还是人工输入都很顺畅。2.4 HHash分布均匀与可逆性如何权衡最后一步是最核心的 H——散列编码。这里要解决的核心矛盾是怎么在“可逆”和“防遍历”之间找到平衡点。如果你只需要“短码能反查回原始 ID”最简单的做法就是把自增 ID 直接转成 58 进制。但这个方案完全扛不住遍历攻击者只要把 58 进制串解码就知道下一个码是什么。这在很多业务场景里是不可接受的。反过来的极端是“完全随机”。用 CSPRNG 生成随机串然后存库查重。这个方案不可预测但需要存储映射关系无法离线反查。真正的平衡点是用“保留格式加密”的思路把 0 到 N-1 的整数空间映射到同样大小的整数空间用密钥做混淆输出再转成字元串。由于映射是双射所以不存在碰撞而且在没有密钥的情况下很难从输出反推输入顺序。FF1Format-Preserving Encryption就是这类问题的标准解法像 AES-FF1、还有 Google 的 Tink 库都提供了现成实现。如果你不想引入加密库只求演示效果也可以用 LCG 之类的乘性混淆来打散顺序。下一章我就拿这个思路写一个完整的实现先让你直观感受“如何把可逆和防遍历结合起来”然后我们再讨论健壮性。3. 实操8位短码生成器的完整实现3.1 需求拆解我拿一个非常典型的需求来演示给 1000 万用户生成邀请码。要求是这样的邀请码 8 位只能包含 58 个不易混淆字符可以通过邀请码反查用户 ID不能让人通过连续注册来遍历用户数量服务端希望不依赖外部存储就能离线校验码的合法性。我把需求拆成设计输入源空间用户 ID假设是 0 到 1000 万之间的整数实际部署时可能是数据库自增 ID。字元空间58 个易读字符即23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ。映射规则可逆、打散、无碰撞。输出长度8 位。58^8 ≈ 1.28×10^14而用户量是 10^7所以空间绰绰有余可以放心设计成定长 8 位。3.2 核心代码与每一行的作用这里我用 Python 写一个最小可运行的版本注释写清楚每一步在做什么。CHARSET 23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ BASE len(CHARSET) # 58 MASK 0x7fffffff # 用 31 位整型空间做混淆 A 1103515245 # LCG 乘数 C 12345 # LCG 增量 SALT 20250314 # 固定盐相当于密钥生产环境从配置读取 def _mix(x: int) - int: # 乘性混淆把相邻的 uid 打散到 31 位整数空间的不同位置 return (x * A C) MASK A_INV pow(A, -1, 1 31) # A 在 mod 2^31 下的逆元用于解密回推 def encode(uid: int, length: int 8) - str: x _mix(uid ^ SALT) # 先用盐异或再混淆 out [] while x 0: x, rem divmod(x, BASE) out.append(CHARSET[rem]) while len(out) length: out.append(CHARSET[0]) # 补位前面已经说了这只是演示简化 return .join(reversed(out)) def decode(code: str) - int: x 0 for ch in code: x x * BASE CHARSET.index(ch) x ((x - C) * A_INV) MASK return x ^ SALT逐行说一下。_mix是一个标准的 LCG 线性同余生成器核心作用不是生成随机数而是把“连续的 uid”映射到“看起来不连续的 31 位整数”。这样你拿到两个相邻用户 ID 的邀请码肉眼完全看不出规律。uid ^ SALT这一步是加盐。盐相当于一个密码本攻击者如果不掌握盐即使知道算法也无法轻易反推原始 uid。注意到这里盐是全局固定的所以本质上只是提高猜测门槛不是加密后面我会讲怎么在正式项目里加强。divmod循环是进制转换的核心。每次取余得到当前最低位的字符下标整除后继续下一轮直到商为 0。这就是把整数转成 58 进制表示的经典过程。decode就是逆过程。先把字符序列转成 58 进制整数然后做逆混淆。A_INV是乘数在模 2^31 下的逆元这一步相当于把“乘 A 加 C”的操作撤销。给你看一组实际输出encode(10000001) - 2XK7fQzV encode(10000002) - 2NgWThwM encode(10000003) - 2QBKZCQ7从这组输出里你能看到相邻 ID 生成的结果完全无规律但又可以稳定地反解回原始 ID。这就是“可逆 防遍历”的最小实现。3.3 唯一性验证与碰撞处理这个演示方案本质上是一个双射映射0 到 2^31-1 的整数空间内每个值经过混淆和进制转换后都对应唯一一个输出。所以在 uid 不超过 2^31 的前提下理论上不会碰撞。但理论归理论工程上我建议你在上线前做一次全量验证。写一个批量任务把库里已有的所有 ID 编码成码插入一张验证表对码字段建唯一索引。如果有任何一条插入失败就说明你的编码撞了立刻排查算法哪里出了问题。我在一个项目里真的用这个方法抓出过一次 bug当时线上跑了两个月某一天新的数据量突破了当初设计时假设的上限溢出导致碰撞如果没有这个唯一索引兜底问题会晚很久才暴露。生产环境建议这样做先把码的唯一索引建好再跑迁移脚本把所有存量 ID 的码批量刷新最后把生成逻辑改成“先编码、再插入、捕获唯一冲突、重试”。重试逻辑里不要盲目循环要先检查是不是算法缺陷如果是算法缺陷重试一万次也绕不过去。3.4 防遍历和安全性的现实取舍我必须强调上面这个 LCG 演示方案的防遍历能力非常有限它适合用来理解概念不适合直接上生产。原因在三个方面。第一LCG 是线性结构只要收集到足够多的输出样本反向推导出 A、C、盐的成本很低。第二31 位的整数空间太小虽然用户量只有一千万但攻击者可以构造大量的编码请求来建立映射表慢慢缩小空间。第三盐是静态的一旦泄露整个方案就退化成单纯的进制转换。如果要做真正抗攻击的生产方案我的建议有几层递进选择第一层把 LCG 换成 FF1 保留格式加密。FF1 用 AES 做底层轮函数能从数学上提供更强的混淆同时保持输出空间固定、可逆、无碰撞。这是目前行业里做邀请码、卡号、凭证码这类需求最正规的做法。第二层如果业务允许放弃算法反查改用“随机码 数据库索引”。库表里存随机码和业务 ID 的映射查询时走唯一索引。这个方案牺牲了离线校验能力但换来了最强的不可预测性很多高安全场景都选这条路。第三层如果真的要在无状态服务里做校验建议给邀请码加上有效期和服务端签名。比如码内嵌入过期时间戳码尾加一段 HMAC 签名验证时先校验签名再解码从机制上杜绝离线伪造。选哪一层取决于你对安全威胁的评估。我见过很多团队为了“显得专业”一上来就上密码学方案结果被复杂度拖垮也见过团队用纯进制转换上线被竞对写了个脚本把所有优惠码遍历个遍。我的建议是普通营销码用第一层足够涉及资金、权限、数据的码必须考虑第二层或第三层。4. 中文语境下的字元组合从拆字到字形结构4.1 输入法里的字元字根组合字元组合这个概念不只是在短码生成里有中文信息处理里其实一直是核心命题只不过我们平时没有把它和“编码方案”联系起来。最典型的例子就是输入法。五笔输入法的本质就是一套“字元组合”系统把汉字拆成有限个字根每个字根等同于一个基础字元然后再按照汉字的书写顺序把字根组合起来。你看“想”字五笔编码是SHNU拆成“木目心”这就是字根组合。仓颉、郑码的底层逻辑也一样只是字根集和编排规范不同。从输入法视角来看“字元组合”你会发现一个非常有意思的结论好的字元组合方案一定是完备且无歧义的。所谓完备是指所有目标汉字都能用这套字根集表达出来所谓无歧义是指一个汉字拆分出来的字根序列是确定的不因输入者不同而产生不同结果。这个原则放到短码系统里完全通用你的字符集要能覆盖所有需要编码的对象而且映射规则要保证同一个对象永远得到同一个输出。4.2 Unicode 组合字符和“看起来是一个字”的陷阱再往底层看字元组合在 Unicode 体系里也有对应的概念那就是组合字符。比如字母é在 Unicode 里有两种表示方式一种是 U00E9直接是一个完整的字符另一种是 U0065小写 e后面跟 U0301组合重音符号是两个码点组合后显示成同一个字形。这两种表示在视觉上几乎一样但在计算机内部是完全不同的字节序列。中文里也有类似情况。偏旁部首和汉字组字有些可以用组合序列表达有些则不行。更常见的是在文本处理时同一个汉字在不同操作系统、不同输入法下可能被打成不同的码点序列比如带声调的拼音字符、注音符号组合字符等。如果程序里不做 Unicode 规范化会出现一个非常诡异的 bug用户输入的字符串看起来是对的但程序比对时就是不相等。我在处理用户输入校验时踩过这个坑。当时用户反馈“我的邀请码明明输入对了系统说无效”排查到最后发现是用户用的手机输入法自动把一个字符转成了组合形式和我们生成端用的预组合形式在码点上不一致。解决方案就是在归一化环节统一跑 NFKC 规范化彻底解决这类问题。这件事也让我意识到字元组合不只是“选字符、定规则”这么简单底层还牵涉字符编码的等价性问题处理不好就会在线上以各种意想不到的方式冒出来。4.3 排版合字与字元组合的相通逻辑再往外扩展一步字体排印领域的合字Ligature本质上也是字元组合的一种形态。比如拉丁字母里的fi、fl在排版时会被渲染成一个合成的字形单元而不是两个单独的字母。字体引擎在渲染时看到f和i相邻自动替换成合字字形。中文排版里同样有类似的上下文替换逻辑。比如同一个偏旁在不同字形位置会有不同的呈现形态像“火”在左边作偏旁时写成“火字旁”形态在底部时又会变成四点底相关形态。从字元组合的角度看这就是“同一个基础字元根据上下文变化输出形态”的典型例子。这些例子说明“字元组合”绝不只是短码、邀请码领域的专利它是字符处理、排版、输入法、编码规范等多个技术领域共享的底层思维方式。理解了这一点你在设计任何涉及字符转换的系统时都会多一层敬畏字符串看似简单底层的坑深不见底。5. 字元组合实战中的反面教材与绕坑经验5.1 易混字元、规范化与等价的坑这一节全是实战中攒下来的血泪经验我按踩坑频率从高到低给你列一下。第一个坑是易混字元。前面已经讲过字符集要避开0/O、1/l/I但我发现即便用了 58 字符集仍然会漏掉一些在特定字体下长得差不多的字符比如字母w和W、m和M在部分窄字体下区分度不高。解决思路不只是在字符集层面规避还要在 UI 层面配合生成结果展示时用等宽字体输入框自动转大写这样能显著降低输入错误率。第二个坑是大小写归一化不一致。生成端输出大写用户手输小写后端直接精确匹配失败。正确的做法是入库前、查询前、校验前全部统一经过同一个归一化函数。我见过太多系统只在注册入口做了大小写转换邀请码校验时又忘了做导致用户注册成功但邀请关系对不上。第三个坑是 Unicode 等价字符。前面提到过é的两种表示方式很多人觉得只要用 NFKC 就万事大吉其实 NFKC 也不能解决所有问题比如全角半角在某些场景下依然可能被遗漏。我的建议是把“全角转半角、NFKC 规范化、统一大小写、去空白”写成一个纯函数并配上单元测试所有入口都复用它不要到处复制粘贴逻辑。第四个坑是校验位计算不一致。如果码尾有校验位校验算法必须是确定且唯一的。我曾经遇到一个系统生成端算校验位时用了字符在 ASCII 里的序号校验端却用了字符在字元集里的下标两边算出来的校验结果当然不一样所有码在核验时都是非法的。这类问题很隐蔽因为它不会直接报错只会表现为“所有校验都失败”。坑点典型表现处理方案易混字元用户反复输错剔除 0/O/1/l/I用等宽字体展示大小写不一致输入正确却校验失败统一走归一化函数Unicode 等价字符看起来相同、字节不同NFKC 规范化校验位算法不一致所有码校验失败生成端和校验端共用同一函数5.2 结果语义敏感过滤生成串里的“意外”这又是一个容易被忽略、但影响很大的问题一个技术上完全合法的字元组合可能在语义上非常尴尬。最常见的是 4 位随机短码随机生成后拼起来是某个不雅词汇。这在优惠码、邀请码场景里尤其尴尬因为用户会把码发到社交媒体上如果你的码恰好是不雅内容那画面简直不敢看。处理方式不复杂但一定要做在生成端不要等用户反馈。维护一份黑名单词组表生成后做一次包含匹配如果命中就重新生成。黑名单的规模不用太大覆盖常见的英文粗俗词、中文拼音联想词、以及一些与品牌相冲突的词即可。更精细的做法是把黑名单检查融入到生成逻辑里如果编码算法的字元空间是可遍历的可以在生成前就判断“这个编号对应的组合是否命中黑名单”命中则给编号加偏移量再走编码流程保证输出一定是干净且唯一的。这样做的好处是不会引入随机重试的开销。我还见过有人用机器学习做语义感知过滤说实话在大多数业务场景里没必要。一条包含 10 个词的黑名单加正则就能拦住 99% 的问题。先跑简单的遇到真实案例再逐步补充不要上来就堆复杂度。5.3 性能、索引与超大规模下的规模感最后一个话题聊性能。字元组合系统在高并发场景下最容易出问题的不是生成本身而是唯一性保障和查询链路。先说唯一性保障。如果用“随机码 数据库存储”方案码字段一定要建唯一索引并且在应用层做冲突重试。重试的退避策略也很重要不要无脑循环建议指数退避并在重试 5 次后记录日志告警因为连续多次冲突往往意味着空间快用完了而不是概率问题。如果用的是可逆编码方案没有存储层那性能瓶颈就转移到了计算上。进制转换本身的 CPU 开销很低百万次编码也就是毫秒级的事真正的瓶颈往往是日志打印、JSON 序列化这些 IO 操作。所以生成邀请码时尽量用批量接口而不是单条接口一次请求处理一批能大幅提升吞吐。再说查询链路。如果系统支持用邀请码反查业务数据邀请码字段必然要作为索引。这里有个细节字符集如果包含大小写字母建议全部转成小写或大写后再落库和建索引否则 MySQL 默认的排序规则在大小写不敏感时会走索引在大小写敏感时可能会失效排查起来非常隐晦。还有一个容易被忽视的点历史兼容。系统升级字元集或编码算法后老码必须仍然可校验。我建议在码内预留一个“版本位”比如第一位用固定字符表示算法版本。这样以后切换算法时老用户手里的码依然能用新用户走新算法二者互不影响。没有版本位的话每次升级都面临一次全量迁移非常痛苦。我自己的习惯是第一版做字元组合方案时先花半天把字符集、归一化函数、校验算法这三个地基打牢后面所有的业务逻辑都在这上面叠。这个决定看起来平平无奇但已经帮我避开过好几次线上事故——有一次新码上线后老运营数据里的码因为字元集变了全部失效当时全靠版本位设计扛了过去。也建议你在动手写生成器之前先把“归一化纯函数”和“字符集常量”写成独立模块并配好测试你会发现后续所有环节的稳定性都建立在这两个小小的地基上。