1. 项目代号是怎么来的:477777背后的需求起点
先交代一下背景。这个项目最初根本没有名字,需求方只丢过来一句话:我们要做一批优惠券,每张券对应一个唯一码,用户输进去就能兑换权益,总量大概几十万到百万级别,要支持批量生成、导出、验证,另外码不能太长,最好用户在手机上也敲得进去。
听起来很简单,对吧?我当时也以为很简单。直到我把第一次测试数据导出来,发现日志刷屏全是477777xxxx,仔细一查,是需求方说"前缀就按你手机尾号来",我随口报了个4777,于是所有测试码都变成了477777开头的样子。后来项目在内部各处引用的时候,大家干脆直接用477777指代这套系统,"477777"就成了正式代号。
从实际经验看,这个代号的来历并不重要,重要的是它把一类非常普遍的需求具象化了:**任何需要"给用户一个不重复的、可以手工输入的唯一标识"的场景,本质都是同一套问题。**优惠码、兑换码、邀请码、活动抽奖码、内部工单号、优惠券批次号,底层逻辑几乎一模一样。
所以我写这篇内容,不只是在讲一个优惠码项目,而是在讲一套可以复用到很多地方的生成、校验、存储、防并发消费的完整方案。如果你之前只用过uuid或者随机字符串随便拼一下就上线,那这篇文章值得你看完,因为里面好几个坑,我都是实际踩过之后才补上的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 码格式设计:为什么前缀、随机段、校验位缺一不可
很多第一次做优惠码的同学,第一步就踩坑:直接random_string(16)生成一串大小写字母加数字丢给用户。结果就是用户收到码以后,分不清0和O、1和l,输入的时候反复报错,客服被打爆。
我在477777项目里一上来就把格式定了三层结构:
| 码段 | 长度 | 说明 |
|---|---|---|
| 前缀 | 4位 | 固定为P477,用于标识业务来源和批次 |
| 随机段 | 8位 | 核心随机部分,决定唯一性 |
| 校验位 | 1位 | 防止用户手输错误,兑换前先本地校验 |
最终码形如:P477-8F3KQ2M1-7,中间加连字符纯粹是为了让人眼分组读取,存数据库的时候去掉连字符统一存储。
2.1 随机段字符集的选择不能偷懒
随机段我用了32字符集:ABCDEFGHJKLMNPQRSTUVWXYZ23456789。去掉I、O、0、1这四个容易混淆的字符。很多人觉得少几个字符无所谓,但实际上字符集大小直接影响碰撞概率,而且设计不好会在后端引发重复码问题。
字符集是32,随机段长度是8,那么理论空间是32^8 ≈ 1.1万亿。即使生成100万条码,占用的空间比例也极小,碰撞概率可以忽略。但如果有人图省事,直接用string.ascii_letters + digits,62字符集空间确实大了很多,可是用户体验极差,用户根本分不清某些字符,反而增加了客服成本。码不是越复杂越好,而是越适合输入越好。
2.2 校验位的计算方式
校验位的用途是:用户手工输入时如果敲错了一个字符,系统可以在不查数据库的情况下直接判断"这个码无效",避免无效请求打到数据库。
我用的校验算法很简单:把随机段的每个字符映射到它在字符集里的位置索引,然后对所有索引值加权求和,取模32得到校验位。这个算法没有标准名叫什么,实现也就几十行:
python复制import secrets
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
ALPHABET_INDEX = {c: i for i, c in enumerate(ALPHABET)}
def _checksum(code: str) -> str:
total = 0
for i, ch in enumerate(code):
total += (ALPHABET_INDEX[ch] + 1) * (i + 1)
return ALPHABET[total % len(ALPHABET)]
def generate_code(prefix: str = "P477") -> str:
random_part = "".join(secrets.choice(ALPHABET) for _ in range(8))
checksum = _checksum(random_part)
return f"{prefix}-{random_part}-{checksum}"
注意这里用的是secrets.choice,不是random.choice。原因见下一节。
有了校验位,用户输入P477-8F3KQ2M1-8的时候,可以直接计算校验位发现不匹配,返回"输入有误请检查",而不需要去数据库跑一次查询。这个设计在高并发营销活动中非常有用,能挡住大量手误请求。
提示:校验位不是安全机制,只能检测输入错误和传输错误,不能防止恶意伪造。如果需要防伪,需要配合服务端签名或数据库校验。这个认知必须有,否则你会误以为码本身是不可伪造的。
3. 随机源选择与碰撞问题的完整排查链路
这一节是整篇文章的重头戏,也是我实际被坑得最惨的地方,值得单独拿出来详细讲。
3.1 第一版代码:我用random写了十万条码
最初原型阶段,我图省事,直接用了Python标准库的random.choice来生成码,本地跑了一下,没发现问题,觉得挺好。直到我要生成测试数据,一次性生成了20万条码,然后拿去插入数据库,数据库报了一个唯一的乌龙:居然有重复。
我第一反应是数据库索引出问题了,查了半天才发现,是代码生成的码本身就重复了。问题出在random模块用的是梅森旋转伪随机数生成器,它不是一个加密安全随机源。对于需要唯一性的业务场景,尤其是批量生成大量码时,random的周期性和可预测性会导致在特定种子下出现大量重复输出。
我当时做的排查链路是这样的:
- 复现:脚本循环生成10万条码,用
set去重,发现实际只有99962条,有38条重复。 - 缩小范围:把随机段改成4位,重复数量暴增到几百条,确认问题出在随机源。
- 定位根因:
random使用系统时间或固定种子初始化,批量循环中多个进程/线程各自初始化时可能得到相同序列。 - 验证修复:换成
secrets.choice后,同样10万条零重复。
python复制# 错误示范:用了random.choice
import random
bad_code = "P477-" + "".join(random.choice(ALPHABET) for _ in range(8))
# 正确做法:使用secrets.choice
import secrets
good_code = "P477-" + "".join(secrets.choice(ALPHABET) for _ in range(8))
3.2 多进程批量生成时的隐患
如果只是单线程脚本,换成secrets之后基本就稳了。但生产环境不可能单线程跑,我当时用了多进程并发生成,结果第二次踩坑。
多进程环境下,每个子进程都会初始化自己的随机源。如果你使用的是random模块,不同进程可能因为初始化种子相同而产生完全相同的码序列。更加隐蔽的是,如果你在多个进程里把生成的码写到一个共享队列,某个码在另一个进程里已经生成过,你并不知道,因为没有全局去重。
我在477777项目里最后用的是这个组合方案:
- 生成阶段:
secrets.choice保证单条码的不可预测性和随机性; - 去重阶段:插入前先用缓存里存的已生成码集合做一次布隆过滤器去重,误判为重复的码直接重新生成;
- 最终防线:数据库唯一索引兜底,捕获唯一键冲突后重试。
这个方案比单纯依赖随机概率靠谱得多。理论上百万级别数据量靠secrets几乎不可能碰撞,但结合缓存去重之后,数据库层面连一次冲突日志都看不到,非常干净。
3.3 数据库唯一索引为什么必须是底线
市面上有些团队设计码表时没有加唯一索引,理由是"反正随机生成不会重复"。我强烈建议不要抱这种侥幸心理。唯一索引的成本几乎可以忽略,但它是保证数据质量的最底层防线。你永远不知道未来哪次运维操作、哪个数据修复脚本会把重复数据怼进去。
建表的时候我特意设计了这样的结构:
sql复制CREATE TABLE coupon_codes (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(32) NOT NULL COMMENT '完整优惠码',
batch_id VARCHAR(32) NOT NULL COMMENT '批次号',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-未使用 1-已使用 2-已过期 3-已作废',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
used_at DATETIME NULL,
UNIQUE KEY uk_code (code),
KEY idx_batch (batch_id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
uk_code就是那个唯一的底线。没有它,后面所有并发兑换逻辑都建立在流沙上。
4. 批量入库与并发兑换:实践中的完整实现
码生成只是第一步。真正的生产环境要解决两个问题:如何快速把百万条码批量入库,以及用户并发兑换时如何保证同一个码只能被成功兑换一次。
4.1 批量入库:千万别逐条INSERT
如果你用一条条INSERT插入百万条码,耗时可能是分钟级别,而且容易把数据库连接拖垮。正确做法是分批提交,每次500到1000条,用executemany或者直接拼多值INSERT。
python复制def batch_insert(cursor, codes, batch_size=1000):
for i in range(0, len(codes), batch_size):
batch = codes[i:i + batch_size]
values = ",".join([f"('{code}', 'BATCH_2025_001')" for code in batch])
sql = f"INSERT INTO coupon_codes (code, batch_id) VALUES {values}"
cursor.execute(sql)
在MySQL里,实测100万条码用这种多值插入大约需要15到20秒,比逐条插入快了几十倍。如果量级再大,建议直接用LOAD DATA INFILE或者PostgreSQL的COPY命令,速度还能再上一个量级。
4.2 并发兑换的正确姿势
用户兑换码的时候,核心需求只有一个:同一个人不能重复兑换同一个码,不同的人也不能同时兑换同一个码。换句话说,要对一个码的消费状态做原子化操作。
错误示范是这样的:先查状态,再更新状态。
python复制# 错误:先查后改会出并发问题
record = db.query("SELECT status FROM coupon_codes WHERE code=%s", code)
if record.status == 0:
db.execute("UPDATE coupon_codes SET status=1 WHERE code=%s", code)
两个请求同时查到status=0,都能进入if分支,然后都执行UPDATE,结果同一张券被两个用户兑换成功。这就是经典的"先查后改"并发陷阱。
正确做法是让更新语句自己判断条件,一步到位:
python复制# 正确:一条UPDATE原子地完成状态流转
affected = db.execute(
"UPDATE coupon_codes SET status=1, used_at=NOW(), user_id=%s WHERE code=%s AND status=0",
user_id, code
)
if affected == 0:
# 没有更新到任何行,说明码不存在、已使用或已作废
return "兑换失败"
else:
# 影响到一行,说明本次兑换成功
return "兑换成功"
UPDATE ... WHERE status=0本身是原子操作,数据库的行锁会保证同一时间只有一个请求能成功修改状态。这就是幂等消费的核心思路,只要最终状态正确,不管并发来多少次,都只会有一个成功者。
4.3 幂等键:防止用户请求重试导致的重复发放
有一种情况上述方案仍然不够:用户在兑换成功之后,网络超时,前端自动重试了一次。第二次请求进来时,码已经变成status=1,WHERE status=0不会命中,所以不会重复发放。看起来没问题。
但如果你还要给用户账号加权益,比如兑换成功就送100积分,而积分发放是独立的流程,就可能出现第一次请求成功但积分发放超时,第二次重试又发了一遍的情况。这时候需要引入业务幂等键,把"用户+兑换码+幂等键"作为唯一约束。
我在477777里建了兑换流水表,UNIQUE(biz_id, code),同一个业务请求ID只能成功插入一次,后续的重试直接返回第一次的结果:
sql复制CREATE TABLE redeem_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
biz_id VARCHAR(64) NOT NULL COMMENT '业务幂等ID,如订单号或请求号',
user_id VARCHAR(64) NOT NULL,
code VARCHAR(32) NOT NULL,
points INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz_code (biz_id, code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这个表的好处是:即使主状态表的更新逻辑出问题,流水表的唯一约束也能兜住重复发放。线上排查重复发放问题时,直接查流水表就能定位到具体的重复请求ID,非常高效。
5. 上线后被日志刷屏:一次真实的"码被疯狂兑换"事故
477777这个项目上线第一天,监控面板就飘红了。日志里满屏都是477777开头的兑换日志,但不是正常用户产生的,而是一个测试账号在并发循环调用兑换接口,同一个码被反复请求了几百次。
表面上看,UPDATE ... WHERE status=0已经挡住了重复兑换,但日志量激增暴露了一个性能问题:每次重复请求都会打到数据库,即使没有更新到行,也白白消耗一次查询。更严重的是,如果恶意用户用一批已使用的码疯狂请求,数据库会一直做无效UPDATE,拖垮整个库。
5.1 第一道防护:本地校验位过滤
这时候校验位就派上用场了。在请求进入数据库前,先本地计算校验位,不匹配的直接拒绝。这一步能过滤掉大约三成恶意请求,因为很多攻击者根本不关心校验位格式,只会随机乱填。
5.2 第二道防护:Redis缓存已使用的码
我在Redis里维护了两类缓存:
active:code,码生成后预热进去,兑换时先查缓存判断码是否存在;used:code,码兑换成功后写入,并发请求进来自动被拦截。
流程变成:校验位通过 -> 查Redis判断码是否存在 -> 查used缓存判断是否已使用 -> 再走数据库UPDATE。
只要缓存没有失效,同一张码的重复请求根本碰不到数据库。实际压测下来,QPS从数据库直接扛的800左右提升到了1万以上,缓存拦截效果非常明显。
5.3 缓存穿透的风险与兜底
用缓存最怕穿透。假设攻击者拿了一个不存在的码,Redis查不到,直接打到数据库,同样能把库打死。我的处理办法是:Redis查不到时,码存在性查询用布隆过滤器挡一层;如果布隆过滤器也误判存在,才放行到数据库,数据库查不到就把它也加入一个短TTL的空值缓存,后续同样的码不会再打库。
这三个防护叠加起来,线上日志量才稳定下来,监控平台也恢复了正常。
6. 这套方案能复用到哪:从优惠码到通用唯一编号
477777项目做完之后,我把代码里和优惠券业务相关的部分抽出来,做成了一个通用的唯一编号生成服务。后面接了好几个需求,都是相同套路,只是改了前缀和位数:
| 场景 | 前缀 | 随机段长度 | 校验方式 | 状态字段 |
|---|---|---|---|---|
| 优惠码 | P477 | 8 | 加权模32校验位 | 未使用/已使用 |
| 邀请码 | INV | 6 | 同上 | 未激活/已激活 |
| 工单号 | WRK | 6 | 无校验位 | 开启/关闭 |
| 内部活动抽奖码 | LOT | 10 | 加权模32校验位 | 未中奖/已中奖 |
特别说明一下,工单号这种内部系统使用的编号,用户不需要手工输入,就没有必要加校验位;随机段短一点,生成出来还方便记忆。而邀请码这种需要用户转发、手工输入的,校验位就必须有。
6.1 哪些设计该保留,哪些该砍掉
根据这几次项目复用,我总结了一套保留/砍掉的原则:
- 保留:字符集去混淆、唯一索引、原子状态更新、流水表幂等键。这四个是任何唯一编号系统都不会亏的设计。
- 砍掉:如果业务量很小,并发不高,Redis多层缓存可以不用,直接用数据库唯一索引兜底就行。一个日活几百人的后台系统,不需要为百万级营销活动设计缓存方案,过度设计反而增加运维负担。
6.2 如果重新做一遍,我会改哪些地方
如果现在让我重写一遍477777,有几个地方我会直接推翻原来的方案:
第一,生成和入库会统一走一个独立的微服务,而不是在业务项目里写脚本。因为后续各个业务线都要用码,统一服务可以统一管理前缀、生成策略和监控指标,避免每条业务线各自造轮子。
第二,校验位的算法会升级成真正的标准算法,比如CRC32取模或者FFI照搬Luhn Mod N这一类公开实现,而不是自己随手设计的加权求和。自己设计的算法在没有经过充分测试的前提下,存在边界场景下校验位不稳定的风险。
第三,会更早埋好全链路日志。我们前期排查并发冲突和重复请求时,花了不少时间在日志格式不统一这个低级问题上。如果一开始就把流水号、用户ID、码本体、请求来源全部打在一条结构化日志里,排查效率会高很多。
写到这里,477777这个项目的核心经验基本都展开完了。这种东西平时没人会写成文档,但它确确实实是任何做发码类业务都会遇到的全套问题:格式怎么设计、随机源怎么选、入库怎么快、并发怎么防、缓存怎么加、日志怎么排。我踩过的坑,我尽量详细地留在了上面,如果你正好在做一个类似的需求,照着这个思路走,能少走不少弯路。
