自己设计了一个兑换码算法:自增ID + Base32转码 + 按位加权签名 + 异或混淆,面试被追问细节时终于不用慌了

之前写的博客都是围绕 Redis 数据结构做文章(Set 做点赞、BitMap 做签到、ZSet 做排行榜),这周终于换了一个方向------优惠券系统。最让我上头的是兑换码生成算法。以前觉得兑换码不就是个随机字符串嘛,直到真正面对需求才发现:要唯一、要可读、要防刷、要高效、还要能用 BitMap 验证兑换状态。五个要求凑在一起,现成的算法全都不够用,只能自己设计。整个过程像拼拼图,每一块单独看都不难,拼在一起的那一刻是真的有成就感。

一、兑换码的五个需求,现成算法全都不行

先说说兑换码要满足什么条件:

需求 具体要求
可读性好 不超过 10 个字符,只能大写字母 + 数字(去掉易混淆的 I、O、0、1)
数据量大 至少支持 10 亿以上的兑换码
唯一性 所有兑换码绝不重复
防重兑 兑换过的码不能再兑,验证要高效
防暴刷 规律性不能太强,不能被人猜出其他兑换码

拿到这个需求,第一反应是 UUID。UUID 唯一性没问题,但它是 128 位的,转成 Base32 要 26 个字符,远超 10 位限制。

第二反应是雪花算法(Snowflake)。64 位,转 Base32 要 13 个字符,还是超了。

第三反应是自增 ID。32 位的 Integer 能支持 40 亿+,转 Base32 只要 7 个字符,完全够用。而且自增 ID 天然有序,配合 Redis BitMap 做防重兑校验,一个 bit 位代表一个 ID 的兑换状态,O(1) 就能判断。

自增 ID 解决了唯一性、数据量、可读性和防重兑,但有一个致命问题:太有规律了。ID 是 1、2、3 递增的,别人拿到一个兑换码,稍微一推算就能猜出其他兑换码,甚至伪造。

所以核心问题变成了:怎么给自增 ID 加一层"伪装",让它看起来毫无规律,但又能反向解析回来?

二、借鉴 JWT 的思路:载荷 + 签名

这个问题让我想到了 JWT。JWT 的 Header (头部)和 Payload(载荷) 本质上就是 Base64 明文,谁都能看懂。它之所以不怕伪造,是因为有第三部分------签名。签名是用密钥对前两部分做哈希运算得到的,密钥不泄露就没人能伪造。

