接手过带防伪溯源需求项目的朋友,应该都懂一个感受:业务方丢过来一句话“我们要给每件产品生成一个唯一的防伪码,支持扫码查询真伪”,听起来简单,但等你真正动手设计防伪码生成规则、搞定几十万甚至上百万条数据的批量写入时,坑就一个一个冒出来了。我之前在负责一套产品防伪溯源后台时,系统还是老的PHP单体架构,框架是ThinkPHP 3.2.3,数据库是MySQL 5.6,服务器配置也一般,却要求在项目上线前把数十万条防伪码全部生成并写入库。这篇文章就是那次实践的完整复盘,从防伪码生成算法设计到批量写入数据库的每一个环节,我都会讲清楚为什么这么做、踩过哪些坑,希望给正在做类似功能的朋友一些参考。
这套内容不只适合防伪溯源项目,只要是“唯一码生成+大数据量写入”这类场景,比如激活码、兑换码、邀请码、卡密系统,都有通用的参考价值。我会结合ThinkPHP 3.2.3为例来写,但核心算法和批量写入思路完全可以用在其他PHP框架,甚至换成别的语言也没问题。
1. 防伪码项目的需求拆解与整体思路
1.1 防伪码到底在解决什么问题
做防伪系统之前,首先要搞清楚一件事:防伪码不是简简单单一个随机字符串,它承担着两个核心职责,一个是“唯一标识”,一个是“防伪验证”。每一件产品对应一个码,消费者拿到产品后刮开涂层、扫一下二维码或者输入防伪码,系统就能告诉他这个码是不是真的、有没有被查过、查过几次。所以码本身必须具备随机性、唯一性、不可猜测性,同时还要考虑存储和查询的效率。
很多初次接触这个需求的同学会想:我直接用 md5(uniqid()) 生成一串哈希当防伪码不就行了吗?当然不行。md5(uniqid()) 生成的是32位十六进制字符串,里面有数字也有小写字母,用户手动输入时很容易输错,而且长度偏长。更关键的是,哈希值是“连续随机”的,如果用户想知道下一个码长什么样,虽然加密算法本身不可逆,但哈希串在展示上并不友好,印刷在包装上体验也很差。防伪码面向的是消费者,不是开发者,所以编码格式、长度、可读性必须从用户体验出发去设计。
一句话总结需求:防伪码要满足“唯一、难猜、好输入、好印刷、可校验”五个条件。前三个偏算法设计,后面两个偏编码规则。把这些想清楚了再动手写代码,后面才不会返工。
1.2 技术方案选型:为什么还在用ThinkPHP 3.2.3
你可能会问,现在都什么年代了,为什么还在用ThinkPHP 3.2.3这种老框架?实话说,这不是我主动选的,而是客户系统一直跑在这个版本上。大部分传统制造业、传统零售企业的后台系统,都是几年前外包团队搭的,技术栈一旦定了就很难升级,牵一发动全身。我们的项目就是在这样一个“存量系统”上做增量开发。
所以这里的方案选型逻辑不是“哪个框架新用哪个”,而是“在现有系统基础上,怎么最稳妥地把功能加上去”。ThinkPHP 3.2.3虽然是老框架,但它的M()模型方法、D()方法在单表操作上非常简洁,而且自带事务支持,配合MySQL做批量插入也没有问题。防伪码生成算法本身是纯PHP逻辑,跟框架无关,我直接封装成独立类,放在 Application/Common/Util/ 下面,既能在控制器里调用,也能在命令行模式跑。这套做法最大的好处是:万一以后系统升级到ThinkPHP 5或6,算法类可以原封不动拷过去。
1.3 数据表结构设计
在写生成算法之前,先把表结构定下来。我设计了三个表:产品表、批次表、防伪码明细表。产品表就是基础的SKU信息,批次表记录每一次批量生成的防伪码属于哪个产品、什么时间生成的,防伪码明细表则存储每一个防伪码的具体内容。
sql复制CREATE TABLE `product` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT '产品名称',
`created_at` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `batch` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_id` int(11) NOT NULL COMMENT '关联产品',
`batch_no` varchar(32) NOT NULL COMMENT '批次号,业务可读',
`total_count` int(11) NOT NULL DEFAULT '0' COMMENT '本批次防伪码总数',
`created_at` int(11) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_batch_no` (`batch_no`),
KEY `idx_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `code_records` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`batch_id` int(11) NOT NULL COMMENT '所属批次ID',
`code` varchar(32) NOT NULL COMMENT '防伪码',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0=未查询 1=已查询',
`query_count` int(11) NOT NULL DEFAULT '0' COMMENT '查询次数',
`created_at` int(11) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
KEY `idx_batch_id` (`batch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
防伪码表加了两条关键索引,一个是 uk_code 唯一索引,一个是 idx_batch_id 普通索引。唯一索引的作用非常明显,它是防重复的“最后一道物理防线”,不管代码逻辑有没有bug,只要数据库层面约束了唯一性,重复码就绝对写不进去。批号索引则是为了后续按批次查询、统计时避免全表扫描。这两条索引一定要在项目起步阶段就加上,如果等数据量大了再加索引,那锁表时间会非常恐怖。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 防伪码生成算法:从随机字符串到安全编码
2.1 编码格式设计的原则
防伪码的格式设计,决定了后面所有的生成逻辑。我先说说自己定下的编码规则,一共耗时不到半天就定了,但每一项背后都有考虑。
格式是16位大写字母+数字混合编码,字符集去掉容易混淆的字符。我用的字符集是:
code复制ABCDEFGHJKLMNPQRSTUVWXYZ23456789
这里面去掉了 I、O、1、0,原因是这几个字符在印刷体和手写体场景下特别容易弄混,消费者刮开涂层看到是 O 还是 0,根本分不清。32个字符的数量也是有讲究的,2的5次方,后续如果想做位运算或者压缩处理很方便。16位长度配合32字符集,总组合数是 32^16,大约是 1.2 × 10^24,足够覆盖绝大多数业务场景。
为什么不用纯数字?纯数字10个字符,要做到同样的防伪强度,码长至少要25位以上,太长了。为什么不用纯字母?纯字母输入歧义大,很多输入法会自动转英文大小写,体验不好。综合下来,“大写字母+数字、剔除混淆字符、16位定长”是最适合消费者手动输入的方案。
2.2 生成算法实现(含校验位)
这个算法里最有技术含量的一点,是加入了“校验位”。防伪码的最后一位不是随机出来的,而是根据前面15位字符算出来的一个校验字符。它的作用是:当用户把防伪码输入进系统时,程序可以在查数据库之前先做一次本地校验,如果校验位对不上,直接提示“防伪码格式不正确”,根本不用查库,既快又减少数据库压力。
生成算法的核心代码如下:
php复制class CodeGenerator
{
private $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
private $charsLen = 32;
private $codeLength = 16;
/**
* 生成一个防伪码
*/
public function generate()
{
$code = '';
for ($i = 0; $i < $this->codeLength - 1; $i++) {
$code .= $this->chars[random_int(0, $this->charsLen - 1)];
}
$checkChar = $this->calcCheckCode($code);
return $code . $checkChar;
}
/**
* 校验防伪码是否合法
*/
public function verify($code)
{
if (strlen($code) != $this->codeLength) {
return false;
}
$body = substr($code, 0, $this->codeLength - 1);
$checkChar = $code[$this->codeLength - 1];
return $this->calcCheckCode($body) === $checkChar;
}
/**
* 根据前N位计算校验位
*/
private function calcCheckCode($body)
{
$sum = 0;
$len = strlen($body);
for ($i = 0; $i < $len; $i++) {
$sum += ord($body[$i]) * ($i + 1);
}
return $this->chars[$sum % $this->charsLen];
}
}
calcCheckCode 的原理是:把前15个字符的ASCII码分别乘以位置权重(第1位乘1,第2位乘2,以此类推),然后求和,再对32取模,得到的结果作为字符集的索引。这个算法不算复杂,但能有效检测出常见的输入错误,比如输错某个字符、字符顺序颠倒、漏字符。校验位所选的取模值必须是字符集长度的约数或者直接用模运算,这样答案分布才均匀,可以最大程度避免“不同的错误输入算出的校验位恰好相同”。
如果以后想升级防伪强度,可以把位置权重改成质数序列,比如2、3、5、7、11这样递增,碰撞概率会更低,当然校验代码也需要同步修改。
2.3 为什么不能用简单随机数
在生成防伪码时,很多人第一反应是用 rand() 或者 mt_rand()。这也算是一个典型的“看起来没问题,细想都是问题”的场景。rand() 在PHP 7.1以前是基于C库的线性同余生成器,可预测性非常强。即使别人不知道你种子值是什么,通过采集若干条防伪码样本做统计分析,都有概率预测出下一个码。对于低安全场景可能无所谓,但防伪码的本质就是防伪,算法一旦被逆向,整个系统就失去了意义。
所以我在代码里用的是 random_int(),这是PHP 7引入的安全随机数函数,它在底层会调用操作系统的 /dev/urandom 或Windows的 CryptGenRandom,不可预测性有硬件级别的保证。如果项目还跑在PHP 5.6上,没有 random_int(),可以用 openssl_random_pseudo_bytes() 来替代,效果类似,也是密码学级别的安全随机源。
这里多说一句经验:安全随机函数在大量循环调用时会有性能损耗。实测下来,random_int() 在普通服务器上每秒大概能执行几十万次,对十万级别的生成任务来说完全不是瓶颈,但如果你的数据量到了千万级别,循环调用会比较吃力。后面我会讲到在这种情况下怎么做优化。
3. 百万级防伪码批量生成的性能优化
3.1 批量生成的三种常见方案
生成防伪码本身不复杂,真正考验代码水平的是“批量”两个字。我梳理下来,业界常见的批量生成方案有三种:
第一种是“逐条生成、逐条入库”。这种方式写起来最简单,一个循环里生成一条、插入一条,但性能最差,因为每次插入都涉及一次网络往返和SQL解析,十万条数据可能要跑几十分钟甚至几个小时。
第二种是“生成一批、拼接一条SQL批量插入”。在一个循环里连续生成几千条码,放到数组里,凑够一批就拼成一条多VALUES的INSERT语句执行,然后再继续下一批。这种方式性能好,入库速度肉眼可见地快。
第三种是“先全部生成到内存、再一次写入”或者“用LOAD DATA INFILE”。这种方式性能最好,但对内存要求高,并且在线上环境使用 LOAD DATA 还受限于文件权限和MySQL配置,实际操作麻烦一些。
我的实践结论是:方案二最适合大多数项目。它不像方案三那样依赖文件权限,也解决了方案一的性能问题。你只需要控制好单批插入条数,即使普通虚拟机也能跑得很好。
3.2 去重策略:内存还是数据库
批量生成时的去重问题,很容易被忽略。虽然单个防伪码重复的概率低到可以忽略,但你要知道,如果每批生成1万条、连续跑几十批,概率就会被放大。而且更常见的重复原因是代码bug,比如随机数种子没变、进程被中断后重跑、多进程并发生成等等。所以去重必须做,而且要做两道:内存去重 + 数据库唯一索引兜底。
内存去重的实现很简单,用PHP数组的键来做哈希集合即可。因为防伪码是字符串,直接以它为键名,判断 isset 就行,时间复杂度是O(1),速度非常快。具体代码如下:
php复制$generator = new CodeGenerator();
$total = 100000; // 本次要生成10万个
$seen = []; // 内存去重集合
$codes = []; // 待入库数据数组
$batchSize = 5000; // 每5000条写一批
while (count($codes) < $total) {
$code = $generator->generate();
if (isset($seen[$code])) {
continue; // 重复了,重新生成
}
$seen[$code] = true;
$codes[] = [
'batch_id' => $batchId,
'code' => $code,
'status' => 0,
'created_at' => time(),
];
if (count($codes) >= $batchSize) {
$this->insertBatch($codes); // 批量写入
$codes = []; // 清空,继续下一轮
}
}
// 最后一句要处理循环结束后不足一批的剩余数据
if (!empty($codes)) {
$this->insertBatch($codes);
}
这里的 $batchSize 不是拍脑袋定的,它和MySQL的 max_allowed_packet 参数有关。默认情况下这个参数是4MB或64MB(不同版本不同),一条INSERT语句拼接的数据量最好不要超过这个值的四分之一,否则会报“Packet too large”错误。按一条记录200字节估算,5000条大约是1MB,比较安全。
3.3 实测数据与内存分析
我用一台2核4G的虚拟机做了一次基准测试,PHP版本7.2,MySQL 5.7,生成并写入10万条防伪码。结果如下:
| 生成方式 | 耗时 | 内存峰值 |
|---|---|---|
| 逐条生成+逐条插入 | 约22分钟 | 不到20MB |
| 批量生成+批量插入(每条1000) | 约65秒 | 约35MB |
| 批量生成+批量插入(每条5000) | 约50秒 | 约50MB |
| 全部生成到内存再一次性插入 | 约45秒 | 约220MB |
从结果可以明显看出来,逐条插入是不现实的,而“5000条一批”在耗时和内存之间取得了不错的平衡。内存占用主要来自 $seen 数组和 $codes 数组,10万个码的键值对大概占30-40MB,这是可以接受的范围。如果你的码数量更大,可以每完成一批就释放 $codes 并配合 gc_collect_cycles() 强制回收,但 $seen 占用的内存是省不掉的,除非改成用Redis的Set存储已生成的码,但那又会引入外部依赖,看项目规模权衡即可。
4. 批量写入数据库的完整实操
4.1 分批插入与事务控制
批量写入这部分,关键点在于“分批 + 事务”。先把一段真实可用的写入代码贴出来,再解释每个环节的细节:
php复制public function insertBatch($dataList)
{
$model = M('code_records');
// 不建议用框架自带的 addAll,自写SQL更可控
$sql = 'INSERT INTO `code_records` (`batch_id`, `code`, `status`, `created_at`) VALUES ';
$values = [];
foreach ($dataList as $row) {
$values[] = '(' . (int)$row['batch_id'] . ",'" . addslashes($row['code']) . "',0," . time() . ')';
}
$sql .= implode(',', $values);
$model->startTrans();
try {
$model->execute($sql);
$model->commit();
} catch (Exception $e) {
$model->rollback();
// 记录日志、触发告警
Log::error('防伪码批次写入失败: ' . $e->getMessage());
throw $e;
}
}
这里有几个容易踩的坑。第一个,为什么不用ThinkPHP 3.2.3的 addAll() 方法?因为底层实现是循环拼接SQL,跟自写SQL本质上没有区别,但它的参数绑定逻辑在某些版本下会对大数组产生额外开销,而且不好控制批大小。自写SQL反而更透明,执行计划更容易分析。
第二个,事务的控制粒度。如果整个批量过程10万条全部放在一个事务里,一旦中间某条数据写入失败(比如触发了唯一索引冲突),整个事务都要回滚,前功尽弃。所以我使用“5000条一个事务”的粒度,既能保证单批写入的原子性,某批失败也不影响前面已经提交的批次,断点续跑的成本低很多。
第三个,addslashes() 和单引号拼接。防伪码字符集严格控制为字母和数字,其实不存在注入风险,但代码里保留 addslashes 是防御性的习惯,防止以后改代码生成规则时引入了特殊字符还浑然不知。如果你用的是PDO,更推荐预处理绑定参数,不过ThinkPHP 3.2.3的 execute 方法对预处理的支持比较繁琐,在数据量大的场景下直接用字符串拼接反而更快。
4.2 唯一索引与防重复兜底
前面提过,数据库唯一索引是防重复的最后一道防线。这里说说当它真正触发时的表现。假设数据里有一条防伪码跟库里已有记录重复,INSERT语句执行时MySQL会返回 Duplicate entry 'XXXX' for key 'uk_code' 错误。这个错误会中断当前整个批次的写入,事务回滚,但前面已经提交的批次不受影响。
遇到这种情况,我不建议在应用层直接忽略重复错误然后继续,因为一旦有重复,往往不是“偶然运气差”,而是生成逻辑哪里出了问题。好的做法是:捕获到重复错误时,先检查日志里哪个批次、哪条SQL出了问题,然后单独拉出那段数据做检测。你可以把冲突数据先插入到一个临时表里,再关联 code_records 查出哪些码已存在,从而反推出生成逻辑的bug位置。
另外一个实用小技巧:如果业务允许,可以在写入前先做一次 SELECT code FROM code_records WHERE code IN (...) 来查重,把已存在于库里的码从待插入数组中剔除,然后再拼接INSERT语句。这个查询同样可以按批来做,比如每5000条查一次,性能开销很小。
4.3 与业务批次、产品绑定
防伪码不是孤立的数据,它必须关联到具体的产品和批次。我这里把“生成防伪码”和“建立业务批次”分成了两步,先插入 batch 表拿到批次ID,再生成防伪码并批量插入 code_records 表。批次记录里有产品ID、批次号、总数等信息,这样后续做统计报表、追溯分析都非常方便。
批次号的生成也有讲究。我用的规则是 P + 产品ID + 日期 + 随机数,比如产品ID为123,则在2025年2月28日生成的批次号类似 P12320250228452。批次号不承担防伪验证功能,只做业务标识,所以不需要太强的随机性,但拼接规则要分明,方便人工快速识别。这个设计在你将来需要“按产品查询所有防伪码”“按批次导出打印”时,会省很多事。
5. 常见问题与排查实录
5.1 防伪码重复问题
我在压测时遇到过一种很隐蔽的重复情况:批量任务跑到一半脚本内存溢出被kill掉了,重启后没有跳过“已生成的码”,而是重新从头生成,由于 $seen 是内存数组,重启后已清空,导致同一批号下出现重复码。如果你不做内存去重,唯一的感受就是库存重复了,但查不到原因。
解决思路是落盘。生成防御码时,除了内存去重外,每成功写入一批,就把这批的批次号和生成范围记到日志里。脚本重启后,先查询 batch 表确认这个批次是否已经生成过,如果已生成过就跳过,或者追加到一个 batch_code_range 表里记录“该批次最小/最大ID”,下次断点续传直接接着来。我们最终用了更简单的方案:为 code_records 表增加一个 batch_no 冗余字段,重启后按 batch_no 查 MIN(id)、MAX(id) 判断进度,虽然多占用一点存储,但排查问题非常直观。
5.2 生成慢、卡死
当数量达到百万级别时,最常见的瓶颈是“生成本身太慢”。一个容易忽略的细节是,在循环里调用 random_int() 时,如果循环体内部还有其他IO操作,比如每生成一条就写一次日志,那性能会急剧下降。我建议整个生成阶段只做纯CPU计算,日志每5000条写一次,尽量不要在循环里做任何数据库操作。
还有一点,不要让Web请求直接跑整个批量生成任务。十万条数据需要几十秒,浏览器请求早就超时了。我当时的方案是写一个命令行脚本,用 php think(在ThinkPHP 3.2.3中可以用 php index.php /Home/Codegen/index 的方式)触发,通过CLI模式执行,不受PHP默认 max_execution_time 限制。如果你只能在Web环境里跑,记得在脚本开头加 set_time_limit(0),但这只是临时的土办法,最终还是建议用CLI或队列。
5.3 写入超时与连接中断
批量写入还有一个非常容易踩的坑:MySQL的 wait_timeout。如果脚本在生成阶段耗时太长,比如生成100万条码花了10分钟,而MySQL连接8分钟没有活动就被服务端断开了,那么你生成完之后再执行INSERT,会直接报“MySQL server has gone away”。解决办法是:每批写入前检测连接状态,断了就重连。ThinkPHP 3.2.3的 M() 模型底层封装了连接管理,但遇到这种情况时需要调用 M()->getDbInstance()->reConnect() 或者干脆退出脚本重跑,利用断点续传机制继续。
另外,批量写入时如果表已经很大,每插5000条数据,InnoDB的B+树索引更新、唯一索引检查、redo log刷盘都会消耗IO。如果服务器磁盘性能一般,可以把 innodb_flush_log_at_trx_commit 临时调为2,等批量任务跑完再改回1。这会让数据安全性降低一些,但对一次性导入任务来说,性能提升非常可观。
5.4 常见故障速查表
| 问题现象 | 可能原因 | 排查方法 | 解决建议 |
|---|---|---|---|
| 生成后防伪码出现重复 | 内存去重失效或断点续传逻辑缺失 | 查询日志中批次号与写入范围 | 增加断点续传机制,用batch_no判断进度 |
| INSERT执行报Packet too large | 单批数据量超过max_allowed_packet | 查看MySQL错误日志 | 减小每批条数,建议1000-5000条 |
| 脚本跑一半报gone away | 连接空闲时间超过wait_timeout | 查MySQL通用日志 | 分批前检测连接、重连 |
| 批量生成响应超时 | Web请求执行时间限制 | 查看PHP error log | 改用CLI或队列执行 |
| 查防伪码很慢 | code字段没有唯一索引 | EXPLAIN查询计划 | 给code加唯一索引,正常应命中uk_code |
写在最后的几点体会
做这个防伪码项目,最深的感触是:真正难的从来不是“生成一个随机字符串”这个动作,而是把它放到“大批量、高可靠、可追溯”的业务场景里时,所有细节都会放大。内存去重、分批事务、断点续传、唯一索引兜底,这些听起来平淡无奇的技术点,组合在一起才构成了一个能扛住线上生产环境的功能模块。
如果让我给后来者一个建议,我会说:先把数据表设计好,索引在项目开始就加上,千万别等数据量上来了再回头补;然后写代码时把日志埋到位,每批生成、每批写入、每批失败都要有记录,这样出了问题才有排查的抓手;最后多想想“脚本中断了怎么办”,而不是假设一切都会顺顺利利跑完。
另外,别忘了生成算法里那个校验位。它在线上运营阶段帮我们拦截了大量“输入错误”的无效查询,直接减掉了数据库的无效压力。这类细节看起来不起眼,但积少成多,才是整个系统能稳定运行的关键。
