
做活动发券、软件授权、课程兑换、会员权益发放绕不开一个很具体的活儿怎么批量生成一批既能让用户在手机上顺利手输、又不容易被人瞎猜出来的激活码、兑换码。我前后在三个项目里写过这套东西从最早UUID 去横线直接扔进数据库的土办法一路改到现在这套带校验位、能本地判废、能扛住撞库的工具类中间踩的坑足够写一篇长文了。这篇就把这个激活码、兑换码生成工具类支持校验的完整设计讲透码长怎么定、位段怎么切、校验位到底用 CRC32 还是 HMAC、归一化为什么要比校验本身还重要、落库之后并发兑换怎么写、以及那些文档里绝对不会告诉你的实操细节。不管你是第一次做发码功能还是已经有一版跑在生产上但总觉得哪里不踏实下面的内容都能直接拿去改代码。需要提前说明的是这套东西的定位是你给自己发放权益的码做生成和核销不是让别人拿去对付别人的系统所以本文只讲正向的生成、校验、核销链路。1. 兑换码工具类的整体设计先把谁来校验想清楚很多人一上来就问码怎么生成其实第一个该回答的问题是校验发生在哪一层。这个问题的答案决定了码里要不要塞校验位、塞几位、用不用密钥。如果连这个都没定后面所有参数都是拍脑袋改起来就是推倒重来。我在第二个项目里就吃过这个亏一开始按纯随机字符串 数据库唯一索引的方案做了结果活动页要在用户输入时实时提示格式错误每次输入都要打一次接口白白多了一堆无效请求。后来才把校验位加进去前端本地就能滤掉九成以上的乱输。1.1 兑换码、激活码、优惠码本质是三种东西虽然大家平时混着叫但它们的约束条件差得很远混着设计会很难受。优惠码通常是活动期间大批量发放、单价低、可以被同一个人多次领取不同码最怕的是超发和被薅所以重点在限购规则和风控码本身的防伪要求反而不高。激活码一般是和某个软件授权、设备绑定、有效期挂钩单价高、一码一授权最怕的是被批量推导出来所以防伪和不可枚举是第一优先级。兑换码多用于积分、会员、实物卡券介于两者之间通常是先发码后核销核销链路要能对账。这三种的共同点是码要被人念、被人抄、被人用键盘敲。只要涉及手输就一定会出现 O 和 0 分不清、I 和 l 分不清、复制粘贴带空格、短信里自动折行插了个换行符这些问题。所以工具类的第一优先级其实不是防伪而是容错——先让真实用户能顺利输对再谈防不防得住坏人。顺序反了你会收到一堆码是你们发的但系统说无效的客诉。1.2 校验放在哪一层本地自校验和落库裁决要分清楚一套完整的校验链其实是四层叠加的从便宜到贵格式归一化把用户输入洗干净去掉分隔符、空格、大小写差异、易混字符别名。纯字符串操作零成本。校验位比对用码自带的几个字符算一遍对不上就是输入错了。CPU 上跑微秒级不需要数据库。数据库状态裁决这条码到底存不存在、有没有被用掉、有没有过期。必须查库这才是权威结论。业务规则判定这个用户能不能用、这个渠道能不能用、有没有超过限购数量。前两层可以在前端做也可以在网关层做目的是把无效请求挡在数据库前面。后两层只能在服务端做因为前端的一切校验都是给用户看的体验优化改个 JavaScript 变量就能绕过去把它当安全边界迟早出事。我见过的真实事故就是前端做了校验通过才允许点击兑换服务端接口却直接信任了前端传来的结果结果有人构造请求把一批码一次性兑完了。所以我给工具类的定位很明确它只负责第 1 层和第 2 层把这个字符串是不是一条格式合法、校验位正确的码这件事做到又准又快并且能解析出版本号和业务类型剩下的交给业务层的数据库操作。职责单一好处是这个类可以被前端用 JS 重写一份逻辑一致的实现、网关、后端服务同时复用行为完全一致。1.3 为什么选 Base32 而不是 Hex 或 UUID码的字符集选择直接决定了用户输入成本。承载同样的信息量字符集越大码越短但可用的好输入字符越少。我实测过几种常见方案编码方式字符集大小承载 80 bit 需要几位输入体验备注十六进制 Hex1620 位差字符单调、易看串行UUID 去横线就是这种Base646414 位差含 / 且大小写敏感URL 里不安全Base626214 位一般大小写敏感短链常用人工输入不友好Crockford Base323216 位好全大写、无易混字符推荐方案Crockford Base32 的字符集是0123456789ABCDEFGHJKMNPQRSTVWXYZ注意它去掉了 I、L、O、U 四个字符。去掉 I 和 L 是因为和数字 1 太像去掉 O 是因为和 0 太像去掉 U 是因为在随机生成的短串里容易出现让人尴尬的字母组合。更妙的是它的解码规则里带别名映射用户输进来 I 或 L 就当成 1 处理输进来 O 就当成 0 处理。这个小设计能直接干掉一大类客诉实际收益远超预期。有人会问去掉 4 个字符后信息密度不就降了吗确实每个字符从 5 bit 变成 4.64 bit 左右。但换来的是用户一次输对的概率显著提升以及输错了系统还知道往哪个方向纠正这笔账非常划算。2. 编码结构与校验位设计80 bit 到底怎么切定好字符集之后接下来是切蛋糕。我倾向的方案是固定 16 位码总容量 80 bit切成版本、业务类型、随机载荷、校验位四段。为什么是 16 位而不是 12 位或者 20 位这里面有取舍下面拆开算。2.1 位段划分与版本位的作用先给结论再讲理由位段位置占用字符占用 bit用途第 1 位15算法版本号当前为 1第 2 位15业务类型0~31第 3~13 位1155随机载荷即真正的无序号第 14~16 位315校验位HMAC-SHA256 截断版本位是整套设计里最容易被忽略、但出事时最救命的一段。有了它你的算法可以平滑演进。比如将来想换校验算法、想改码长、想换密钥只要把版本号加 1新码用新逻辑生成老码继续用老逻辑校验两套并行跑。如果没有版本位某天你换了 HMAC 密钥线上所有已发出的码会瞬间全部校验失败——那种场面我经历过一次凌晨两点在群里解释为什么用户手里的码全废了非常难受。业务类型位用来区分这条码是干什么的1 是会员月卡2 是视频课程兑换3 是优惠券。这样一条码从格式上就能判断它不该出现在哪个兑换入口用户在错误的页面输入时能立刻给出这不是本页面的兑换码的提示而不是查完库才说码不存在。用户体验和数据库压力都受益。随机载荷 55 bit是真正需要不可预测的部分必须用密码学安全的随机源生成不能用Math.random()或者new Random()。原因很简单java.util.Random是一个 48 位的线性同余发生器只要观察到连续两个输出就能完整推算出后续所有序列。要是有人批量兑换了几条码理论上就能反推出你后面要发的码。SecureRandom没这个问题代价是慢一点但生成码是离线批量操作这点开销完全可以接受。2.2 校验算法选型CRC32、Luhn、HMAC 到底用哪个这是被问得最多的问题我直接给一张对照表算法需要密钥能防手误能防伪造计算成本我的建议Luhn模 10否是否极低适合卡号这类纯防错场景CRC32 截断否是否极低文件校验常用短码离线校验可用MD5/SHA 截断否是否低本质和 CRC 一样无密钥就能被算出HMAC-SHA256 截断是是是低服务端校验推荐纯随机 唯一索引否否是低不需要自校验全靠查库关键差异在防伪造这一列。所有不带密钥的算法——不管是 CRC32、Luhn 还是 MD5——都有一个共同弱点算法是公开的任何人知道算法之后都能自己构造出校验位完全正确的假码。它只能告诉你这个字符串是不是被人改错了一位不能告诉你这个字符串是不是我们发的。HMAC 不一样它有一个只有服务端知道的密钥。攻击者即使完全知道你的算法、码长、位段划分没有密钥也算不出正确的校验位。这就把随便编一个码试试看的成功率压到了极低。所以我最终的方案是 HMAC-SHA256 取高 15 bit 作为校验位。但这里有个现实问题要提前讲清楚前端做不了 HMAC因为密钥不能下发到前端。所以如果你的产品需要输入时即时提示格式错误前端只能做归一化和长度检查校验位必须在服务端验。这是安全性和体验之间的必然取舍别为了前端能校验就把密钥泄露出去那是把整扇门都拆了。如果确实有纯离线校验的需求比如码要印在实体卡上、由线下设备在断网状态下核销那就只能退回 CRC32 或者 Luhn。这种情况下必须接受码可以被伪造这个前提然后在核销环节加一道在线确认或者干脆把这些码当预付费卡处理用硬件密钥做二次保护。2.3 碰撞概率算给你看55 bit 够不够用55 bit 随机够不够这个问题不能凭感觉得算。随机码碰撞遵循生日问题公式近似概率是P ≈ n² / (2N)其中 n 是你总共发出的码数量N 是随机空间大小55 bit 对应 N 2^55 ≈ 3.6 × 10^16。累计发码量碰撞概率近似结论100 万约 0.0014%完全无感1000 万约 0.14%可接受3000 万约 1.25%需要关注1 亿约 13.9%必须加长码从表里能看出来55 bit 载荷大约能安全支撑到千万级发码量。超过这个量级要么把码加长到 18 位要么在载荷段先加一段批次号让不同批次在随机空间里错开降低同批次内的碰撞压力。不过说实话即使算出来有 0.14% 的碰撞概率实际生产里也不会出问题因为还有两道防线一是生成时的应用层去重用一个Set保证同一批次内不重复二是数据库的唯一索引跨批次重复会被数据库直接拒绝。这两道加起来理论概率基本不会变成真实事故。但知道这个数字的价值在于你能理直气壮地回绝产品经理能不能把码改成 8 位用户好输这种需求因为 8 位 Base32 只有 40 bit发 100 万条码的碰撞概率就接近 50%纯粹是给自己找麻烦。2.4 分隔符和容错归一化规则16 位连续的字符在手机上看着眼晕所以展示时按每 4 位加一个短横线A7K2-9MXP-3QT8-WD5F。注意这里的重点是展示格式和存储格式必须分开展示给用户、发短信、印在卡片上的是带横线的版本。数据库里存的、程序内部比对的是去掉横线的 16 位归一化版本。为什么要分两套因为用户复制粘贴的时候横线可能丢、可能变成空格、可能变成全角字符、可能被某个输入法自动转换。如果数据库里存的是带横线的版本你就得写一堆字符串匹配逻辑去兼容各种变体后患无穷。存归一化版本就简单多了查询条件永远只有一个索引效率也高。归一化要处理的完整情况清单我整理成了下面这些代码里一条都不能漏去掉所有短横线、空格、制表符、换行符、回车符。统一转大写。把 I、L 映射成 1把 O 映射成 0。遇到字符集之外的字符比如中文、标点、全角字母直接判定为非法返回空串。长度超过 16 位时截断防止有人粘贴一大段文本。注意归一化必须在计算 HMAC之前做。有人喜欢拿用户原始输入直接算 HMAC那就完了——用户输入 o 和小写 0 是同一个语义但字节完全不同算出来的校验值天差地别会出现用户明明输对了却说码无效的诡异现象。我踩过这个坑排查了一下午最后发现是归一化顺序放错了位置。3. 工具类落地生成、归一化、校验三段式实现理论讲完开始写代码。我最终的实现是一个不可变的实例类RedeemCodeCodec通过构造函数注入密钥注册成 Spring 的单例 Bean。为什么不用静态工具类因为它持有密钥静态字段会把密钥固化成编译期常量想换密钥、想多环境隔离都会很难受。实例化的做法在 Spring 里天然就是单例性能上没有区别灵活性高很多。3.1 类结构和对外接口设计先看骨架。整个类的静态常量部分定义了这套编码的全部物理规格这些数字一旦上线就不能改了所以我在代码里把它们标成public static final并在注释里写清楚含义方便前后端对齐。public class RedeemCodeCodec { /** Crockford Base32去掉 I L O U避免肉眼混淆 */ private static final char[] ALPHABET 0123456789ABCDEFGHJKMNPQRSTVWXYZ.toCharArray(); private static final int[] DECODE new int[128]; static { Arrays.fill(DECODE, -1); for (int i 0; i ALPHABET.length; i) { DECODE[ALPHABET[i]] i; } // 容错别名手输时最常出现的混淆 DECODE[I] 1; DECODE[L] 1; DECODE[O] 0; } public static final int VERSION 1; // 算法版本 public static final int LENGTH 16; // 归一化后的码长 public static final int PREFIX_LEN 13; // 版本1 类型1 载荷11 public static final int MAC_LEN 3; // 校验位 3 字符 15 bit public static final int PAYLOAD_BITS 55; private static final int GROUP 4; // 展示时每 4 位一组 }对外暴露的核心方法只有三个generate(int bizType)生成单条generateBatch(int bizType, int count, PredicateString exists)批量生成verify(String raw)校验并解析。其余都是内部私有方法。接口越窄用错的可能性越小。3.2 生成SecureRandom 取载荷HMAC 出校验位构造函数要做两件事校验密钥强度以及准备线程安全的 HMAC 实例。private final byte[] secret; private final SecureRandom random new SecureRandom(); private final ThreadLocalMac macHolder; public RedeemCodeCodec(String secretText) { byte[] s secretText null ? new byte[0] : secretText.getBytes(StandardCharsets.UTF_8); if (s.length 16) { throw new IllegalArgumentException(secret 至少 16 字节建议 32 字节随机串); } this.secret s; this.macHolder ThreadLocal.withInitial(this::newMac); } private Mac newMac() { try { Mac mac Mac.getInstance(HmacSHA256); mac.init(new SecretKeySpec(secret, HmacSHA256)); return mac; } catch (GeneralSecurityException e) { throw new IllegalStateException(HmacSHA256 初始化失败, e); } }关于ThreadLocalMac这里有个必须解释清楚的细节javax.crypto.Mac不是线程安全的。Mac对象内部维护了一个进行中的摘要状态两个线程同时调用doFinal会互相污染中间状态算出来的结果不可预测。而且这个 bug 极其隐蔽——它在低并发时完全不出现压力一上来就随机报错排查起来非常折磨。用ThreadLocal的好处是每个线程复用自己的实例既避免了竞争又不用每次调用都重新初始化HmacSHA256的初始化要处理密钥扩展比doFinal本身贵不少。用完记得reset()否则残留状态会影响下一次计算。生成方法本体public String generate(int bizType) { if (bizType 0 || bizType 31) { throw new IllegalArgumentException(bizType 取值范围 0~31); } char[] buf new char[LENGTH]; buf[0] ALPHABET[VERSION]; buf[1] ALPHABET[bizType]; // 55 bit 安全随机载荷 byte[] rnd new byte[8]; random.nextBytes(rnd); long payload ByteBuffer.wrap(rnd).getLong() ((1L PAYLOAD_BITS) - 1); // 11 个字符每个承载 5 bit高位在前 for (int i 0; i 11; i) { int shift 50 - i * 5; buf[2 i] ALPHABET[(int) ((payload shift) 0x1F)]; } // 校验位写在末尾 char[] macPart macOf(buf, PREFIX_LEN); System.arraycopy(macPart, 0, buf, PREFIX_LEN, MAC_LEN); return format(buf); }有几个写法上的选择值得说明。随机载荷用 8 字节生成再掩码到 55 bit而不是直接random.nextLong(1L 55)是因为后者在部分 JDK 实现里会用拒绝采样性能略差前者一次性取满 64 bit 再截断浪费一点随机性但速度更稳。shift 50 - i * 5这个表达式对应的是第 i 个字符承载从高位数第 (50 - 5i) 位开始的 5 bit写的时候一定要在纸上画一遍位图否则很容易写成小端序到时候和前端对不上。计算的辅助方法private char[] macOf(char[] prefix, int len) { Mac mac macHolder.get(); mac.reset(); byte[] digest mac.doFinal( new String(prefix, 0, len).getBytes(StandardCharsets.US_ASCII)); // 取 digest 的高 15 bit int v (((digest[0] 0xFF) 8) | (digest[1] 0xFF)) 1; char[] out new char[MAC_LEN]; for (int i 0; i MAC_LEN; i) { out[i] ALPHABET[(v (10 - i * 5)) 0x1F]; } return out; }这里用 ASCII 字节做 HMAC 输入而不是把 5 bit 打包成紧凑字节流。两种做法安全性等价但 ASCII 版本更好调试——出问题时你直接把字符串打到日志里重算一遍就能定位不用写一段位打包的反解工具。工程上可调试这个属性比省几个字节重要得多。3.3 归一化把用户输入洗干净是校验的第一步归一化方法的代码不长但它是整个工具类里被调用最频繁、最影响用户感受的一段。public static String normalize(String raw) { if (raw null || raw.isEmpty()) { return ; } StringBuilder sb new StringBuilder(LENGTH); for (int i 0; i raw.length() sb.length() LENGTH; i) { char c Character.toUpperCase(raw.charAt(i)); if (c - || c || c \t || c \r || c \n) { continue; } int v (c 128) ? DECODE[c] : -1; if (v 0) { return ; // 出现非法字符直接判废 } sb.append(ALPHABET[v]); // 别名统一回规范字符 } return sb.toString(); }几个细节。第一Character.toUpperCase放在最前面因为用户用手机输入法有可能会把首字母自动大写、后面保持小写不统一大小写就会出现同一个码两种表示。第二非法字符直接返回空串而不是跳过这是个刻意的选择——如果用户输入里混了中文或者标点与其猜他想输什么不如直接告诉他格式不对。跳过非法字符会导致一种很隐蔽的问题用户从一段带格式的文本里复制粘贴前面有兑换码四个字跳过之后后面碰巧凑够 16 位合法字符校验位也对反而兑换成功了但他自己都不知道发生了什么。第三sb.length() LENGTH这个循环条件保证不会因为粘贴超长文本而做无谓的遍历。3.4 校验三段式判定和失败原因枚举校验方法的设计原则是把失败原因分类返回而不是简单地返回 true/false。因为不同的失败原因对应完全不同的用户提示混在一起你就没法给用户有用的反馈。public CodeInfo verify(String raw) { String code normalize(raw); if (code.length() ! LENGTH) { return CodeInfo.invalid(FailReason.LENGTH, raw); } if (DECODE[code.charAt(0)] ! VERSION) { return CodeInfo.invalid(FailReason.VERSION, raw); } char[] expect macOf(code.toCharArray(), PREFIX_LEN); char[] actual code.substring(PREFIX_LEN).toCharArray(); if (!MessageDigest.isEqual( new String(expect).getBytes(StandardCharsets.US_ASCII), new String(actual).getBytes(StandardCharsets.US_ASCII))) { return CodeInfo.invalid(FailReason.CHECKSUM, raw); } return CodeInfo.ok(raw, code, DECODE[code.charAt(1)]); }关于比较方式这里用的是MessageDigest.isEqual而不是String.equals。原因是String.equals会在发现第一个不同字符时立刻返回执行时间随匹配前缀长度变化理论上存在通过计时侧信道逐步猜出正确校验值的可能。虽然在实际网络环境下这种攻击很难稳定实施但MessageDigest.isEqual的写法是常量时间的成本几乎为零没有理由不用。失败原因的枚举我定义成四类对应四类不同的用户提示这套映射关系在客服培训的时候特别有用失败原因含义给用户的提示LENGTH位数不对兑换码为 16 位请检查是否漏输VERSION版本不匹配该码不适用于当前活动CHECKSUM校验位不通过兑换码有误请核对后重新输入数据库层不存在格式正确但库里没有兑换码不存在或已失效提示生产环境千万别把校验位不通过和码不存在合并成同一句提示。前者是用户输错了后者可能是码被人用了。区分开来能大幅降低客服排查成本也能帮你从日志里快速判断到底是用户问题还是系统问题。3.5 批量生成和冲突重试批量生成要解决两个层面的重复批次内重复和跨批次重复。public ListString generateBatch(int bizType, int count, PredicateString exists) { SetString batch new LinkedHashSet(count * 2); int miss 0; while (batch.size() count) { String code generate(bizType); if (exists ! null exists.test(normalize(code))) { if (miss count * 3 100) { throw new IllegalStateException( 连续冲突次数过多请检查密钥或加大码长); } continue; } batch.add(code); } return new ArrayList(batch); }批次内重复用LinkedHashSet天然解决重复的自动被吃掉而且LinkedHashSet保持插入顺序生成的列表在入库时顺序稳定方便按批次对账。跨批次重复交给外部传入的exists判断函数这个函数通常查一次数据库或者查一次布隆过滤器。注意重试次数的上限阈值我设的是count * 3 100这个数字是根据 2.3 节算出来的碰撞概率倒推的——正常情况下几乎不会触发重试一旦触发说明要么密钥配错了要么码长该加了这个时候抛异常比默默重试更正确因为它在告诉你系统有问题。另外有一点必须强调无论应用层怎么去重数据库的UNIQUE索引都不能省。应用层去重是优化数据库唯一索引是保证。批量插入时捕获DuplicateKeyException把冲突的那几条剔出来重新生成再插一次这个最后一道防线的存在感很低但没有它的时候是真会出事的。4. 从工具类到可用系统落库、并发和兑换链路工具类写好只是完成了三分之一。真正把码发出去、让用户能兑、不出超发和重复兑换靠的是后面这套数据库和业务逻辑。这一节的内容很多是从线上事故里总结出来的。4.1 表结构设计唯一索引是最后一道防线我用的表结构大致是这样CREATE TABLE redeem_code ( code_norm CHAR(16) NOT NULL COMMENT 归一化后的码主键, code_show CHAR(19) NOT NULL COMMENT 带横线的展示码, batch_no VARCHAR(32) NOT NULL COMMENT 生成批次号, biz_type TINYINT NOT NULL COMMENT 业务类型 0~31, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未用 1已用 2冻结, grant_id BIGINT NULL COMMENT 权益发放ID幂等用, redeem_user BIGINT NULL COMMENT 核销用户ID, redeem_time DATETIME NULL COMMENT 核销时间, expire_time DATETIME NULL COMMENT 过期时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (code_norm), KEY idx_batch (batch_no), KEY idx_user (redeem_user, biz_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;设计上有几个刻意的取舍。主键用code_norm而不是自增 ID。因为查询路径永远是按码查状态用码做主键就是聚簇索引的直接命中比自增主键 二级索引回表少一次 IO。代价是插入时页分裂会多一些但码是随机分布的本来也没法顺序插入所以这个代价本来就跑不掉。同时存code_show。有人觉得这是冗余其实不是。运营对账、客服查单、导出印刷文件的时候都需要展示码如果每次都靠程序重新插横线一是会分散逻辑二是导出脚本可能是别的语言写的规格容易走样。存一份展示码跨语言一致性问题就消失了。idx_user这个索引是为幂等查询准备的。后面 4.3 节会讲用户连点两次兑换按钮的场景非常常见需要一个快速的查询路径去判断这条码是不是你刚兑的。grant_id这个字段是发放权益的幂等键。核销码和发放权益是两件事可能跨越两个服务甚至两个数据库。用grant_id把两者串起来即使中间环节重试也不会重复发权益。4.2 并发兑换一条 UPDATE 解决超发问题并发兑换是这类系统里唯一真正有技术难度的点。常见的错误写法有两种先SELECT查状态判断status 0再UPDATE置为 1。这个写法在并发下必然出问题因为查和改之间有窗口期两个线程都能查到status 0然后都去更新结果发出两份权益。正确的写法是把判断条件下推到UPDATE的WHERE里让数据库用行锁保证原子性UPDATE redeem_code SET status 1, redeem_user #{userId}, redeem_time NOW(), grant_id #{grantId} WHERE code_norm #{code} AND status 0 AND (expire_time IS NULL OR expire_time NOW());然后看affectedRows等于 1抢到了继续走发放权益的流程。等于 0说明码不存在、已被使用或者已过期。具体是哪种需要再查一次状态来区分因为要给用户不同的提示。这个模式的好处是绝对可靠——InnoDB 在执行这条UPDATE时会对匹配的行加排他锁两个并发事务里只有一个能拿到锁并把status从 0 改成 1另一个会等待等第一个提交后重新读取发现status已经不是 0 了affectedRows就是 0。整个过程不需要你手动加锁也不会死锁因为只锁一行加锁顺序是确定的。发放权益的时机也有讲究。有两种思路方案做法优点缺点先扣码后发货本地事务扣码事务提交后异步发权益不会超发需要补偿任务处理发货失败先发货后扣码先调权益服务成功后再扣码不会漏发并发下必须先占位否则会超发我推荐第一种并且用本地消息表来保证最终一致性扣码成功后在同一个本地事务里往outbox表插一条待发送记录然后由定时任务或者消息队列去消费这条记录、调用权益服务。权益服务成功返回后把outbox记录标记为完成。发货失败的记录会一直重试直到成功或者进入人工处理队列。这套方案的可靠性是经过验证的不会出现码扣了但权益没到的静默失败。4.3 幂等用户连点两次不该告诉他已被使用这是我见过最多、也最影响体验的问题。用户点了兑换按钮网络卡了一下没反应他就又点了一次。第一次请求成功扣了码发了权益第二次请求发现affectedRows 0如果直接返回兑换码已被使用用户就懵了——明明是我自己刚兑的怎么就被别人用了解决方案是在affectedRows 0之后补一次归属判断if (affectedRows 0) { RedeemCodeRow row mapper.selectByCode(code); if (row null) { return Result.fail(兑换码不存在); } if (Objects.equals(row.getRedeemUser(), userId) Objects.equals(row.getGrantId(), grantId)) { // 就是你自己刚才那次成功的请求幂等返回成功 return Result.ok(兑换成功); } if (row.getStatus() 1) { return Result.fail(兑换码已被使用); } return Result.fail(兑换码已过期或不可用); }关键点是把grantId一起比。如果只比用户 ID会出现一种边界情况同一个用户持有两条不同的码兑了第一条之后又去兑第二条第二条的查询逻辑会误判成你已经兑过了从而直接返回成功但权益根本没发。加上grantId由前端在页面打开时生成一个 UUID 随请求一起传就能区分同一次请求的重试和新的一次兑换。注意grantId一定要由前端在页面加载时生成并保持不变不能每次点击都重新生成否则幂等判断就失效了。这个细节我在代码评审里纠正过好几次。4.4 限流和防撞库为什么它不是可选项回到 2.2 节算的那个数字15 bit 校验位意味着随机猜一个 16 位字符串大约每 32768 次就有一次能通过校验位检查。这个概率听起来很低但攻击者用脚本以每秒几百次的速度刷你的接口一个小时就能撞出上百个格式合法的码。虽然这些码大概率在数据库里不存在但每一次都要打一次数据库查询压力全落到你最宝贵的资源上。所以限流不是锦上添花是这个设计的必要组成部分。我一般做三层第一层布隆过滤器前置拦截。把所有已生成的码灌进一个布隆过滤器请求进来先查一次。布隆过滤器的特性是说不存在就一定不存在所以一旦它说不存在可以直接拒绝完全不碰数据库。这能挡掉 99.99% 以上的撞库请求。误判只发生在说存在但实际不存在的方向上这部分漏给数据库处理量已经很小了。记得每次批量生成后要把新码同步进过滤器。第二层按维度的失败计数限流。同一个用户 ID、同一个 IP、同一个设备指纹在滑动窗口内失败次数超过阈值就临时阻断。我的经验值是 5 分钟内失败 10 次就要求输入图形验证码失败 20 次直接封 1 小时。阈值不能设得太严因为真实用户确实会输错好几次一上来就封会误伤。第三层核销接口的全局 QPS 上限。用令牌桶给整个接口设一个上限防止某个攻击者用分布式 IP 绕过单 IP 限流。这一层是兜底正常流量远达不到这个阈值。还有个小技巧成功和失败的日志要分开统计。失败率突然升高往往意味着有人在撞库这时候看一眼失败请求的来源分布就能判断。成功的核销日志则用于对账两条通道的保留策略和采样率都可以不一样。5. 踩坑实录和问题速查表前面讲的都是应该怎么做这一节讲我实际做错过的。5.1 我踩过的五个坑坑一密钥换了老码全废。有一次做安全加固运维顺手把配置里的密钥轮换了结果线上几万条已发出的激活码在一瞬间全部校验失败。当时还没有版本位机制只能连夜回滚配置。教训是密钥必须有版本管理并且新老密钥要并行保留。后来我加了版本位并且在配置里支持一个密钥列表验证时依次尝试这样轮换就能平滑过渡。坑二日志里打了完整码。早期为了排查问题我在校验失败时把用户输入的完整码打进了日志。后来安全审计的时候被指出来——日志系统的访问权限比数据库宽松得多把有效码写进日志等于扩大了泄露面。现在的做法是只打前 4 位加掩码比如A7K2****排查问题时靠批次号和请求 ID 定位就够了。坑三以为SecureRandom每次 new 很便宜。批量生成 10 万条码的时候我一开始是在循环里new SecureRandom()结果跑了三分多钟。后来改成类级别的单例同样的量降到十几秒。原因是SecureRandom的初始化要收集熵池开销不小绝对不能放在循环里。顺便一提Linux 上如果熵池不足SecureRandom还可能阻塞所以服务启动时预热一次也是个好习惯。坑四批量插入没开批处理。用 MyBatis 批量插入时忘了在连接串上加rewriteBatchedStatementstrue10 万条码插了三分钟。加上之后降到几秒。这个参数的作用是把多条INSERT合并成一条多值INSERT减少网络往返是 MySQL 批量写入的必开项。坑五归一化做在了校验之后。就是前面 2.4 节提到的那个问题用户输入的小写字母没被统一HMAC 算出来不匹配。这个 bug 在测试环境很难复现因为测试同学都习惯直接复制粘贴。上线后收到我手动输的码一直提示错误的反馈才定位到。现在的做法是把normalize作为verify的第一行代码并且单独写了一个覆盖各种大小写、别名、分隔符组合的单元测试。5.2 问题速查表我把线上遇到过的现象和对应的原因、处理方式整理成一张表出问题时可以直接查现象大概率原因处理方式用户说码是对的但提示无效输入了 I/L/O 别名或结尾多了空格检查归一化是否覆盖大小写、别名、空白字符校验通过但提示已被使用前端重复提交补 grantId 幂等判断校验通过但数据库查不到生成脚本跑失败、批次未入库加批次维度的生成入库一致性校验数据库唯一键冲突随机载荷碰撞或生成脚本重复执行捕获 DuplicateKeyException 重试给 batch_no 加唯一约束换密钥后老码全部失效校验位依赖密钥且没有版本隔离引入版本位密钥按版本并行保留接口被打挂、QPS 异常撞库攻击布隆过滤器 多维度限流 失败率告警批量生成耗时过长循环内 new SecureRandom或未开批处理单例 SecureRandom rewriteBatchedStatementstrue兑换后权益没到账扣码和发货不在一个事务里且没补偿本地消息表 定时重试 人工兜底队列一码被多人兑换成功先查后改存在并发窗口改成条件 UPDATE判断 affectedRows5.3 几个能省事的经验码里不要带任何明文业务信息。我见过有人把用户 ID、批次号、时间戳编进码的前几位说是方便排查。这等于把信息暴露给了所有拿到码的人有心人拿着几条码就能推导出发放节奏和用户规模。业务信息应该全部放在数据库里用码去关联不要编进码本身。给码加过期时间而且要默认加。没有过期时间的码是一种长期负债活动结束三年后还有人拿着码来兑你既不能说不认又不好处理。我现在的默认策略是活动结束时间后延 30 天配置化运营可以调整但要显式填写。生成和入库一定要做成一个可重入的批次任务。给每个批次分配一个batch_no任务开始时先检查这个批次号是否已经生成过如果生成了就直接返回已有的结果。这样即使任务中途失败重跑也不会产生重复的码。这个设计在一次生成 50 万条码、跑到 30 万条时服务被重启的场景里救了命。前端展示码的时候用等宽字体。这个属于细节但体验提升明显。等宽字体加上适当加大字间距能让 1 和 l、0 和 O 的区分度显著提升手输场景下的错误率能降不少。如果产品允许还可以在输入框下面实时回显归一化后的结果用户能自己发现输错了。给码做单元测试的时候一定要覆盖往返属性。也就是verify(generate(x))必须成功以及verify(tamper(generate(x)))必须失败。后者尤其重要我写过一个简单的篡改用例把码里任意一个字符替换成字符集中的另一个字符逐个位置测一遍确保所有 15 个位置的篡改都能被校验位捕获。这个测试跑起来很轻量但能挡住后面所有因为位段算错导致的低级问题。别把码的生成逻辑写在前端。有些人图省事用 JavaScript 在前端算 HMAC那必然要把密钥下发到浏览器。有密钥等于没有密钥任何人都能从页面源码里把密钥抠出来然后批量造码。生成逻辑必须在服务端前端拿到的只有已经生成好的码列表。最后分享一个我自己用着很顺手的小扩展在这套RedeemCodeCodec基础上加一个preview(String raw)方法返回归一化结果、长度、版本、业务类型、校验结果这几个字段但不查库。这个方法在排查线上问题时特别有用——客服把用户输入的原始串发过来我用preview跑一遍就能立刻判断到底是输入格式的问题还是数据库状态的问题不用来回猜。代码量不到二十行但每次排查能省十几分钟。