兑换码也可以用类似的思路:
#mermaid-svg-oKD9ikK7pJv4MgCu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oKD9ikK7pJv4MgCu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oKD9ikK7pJv4MgCu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oKD9ikK7pJv4MgCu .error-icon{fill:#552222;}#mermaid-svg-oKD9ikK7pJv4MgCu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oKD9ikK7pJv4MgCu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oKD9ikK7pJv4MgCu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oKD9ikK7pJv4MgCu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oKD9ikK7pJv4MgCu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oKD9ikK7pJv4MgCu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oKD9ikK7pJv4MgCu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oKD9ikK7pJv4MgCu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oKD9ikK7pJv4MgCu .marker.cross{stroke:#333333;}#mermaid-svg-oKD9ikK7pJv4MgCu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oKD9ikK7pJv4MgCu p{margin:0;}#mermaid-svg-oKD9ikK7pJv4MgCu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oKD9ikK7pJv4MgCu .cluster-label text{fill:#333;}#mermaid-svg-oKD9ikK7pJv4MgCu .cluster-label span{color:#333;}#mermaid-svg-oKD9ikK7pJv4MgCu .cluster-label span p{background-color:transparent;}#mermaid-svg-oKD9ikK7pJv4MgCu .label text,#mermaid-svg-oKD9ikK7pJv4MgCu span{fill:#333;color:#333;}#mermaid-svg-oKD9ikK7pJv4MgCu .node rect,#mermaid-svg-oKD9ikK7pJv4MgCu .node circle,#mermaid-svg-oKD9ikK7pJv4MgCu .node ellipse,#mermaid-svg-oKD9ikK7pJv4MgCu .node polygon,#mermaid-svg-oKD9ikK7pJv4MgCu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oKD9ikK7pJv4MgCu .rough-node .label text,#mermaid-svg-oKD9ikK7pJv4MgCu .node .label text,#mermaid-svg-oKD9ikK7pJv4MgCu .image-shape .label,#mermaid-svg-oKD9ikK7pJv4MgCu .icon-shape .label{text-anchor:middle;}#mermaid-svg-oKD9ikK7pJv4MgCu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oKD9ikK7pJv4MgCu .rough-node .label,#mermaid-svg-oKD9ikK7pJv4MgCu .node .label,#mermaid-svg-oKD9ikK7pJv4MgCu .image-shape .label,#mermaid-svg-oKD9ikK7pJv4MgCu .icon-shape .label{text-align:center;}#mermaid-svg-oKD9ikK7pJv4MgCu .node.clickable{cursor:pointer;}#mermaid-svg-oKD9ikK7pJv4MgCu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oKD9ikK7pJv4MgCu .arrowheadPath{fill:#333333;}#mermaid-svg-oKD9ikK7pJv4MgCu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oKD9ikK7pJv4MgCu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oKD9ikK7pJv4MgCu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oKD9ikK7pJv4MgCu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oKD9ikK7pJv4MgCu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oKD9ikK7pJv4MgCu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oKD9ikK7pJv4MgCu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oKD9ikK7pJv4MgCu .cluster text{fill:#333;}#mermaid-svg-oKD9ikK7pJv4MgCu .cluster span{color:#333;}#mermaid-svg-oKD9ikK7pJv4MgCu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oKD9ikK7pJv4MgCu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oKD9ikK7pJv4MgCu rect.text{fill:none;stroke-width:0;}#mermaid-svg-oKD9ikK7pJv4MgCu .icon-shape,#mermaid-svg-oKD9ikK7pJv4MgCu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oKD9ikK7pJv4MgCu .icon-shape p,#mermaid-svg-oKD9ikK7pJv4MgCu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oKD9ikK7pJv4MgCu .icon-shape .label rect,#mermaid-svg-oKD9ikK7pJv4MgCu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oKD9ikK7pJv4MgCu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oKD9ikK7pJv4MgCu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oKD9ikK7pJv4MgCu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 兑换码的组成(50 bit)
每4位一组 × 加权数组
选择第几组加权数组
取低14位
🔐 校验码

14 bit
🎲 新鲜值

4 bit
🔢 自增序列号

32 bit
📊 按位加权求和
🔀 与大质数异或混淆
📝 Base32 转码
✅ 最终兑换码

10个字符

这张图说的是兑换码的生成流程:自增序列号(32 bit)前面拼一个新鲜值(4 bit),组成 36 bit 的载荷。用新鲜值从 16 组加权数组中选一组,对载荷按位加权求和,取低 14 位作为校验码。最后把校验码、新鲜值、序列号拼成 50 bit 的数,与大质数异或混淆,再用 Base32 转码得到 10 位兑换码。

拆开来看每一步在干什么:

1. 自增序列号:用 Redis INCR 生成

java 复制代码
// Redis 自增序列号,一次性增加 totalNum 个
Long result = serialOps.increment(totalNum);
int maxSerialNum = result.intValue();
// 然后从 maxSerialNum - totalNum + 1 到 maxSerialNum,逐个生成兑换码
for (int serialNum = maxSerialNum - totalNum + 1; serialNum <= maxSerialNum; serialNum++) {
    String code = CodeUtil.generateCode(serialNum, coupon.getId());
    // ...
}

用 Redis 的 INCRBY 生成全局自增序列号,好处是即使多实例部署也不会重复。

2. 新鲜值:让同一张券的兑换码有共同标记

新鲜值取优惠券 ID 的低 4 位(0~15),作用是:同一张优惠券的所有兑换码共享一个新鲜值,在验证时可以快速判断这个兑换码属于哪张券。同时,新鲜值决定了用哪一组加权数组,增加了破解难度。

3. 按位加权签名:核心加密逻辑

java 复制代码
private static long calcCheckCode(long payload, int fresh) {
    int[] table = PRIME_TABLE[fresh]; // 根据新鲜值选一组加权数组
    long sum = 0;
    int index = 0;
    while (payload > 0) {
        sum += (payload & 0xf) * table[index++]; // 每4位一组,乘加权数
        payload >>>= 4;                           // 右移4位,取下一组
    }
    return sum & CHECK_CODE_MASK; // 取低14位作为签名
}

把载荷每 4 位切一刀,转成十进制就是 0~15 的数字。每个数字乘以对应的权重,最后求和。这个加权数组就是"密钥"------不知道密钥的人,就算拿到了兑换码也推算不出其他兑换码。

准备 16 组不同的加权数组,新鲜值决定用哪一组。这样即使有人发现了一组规律,换一张券又是不一样的规则。

4. 异或混淆:最后一道防线

java 复制代码
// payload 与大质数做异或运算
payload ^= XOR_TABLE[(int) (checkCode & 0b11111)];

加权签名之后,再把载荷跟一个大质数做异或。异或运算是可逆的(A ^ B ^ B = A),所以验证时可以还原。但不知道大质数的值,看到的结果就是一堆毫无规律的数。

5. Base32 转码:把数字变成字母

复制代码
50 bit 的二进制数 → 每 5 位一组 → 10 组 → 每组对应一个 0~31 的角标 → 查表得到字符

字符表是 ABCDEFGHJKLMNPQRSTUVWXYZ23456789(去掉了 I、O 避免和 1、0 混淆),32 个字符刚好对应 5 位二进制。50 bit 切成 10 组,每组 5 bit,查 10 次表就得到了 10 位兑换码。

三、验证兑换码:反向拆解 + 签名比对

验证是生成的逆过程。拿到一个兑换码后:

java 复制代码
public static long parseCode(String code) {
    // 1.Base32 解码,还原成数字
    long num = Base32.decode(code);
    // 2.取低 36 位得到载荷
    long payload = num & PAYLOAD_MASK;
    // 3.取高 14 位得到校验码
    int checkCode = (int) (num >>> CHECK_CODE_BIT_OFFSET);
    // 4.用校验码的低 5 位找到对应的大质数,异或还原
    payload ^= XOR_TABLE[(checkCode & 0b11111)];
    // 5.取载荷的高 4 位得到新鲜值
    int fresh = (int) (payload >>> FRESH_BIT_OFFSET & FRESH_MASK);
    // 6.重新计算签名,与原始签名比对
    if (calcCheckCode(payload, fresh) != checkCode) {
        throw new BadRequestException("无效兑换码");
    }
    // 7.验证通过,返回低 32 位的自增序列号
    return payload & SERIAL_NUM_MASK;
}

关键在第六步:用同样的算法重新算一遍签名,跟兑换码里携带的签名比对。一致说明兑换码没被篡改,不一致说明是伪造的。

验证通过后,拿到的就是自增序列号。用这个序列号去 Redis BitMap 里查一下对应的 bit 位是 0 还是 1,就知道有没有被兑换过了。整个验证过程不查数据库,全是内存运算和 Redis 位运算,性能极高。
#mermaid-svg-uBelmDw29plSzghJ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uBelmDw29plSzghJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uBelmDw29plSzghJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uBelmDw29plSzghJ .error-icon{fill:#552222;}#mermaid-svg-uBelmDw29plSzghJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uBelmDw29plSzghJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uBelmDw29plSzghJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uBelmDw29plSzghJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uBelmDw29plSzghJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uBelmDw29plSzghJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uBelmDw29plSzghJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uBelmDw29plSzghJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uBelmDw29plSzghJ .marker.cross{stroke:#333333;}#mermaid-svg-uBelmDw29plSzghJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uBelmDw29plSzghJ p{margin:0;}#mermaid-svg-uBelmDw29plSzghJ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uBelmDw29plSzghJ .cluster-label text{fill:#333;}#mermaid-svg-uBelmDw29plSzghJ .cluster-label span{color:#333;}#mermaid-svg-uBelmDw29plSzghJ .cluster-label span p{background-color:transparent;}#mermaid-svg-uBelmDw29plSzghJ .label text,#mermaid-svg-uBelmDw29plSzghJ span{fill:#333;color:#333;}#mermaid-svg-uBelmDw29plSzghJ .node rect,#mermaid-svg-uBelmDw29plSzghJ .node circle,#mermaid-svg-uBelmDw29plSzghJ .node ellipse,#mermaid-svg-uBelmDw29plSzghJ .node polygon,#mermaid-svg-uBelmDw29plSzghJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uBelmDw29plSzghJ .rough-node .label text,#mermaid-svg-uBelmDw29plSzghJ .node .label text,#mermaid-svg-uBelmDw29plSzghJ .image-shape .label,#mermaid-svg-uBelmDw29plSzghJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-uBelmDw29plSzghJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uBelmDw29plSzghJ .rough-node .label,#mermaid-svg-uBelmDw29plSzghJ .node .label,#mermaid-svg-uBelmDw29plSzghJ .image-shape .label,#mermaid-svg-uBelmDw29plSzghJ .icon-shape .label{text-align:center;}#mermaid-svg-uBelmDw29plSzghJ .node.clickable{cursor:pointer;}#mermaid-svg-uBelmDw29plSzghJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uBelmDw29plSzghJ .arrowheadPath{fill:#333333;}#mermaid-svg-uBelmDw29plSzghJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uBelmDw29plSzghJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uBelmDw29plSzghJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uBelmDw29plSzghJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uBelmDw29plSzghJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uBelmDw29plSzghJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uBelmDw29plSzghJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uBelmDw29plSzghJ .cluster text{fill:#333;}#mermaid-svg-uBelmDw29plSzghJ .cluster span{color:#333;}#mermaid-svg-uBelmDw29plSzghJ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uBelmDw29plSzghJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uBelmDw29plSzghJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-uBelmDw29plSzghJ .icon-shape,#mermaid-svg-uBelmDw29plSzghJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uBelmDw29plSzghJ .icon-shape p,#mermaid-svg-uBelmDw29plSzghJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uBelmDw29plSzghJ .icon-shape .label rect,#mermaid-svg-uBelmDw29plSzghJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uBelmDw29plSzghJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uBelmDw29plSzghJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uBelmDw29plSzghJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否



📥 收到兑换码
📝 Base32 解码
✂️ 拆分:高14位签名 + 低36位载荷
🔀 载荷异或大质数还原
📊 重新计算签名
🔍 签名一致?
❌ 无效兑换码

被篡改或伪造
🔢 取出低32位自增序列号
💾 Redis BitMap

检查兑换状态
🔍 已兑换?
❌ 重复兑换
✅ 兑换成功

SETBIT 标记为1

这张图说的是完整的验证流程:Base32 解码 → 拆分签名和载荷 → 异或还原 → 重算签名比对 → BitMap 查兑换状态。全程不碰数据库。

四、线程池异步生成:不让用户等

兑换码生成算法虽然高效,但如果一张券要发 5000 个码,在发放接口里同步生成还是会让用户等好几秒。所以项目里用线程池异步处理:

java 复制代码
@Bean
public Executor generateExchangeCodeExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(2);       // 核心线程 2
    executor.setMaxPoolSize(5);        // 最大线程 5
    executor.setQueueCapacity(200);    // 队列 200
    executor.setThreadNamePrefix("exchange-code-handler-");
    // 拒绝策略:CallerRunsPolicy(交给调用线程处理)
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.initialize();
    return executor;
}

参数选择的思路:发放优惠券不是高频操作,线程池的目的纯粹是"不让用户等",不是为了扛并发。所以核心线程只设了 2,最大线程 5。队列 200 足够缓冲。拒绝策略用 CallerRunsPolicy------万一真的满了,就让调用线程自己干,用户稍微等一下,总比丢任务强。

java 复制代码
@Async("generateExchangeCodeExecutor")
public void asyncGenerateCode(Coupon coupon) {
    Long result = serialOps.increment(coupon.getTotalNum());
    int maxSerialNum = result.intValue();
    List<ExchangeCode> list = new ArrayList<>(coupon.getTotalNum());
    for (int serialNum = maxSerialNum - coupon.getTotalNum() + 1; 
         serialNum <= maxSerialNum; serialNum++) {
        String code = CodeUtil.generateCode(serialNum, coupon.getId());
        // ... 构建兑换码对象
    }
    saveBatch(list);  // 批量保存数据库
}

发放接口调用完立刻返回,兑换码在后台线程池里慢慢生成。用户不会感受到任何延迟。

五、优惠券状态机:五个状态六种转换

优惠券的生命周期比想象中复杂。它有五个状态,状态之间的转换有严格的规则:
#mermaid-svg-ph89BZjBH68MdbYz{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ph89BZjBH68MdbYz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ph89BZjBH68MdbYz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ph89BZjBH68MdbYz .error-icon{fill:#552222;}#mermaid-svg-ph89BZjBH68MdbYz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ph89BZjBH68MdbYz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ph89BZjBH68MdbYz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ph89BZjBH68MdbYz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ph89BZjBH68MdbYz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ph89BZjBH68MdbYz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ph89BZjBH68MdbYz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ph89BZjBH68MdbYz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ph89BZjBH68MdbYz .marker.cross{stroke:#333333;}#mermaid-svg-ph89BZjBH68MdbYz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ph89BZjBH68MdbYz p{margin:0;}#mermaid-svg-ph89BZjBH68MdbYz defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-ph89BZjBH68MdbYz g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-ph89BZjBH68MdbYz g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-ph89BZjBH68MdbYz g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-ph89BZjBH68MdbYz g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-ph89BZjBH68MdbYz g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-ph89BZjBH68MdbYz .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-ph89BZjBH68MdbYz .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-ph89BZjBH68MdbYz .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-ph89BZjBH68MdbYz .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ph89BZjBH68MdbYz .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-ph89BZjBH68MdbYz .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-ph89BZjBH68MdbYz .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-ph89BZjBH68MdbYz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ph89BZjBH68MdbYz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ph89BZjBH68MdbYz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ph89BZjBH68MdbYz .edgeLabel .label text{fill:#333;}#mermaid-svg-ph89BZjBH68MdbYz .label div .edgeLabel{color:#333;}#mermaid-svg-ph89BZjBH68MdbYz .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-ph89BZjBH68MdbYz .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-ph89BZjBH68MdbYz .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-ph89BZjBH68MdbYz .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-ph89BZjBH68MdbYz .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-ph89BZjBH68MdbYz .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ph89BZjBH68MdbYz .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ph89BZjBH68MdbYz #statediagram-barbEnd{fill:#333333;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ph89BZjBH68MdbYz .cluster-label,#mermaid-svg-ph89BZjBH68MdbYz .nodeLabel{color:#131300;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-ph89BZjBH68MdbYz .note-edge{stroke-dasharray:5;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-note text{fill:black;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram-note .nodeLabel{color:black;}#mermaid-svg-ph89BZjBH68MdbYz .statediagram .edgeLabel{color:red;}#mermaid-svg-ph89BZjBH68MdbYz #dependencyStart,#mermaid-svg-ph89BZjBH68MdbYz #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-ph89BZjBH68MdbYz .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ph89BZjBH68MdbYz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 新增优惠券
立刻发放
定时发放
定时任务触发
手动暂停
重新发放
过期/手动结束
过期
待发放
进行中
未开始
暂停
已结束

这张图说的是优惠券的完整生命周期。注意几个关键约束:只有"待发放"和"暂停"状态的券可以被发放;只有"进行中"的券可以被暂停;"已结束"是终态,不可逆转。

项目里用枚举管理状态,在发放接口里做前置校验:

java 复制代码
if (coupon.getStatus() != CouponStatus.DRAFT && coupon.getStatus() != PAUSE) {
    throw new BizIllegalException("优惠券状态错误!");
}

还有一个容易忽略的细节:兑换码只在"首次发放"时生成,暂停后再发放不能重复生成。所以代码里同时判断了领取方式和前置状态:

java 复制代码
if (coupon.getObtainWay() == ObtainType.ISSUE && coupon.getStatus() == CouponStatus.DRAFT) {
    codeService.asyncGenerateCode(coupon);
}

六、面试怎么说

面试官问"兑换码怎么设计的",建议按这个节奏讲:先说需求(唯一、可读、防刷、高效、防重兑),再说为什么不用 UUID 和雪花(位数太长),然后引出"自增 ID + Base32 + 加权签名 + 异或混淆"的方案。

重点讲三个加分项:一是借鉴 JWT 的验签思想(载荷 + 签名 + 密钥),二是用 Redis BitMap 做防重兑(不查数据库,O(1) 验证),三是线程池异步生成(不让用户等)。

面试官如果追问线程池参数怎么设的,就说:发放不是高频操作,线程池的目的是减少业务耗时而不是扛并发,所以核心线程设 2、最大线程 5、队列 200。拒绝策略用 CallerRunsPolicy,宁可让调用线程慢一点也不丢任务。

七、这周的整体感受

做完兑换码算法,最大的感受是:真正有价值的设计不是"会用某个框架",而是能根据具体需求把基础知识组合起来解决问题。自增 ID、Base32 转码、按位加权、异或运算、BitMap------这些东西单独拿出来都是课本上的基础知识,但组合在一起就变成了一个能满足五个需求的兑换码方案。

另一个感触是 JWT 的验签思想比我想象中通用。以前学 JWT 只觉得是"登录用的 token",现在发现它的核心思路------"明文 + 签名 + 密钥"------可以迁移到任何需要防篡改的场景。这种"理解了原理才能举一反三"的感觉,跟之前做点赞系统时理解 Set 底层结构才能选对数据结构的感受是一样的。

下周准备做优惠券的领取和使用功能,据说涉及并发安全和优惠金额计算。继续加油。

相关推荐
shirsl2 小时前
数据开发实时项目问题整理
数据库·sql·big data
m0_587383002 小时前
外卖CPS系统开发实战:从架构设计到运营落地全指南
java·spring·小程序·架构·需求分析
zhangzeyuaaa2 小时前
Python asyncio 事件循环演进:从手动管理到现代化实践
java·服务器·python
bbq粉刷匠2 小时前
数据库设计实践
数据库
java_logo2 小时前
Docker 部署 ClickHouse Server:轻松搭建列式 OLAP 分析数据库平台
数据库·clickhouse·docker·私有化部署·olap·列式数据库·轩辕镜像
盛世宏博智慧档案2 小时前
高速 ETC 门架外场机房温湿度远程监测解决方案
服务器·数据库·php·高度·高速·温湿度
—Miss. Z—2 小时前
计算机三级数据库技术—应用题2️⃣
数据库·mysql
PrudentWoo2 小时前
PostgreSQL 12 登录失败:role “postgres“ is not permitted to log in 的排查与修复
数据库·postgresql
云析赢指标公式网42 小时前
文华WH6布林轨道均线强弱共振指标公式
前端·算法