简介:面向需要搭建动态二维码管理平台的开发者与站长,这套源码围绕“活码”实现二维码图案不变、指向内容可随时更新的管理流程,覆盖用户提交参数、后端建库生成码、后台修改内容、扫码实时回源查询等环节,可用于营销推广、产品追踪、会员服务与多语言展示等场景。压缩包共2000个文件,约17.79MB,以php程序、html页面、js脚本、css样式以及gif、png等图像资源为主,另含sql建表数据、config配置、函数文件与tpl模板等,目录结构较完整,便于本地部署、二次开发和功能扩展研究。源码将前端展示、后端接口、数据库记录与模板配置分层组织,读者可据此梳理活码从创建、更新到扫码回源的完整链路。已有1049人学习下载,适合具备PHP与数据库基础、想理解活码生成逻辑、后台内容管理、扫描反馈统计及隐私处理的读者参考。
1. 活码不是“生成一张图”:从扫码到落地页这条链路上到底发生了几件事
很多人第一次听到“二维码活码”,以为是某种更高级的二维码图案。其实不是。二维码活码的图案和普通二维码一模一样,变化发生在扫码之后:手机先请求一个固定的短链地址,服务端再根据当前配置决定用 302 跳到哪个目标页。所以“二维码活码网站源码”本质上是一套短链跳转服务,外加一套动态码管理后台:建码、配置子码、设置分流规则、查看扫码数据。
这套东西最适合三类人:印刷物料已经发出但内容不能重印的运营;需要按渠道、设备、地域分流的技术同学;以及想拿一份能跑起来的骨架做二次开发的人。它解决的问题很具体:一张已经贴在包装上的码,明天要改落地页,你不用重印。接下来的内容按“拆包看结构 → 本地跑通 → 分流与统计 → 上线排错 → 二次开发”这条线走,中间会把参数和踩过的坑都摆出来。
2. 拆包先看三张表:活码、子码、扫码日志之间的关系
拿到一个网站源码包,最快的上手方式不是先去找安装说明,而是看数据表。因为活码系统所有的业务规则,最后都会落到“一个短码关联 N 个子码”这个结构上。表设计定了,后面的分流算法、统计口径、后台表单字段基本就锁死了。这一章拆的是骨架,下一章才开始装环境。
2.1 目录骨架与唯一入口
这类包的目录结构大同小异,能看出作者水平的地方在于:public是不是真的只放对外文件,config和runtime有没有被挡在 web 根目录外面。
qr-live/ ├── public/ # 唯一对外的 web 根目录 │ ├── index.php # 单一入口 │ ├── admin/ # 后台的前端静态资源 │ └── uploads/qrcode/ # 生成出来的二维码 PNG ├── app/ │ ├── controller/ │ │ ├── Redirect.php # 短链跳转,全站最热的文件 │ │ ├── Live.php # 活码的增删改查 │ │ └── Stats.php # 扫码数据查询 │ ├── service/ │ │ ├── Picker.php # 分流算法 │ │ └── QrBuilder.php # 二维码出图 │ └── model/ ├── config/ │ ├── app.php │ └── database.php ├── runtime/ # 日志与缓存,别放 public 下 └── sql/install.sqlpublic/index.php通常只做三件事:加载配置、注册路由、分发请求。我见过不少源码包把控制器逻辑直接写进入口文件,读起来很累,但功能上不影响使用,不用急着重构。真正需要注意的是路径:web 根目录必须指向public,不要指向包根目录。否则config/database.php和.env可以直接被浏览器下载走,这类事故在实际运维事故里占比不低。
2.2 三张核心表,以及字段为什么这么留
活码系统的最小表集合是:活码主表、子码表、扫码日志表。下面是这类项目里最常见、也够用的一组结构,可直接在 MySQL 5.7 及以上版本跑。
-- 活码主表:一行代表一个固定不变的短码 CREATE TABLE `qr_live` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `code` varchar(12) NOT NULL COMMENT '短链码,全局唯一,印在物料上的就是它', `name` varchar(64) NOT NULL COMMENT '备注名,给运营看的', `mode` tinyint NOT NULL DEFAULT 1 COMMENT '1权重轮询 2条件分流 3固定单目标', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用', `expire_at` datetime DEFAULT NULL COMMENT '到期时间,NULL 表示长期', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 子码表:一个活码下面挂 N 条,是真正的落地地址 CREATE TABLE `qr_sub` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `live_id` int unsigned NOT NULL, `target_url` varchar(512) NOT NULL, `weight` smallint NOT NULL DEFAULT 100 COMMENT '权重,仅在 mode=1 时生效', `cond_type` varchar(16) NOT NULL DEFAULT 'default' COMMENT 'default/device/region/time', `cond_value` varchar(64) NOT NULL DEFAULT '' COMMENT '条件值,如 MicroMessenger 或 2025-06-01', `status` tinyint NOT NULL DEFAULT 1, `sort` smallint NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_live` (`live_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 扫码日志:只追加,不更新,方便按天分区或定期归档 CREATE TABLE `qr_scan_log` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `live_id` int unsigned NOT NULL, `sub_id` int unsigned NOT NULL DEFAULT 0, `ip` varbinary(16) NOT NULL COMMENT 'inet_pton 存,省一半空间', `ua` varchar(255) NOT NULL DEFAULT '', `scanned_at` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_live_time` (`live_id`, `scanned_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个字段的取舍值得单独说:
| 字段 | 常见错误做法 | 建议做法与理由 |
|---|---|---|
qr_live.code | 直接用自增 id | 用 6~8 位大小写字母数字,避免被顺序遍历爬取;也方便后台按码搜索 |
| 落地地址存放位置 | 直接塞在qr_live上 | 必须拆到qr_sub,否则“一码多目标”就无从谈起,改一次地址要动主表 |
weight类型 | 用decimal | smallint足够,权重只参与整数累加,浮点会带来精度麻烦 |
ip存储 | varchar(15) | varbinary(16)配合inet_pton,IPv6 也能一列装下 |
| 扫码时间 | timestamp | datetime,避开 2038 问题,统计时也不用反复做时区转换 |
mode这个字段是整个系统的分水岭。mode=1走权重轮询,mode=2走条件匹配,mode=3就是最简单的固定跳转。很多源码包把三种策略写成三个独立的控制器,改起来很麻烦;放在一个字段里,跳转入口只判断一次,后面都是策略分支。
提示:
qr_sub.target_url一定要在入库前做协议白名单校验,只允许http和https。否则后台一旦被弱口令进去,就是一个现成的跳转跳板。
2.3 跳转为什么必须是 302,而不是 301
这是整套系统里最容易被忽略、但后果最严重的一个技术决策。301 是永久重定向,浏览器、CDN、中间的各级缓存都会把它记下来,而且理论上没有过期时间。你后台把目标地址从 A 改成 B,扫出来还是 A,清缓存都不一定管用。活码的全部价值就在于“随时能改”,用 301 等于把这个价值亲手掐掉。
正确的做法就三行:
<?php // 只在确认目标之后才发头,之前不能有任何输出 header('Location: ' . $target, true, 302); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); exit;302表示临时重定向,语义上就告诉所有中间层“别记”。第二行和第三行是给老缓存层的双保险,尤其是部分内置浏览器对Cache-Control的处理并不一致,两个头一起发成本几乎为零。
至于 307:它和 302 的区别在于保留原始请求方法。扫码场景永远是 GET,用 302 就够,307 在部分老设备上反而有兼容性讨论。这一点不用纠结,选 302。
3. 本地跑通:依赖、配置项与第一个能扫的码
结构看完,接下来让它跑起来。这一章的目标很明确:在本地或者一台测试机上,从空环境开始,直到用手机扫出一张自己生成的码,并且能在后台改掉它的落地页。整个过程大概二十分钟,前提是别在版本上踩坑。
3.1 环境版本与建库
这类 PHP 系的活码源码,常见依赖是 PHP 8.0 以上、MySQL 5.7 以上、Redis 5 以上。PHP 扩展里必须确认三个:pdo_mysql、redis、gd。前两个不用解释,gd是用来在服务端合成带 logo 的二维码图片的,缺了它后台点“下载二维码”会直接 500。
# 1. 建库,字符集必须 utf8mb4,否则中文备注名会变问号 mysql -uroot -p -e "CREATE DATABASE qr DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;" # 2. 导入结构,install.sql 里通常只有建表语句,不含演示数据 mysql -uroot -p qr < sql/install.sql # 3. 自检扩展,两个 false 里出现任何一个就先装扩展再往下走 php -r "var_dump(extension_loaded('redis'), extension_loaded('gd'), extension_loaded('pdo_mysql'));" # 4. 起一个本地服务,只为验证跳转逻辑,不做生产用 php -S 127.0.0.1:8080 -t public第 4 步用内置服务器只是图快,它不带 URL 重写,短链/aB3dK9这种路径会 404。验证跳转逻辑时先用http://127.0.0.1:8080/index.php?c=redirect&code=aB3dK9这种带参数的地址,等第 3.2 节的 Nginx 配好再切短链形式。
3.2 配置项逐条说清:短链域名、重写规则、缓存开关
配置文件通常是config/app.php加一个.env。下面这张表是必须逐条确认的,漏一条就可能出现“后台正常、扫码 404”这种最难查的组合。
| 配置项 | 示例值 | 说明与常见坑 |
|---|---|---|
APP_URL | https://qr.example.com | 后台生成二维码时用它拼短链,写成http://会导致部分浏览器提示混合内容 |
SHORT_DOMAIN | qr.example.com | 短链专用域名,建议和后台域名分开,方便单独做限流 |
DB_HOST/DB_NAME | 127.0.0.1/qr | 用127.0.0.1而不是localhost,可以避开 socket 路径不一致的问题 |
CACHE_DRIVER | redis | 设成file也能跑,但高并发下文件锁会拖慢跳转,测试环境可以,线上别用 |
REDIS_TIMEOUT | 0.05 | 连接超时,单位秒。跳转链路对延迟极度敏感,宁可降级查库也不能卡住 |
CACHE_TTL | 300 | 活码配置的缓存秒数。改完配置要立刻生效就手动删 key,别指望它自己过期 |
LOG_QUEUE | redis | 扫码日志写队列,不开的话每次扫码都要同步写一次 MySQL |
Nginx 的重写规则是本地跑通短链的最后一道坎。核心诉求只有一条:把/{code}这种根路径形式的短链,转给跳转控制器,同时不影响后台的静态资源。
server { listen 80; server_name qr.example.com; # 换成你自己的短链域名 root /www/qr-live/public; # 必须指向 public,不是包根目录 index index.php; # 短链入口:/aB3dK9 这种 4~12 位的码,转给跳转控制器 location ~ ^/([A-Za-z0-9]{4,12})$ { rewrite ^/([A-Za-z0-9]{4,12})$ /index.php?c=redirect&code=$1 last; } # 其余请求走常规入口,后台和接口都靠这条 location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/run/php/php8.1-fpm.sock; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_read_timeout 3s; # 跳转接口不能拖,3 秒还没完就是有问题 } }正则里{4,12}这个位数限制不是随便写的。太短容易和后台的admin、login、api这些真实路径撞车,太长则二维码图案会变密、印刷后更难扫。6 到 8 位是比较舒服的区间。另外location ~ ^/([A-Za-z0-9]{4,12})$用的是正则匹配,优先级高于普通前缀匹配,所以后台的/admin/index.php这种带扩展名的路径不会被误吞,可以放心。
注意:改完 Nginx 一定要
nginx -t再 reload。重写规则里的last和break写错一个,表现是后台能进、短链 404,或者反过来,排查起来很费时间。
3.3 后台建码与二维码出图
配置通了之后,建码流程本身没什么难度:登录后台,填活码名称,选模式,加一条或多条子码,保存,然后在列表页点“二维码”拿到 PNG。真正值得说的是码的生成参数,因为这一步决定了印出去的物料能不能扫得动。
# 独立出图脚本,比在后台点按钮更适合批量生成 import qrcode def build(code: str, out: str) -> None: qr = qrcode.QRCode( version=None, # 自动选版本,别硬编码 error_correction=qrcode.constants.ERROR_CORRECT_H, # 30% 冗余,带 logo 必须用 H box_size=12, # 每个模块 12 像素,印刷下限 border=4, # 静区 4 个模块,少了边缘扫不出 ) qr.add_data(f"https://qr.example.com/{code}") qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") img.save(out) build("aB3dK9", "live_aB3dK9.png")四个参数值得逐个解释。version=None让库根据数据长度自动选版本,短链只有三十来个字符,自动选出来通常是 version 3 到 4,图案不至于太密。ERROR_CORRECT_H提供约 30% 的容错,这是你要在码中间放 logo 时的前提,用 M 级(15%)放 logo 会显著提高扫不出来的概率。box_size=12决定成品像素密度,屏幕展示用 8 就够,印刷物料建议 12 以上,因为印刷有网点扩散。border=4是二维码四周必须留白的静区,这是国际规范要求,很多设计同学为了排版好看把白边裁掉,结果就是扫不出来,这是最容易遇到的一类问题。
建码这一步还有一个容易漏的动作:新码入库后,要把短链对应的缓存一并写进去或者删掉。有些包的逻辑是先查缓存,查不到再查库并回填。如果你在后台改了子码但缓存 TTL 是 300 秒,那这五分钟里扫出来的仍是旧目标。改配置时顺手删live:{code}这个 key,问题就没了。
4. 动态码管理的核心:分流规则怎么写才不出乱子
活码之所以“活”,一半在能改地址,另一半在能自动分流。分流做得好,一条物料可以同时服务多个渠道;做得糙,就会出现“明明配了三条子码,实际全打进同一条”的诡异现象。这一章把权重轮询和条件分流拆开讲,顺带说清扫码统计为什么不能同步写库。
4.1 权重轮询:等权重取模,不等权重平滑加权
最朴素的实现是index = 自增计数 % 子码数量。它有个硬伤:只支持等权重。一旦运营说“A 链 70%、B 链 30%”,取模就废了。
加权轮询的经典解法是平滑加权轮询:每个子码维护一个“当前权重”,每次请求给所有子码的当前权重加上自身权重,选出当前权重最大的那个,再把被选中者的当前权重减去总权重。这样出来的序列是交错的,不会出现“前七个全是 A”这种扎堆。
<?php // app/service/Picker.php // 输入:某个活码下全部启用的子码;输出:本次命中的那一条 function pickSub(array $subs, string $liveKey, Redis $redis): array { // 只有一条就别折腾算法,省一次 Redis 往返 if (count($subs) === 1) { return $subs[0]; } $weights = array_column($subs, 'weight'); // 权重全等:自增取模,O(1),多机部署下也是最公平的 if (count(array_unique($weights)) === 1) { $n = (int)$redis->incr('rr:' . $liveKey); return $subs[$n % count($subs)]; } // 权重不等:平滑加权,当前权重存 Redis Hash,多台机器共享同一份状态 $hashKey = 'cw:' . $liveKey; $best = null; $total = 0; foreach ($subs as $s) { $cw = (float)$redis->hIncrByFloat($hashKey, (string)$s['id'], (float)$s['weight']); $total += (int)$s['weight']; if ($best === null || $cw > $best['cw']) { $best = ['cw' => $cw, 'sub' => $s]; } } $redis->hIncrByFloat($hashKey, (string)$best['sub']['id'], -$total); return $best['sub']; }几个参数和实现细节:
rr:{liveKey}这个计数器不需要设过期时间,但要定期清理已经删除的活码对应的 key,否则 Redis 里会留一堆垃圾。我一般写个每周跑的脚本,扫rr:*和库里qr_live.code做差集。- 当前权重用 Redis Hash 而不是 PHP 静态变量,是因为线上大概率是多台机器。如果各自在进程内算,用户连着扫两次可能都落到 A,分流就形同虚设。
hIncrByFloat的返回值就是累加后的当前权重,不需要再读一次,这一点在 QPS 高的场景下能省掉一半往返。- 权重值本身建议限制在 1~1000 之间,并且定期做一次归一化。见过有人把权重写成 100000,跑一阵之后 Redis 里的当前权重变成七位数,虽然不影响正确性,但可读性很差。
提示:子码状态变更时(停用、删除、新增),记得把
cw:{liveKey}这个 Hash 一起删掉。残留的旧子码 id 会参与累加,导致新加的子码永远选不中。
4.2 条件分流:判定顺序比条件本身更重要
条件分流要解决的是“同一张码,微信里跳 A,支付宝里跳 B,晚上八点后跳 C”。实现上通常是给qr_sub打上cond_type和cond_value,然后在跳转时按顺序匹配。
顺序这件事,比写多少种条件都关键。合理的顺序是:时间条件 → 地域条件 → 设备条件 → 默认兜底。理由是时间窗口最窄、最“贵”,先匹配能减少后续判断;默认兜底必须放最后,否则前面所有条件都失效。
<?php // 按优先级匹配,返回 null 表示没有命中,调用方用全局备用地址兜底 function matchByCondition(array $subs, array $ctx): ?array { // 1. 时间条件:cond_value 形如 "2025-06-01" 或 "2025-06-01~2025-06-07" foreach ($subs as $s) { if ($s['cond_type'] !== 'time') continue; [$from, $to] = array_pad(explode('~', $s['cond_value']), 2, $s['cond_value']); if ($ctx['ts'] >= strtotime($from . ' 00:00:00') && $ctx['ts'] <= strtotime($to . ' 23:59:59')) { return $s; } } // 2. 设备条件:cond_value 就是 UA 关键字,如 MicroMessenger / AlipayClient foreach ($subs as $s) { if ($s['cond_type'] === 'device' && stripos($ctx['ua'], $s['cond_value']) !== false) { return $s; } } // 3. 地域条件:用已经查好的地区码比对,绝不在这一段里发 HTTP 请求 foreach ($subs as $s) { if ($s['cond_type'] === 'region' && $ctx['region'] === $s['cond_value']) { return $s; } } // 4. 默认兜底 foreach ($subs as $s) { if ($s['cond_type'] === 'default') return $s; } return null; }cond_value的约定要提前和运营说清楚。设备条件里填的是 UA 子串,微信内置浏览器是MicroMessenger,支付宝是AlipayClient,这两个是行业里通用的标识,写错一个字母就永远不命中。地域条件依赖 IP 库,我的做法是把 IP 段到地区码的映射提前导入一张本地表,跳转时用inet_pton后的二进制做范围查询,单次查询在毫秒级。曾经有版本在跳转链路里调用外部 IP 查询接口,结果对端一抖动,整个短链的 P99 直接飙到两秒——这类错误只会在真实流量下才暴露出来。
还有一个常被忽略的细节:条件分流的子码如果被停用,status=0的那条必须在前置过滤里剔除掉。不少源码包是先匹配条件再查状态,就会出现“命中了一条已经停用的子码,然后 404”的情况。
4.3 扫码统计:跳转前只做一次 RPUSH
统计功能最容易犯的错,是在跳转前同步写一次 MySQL。扫码是个突发性极强的动作——一场活动放出去,几千人同时扫,每条日志一次 INSERT,表锁和自增争用立刻就把跳转接口拖垮。而跳转接口的用户体感是“扫完就得出来”,慢半秒用户就会以为码坏了。
正确的分工是:跳转链路只负责把一条 JSON 推进 Redis 队列,几十微秒的事;后台起一个常驻消费者或定时任务,批量落库。
<?php // 跳转控制器里的统计部分,就这么几行 $redis->rPush('scan:queue', json_encode([ 'live_id' => $live['id'], 'sub_id' => $sub['id'], 'ip' => inet_pton($clientIp), 'ua' => mb_substr($ua, 0, 255), 'ts' => time(), ], JSON_UNESCAPED_UNICODE));消费者每分钟跑一次,一次最多取 500 条,攒成一条多值 INSERT 写进去,吞吐比一条条写高一个数量级:
<?php // cron/scan_consumer.php,建议配成每分钟执行 $batch = []; while (count($batch) < 500 && ($raw = $redis->lPop('scan:queue')) !== false) { $batch[] = json_decode($raw, true); } if (!$batch) exit; $values = []; $params = []; foreach ($batch as $b) { $values[] = '(?,?,?,?,FROM_UNIXTIME(?))'; array_push($params, $b['live_id'], $b['sub_id'], $b['ip'], $b['ua'], $b['ts']); } $sql = 'INSERT INTO qr_scan_log (live_id, sub_id, ip, ua, scanned_at) VALUES ' . implode(',', $values); $pdo->prepare($sql)->execute($params);去重是另一件事。同一个用户反复刷新,会把扫码量刷得很高,看数据的人就会误判。常规做法是用SETEX scan:dedup:{live_id}:{ip_hash}:{date} 86400 1做 24 小时去重,SETNX返回真才入队。这里的{date}要按自然日算,不然跨天统计会有半天的数据挂在两个桶里。
统计口径还有一点要提前定:uv用 IP 加 UA 的组合哈希,还是只用 IP?只用 IP 在移动网络下严重失真,因为运营商 NAT 出口会导致大量用户共享同一个 IP。我一般用 IP 加 UA 的前 64 位做哈希,误差能接受,成本也低。
5. 排查清单:上线后最容易踩坑的五个点
前面讲的是“应该怎么建”,这一章讲的是“建完了为什么会不对”。这五条都是我在实际部署里反复遇到的,按出现频率排序,每条按现象、原因、解决三步走。遇到问题先对号入座,能省掉大量盲猜时间。
5.1 后台改了目标地址,扫出来还是旧页面
现象:后台把子码的target_url从 A 改成 B,保存成功,刷新列表也确实显示 B,但手机扫码仍然跳到 A,换个手机也一样。
原因:缓存没失效。跳转链路读的是live:{code}这个 Redis key,TTL 五分钟。改配置时只更新了数据库,没有删缓存。更隐蔽的一种情况是 Nginx 或前置 CDN 缓存了 302 响应本身,这时候连 Redis 都清了也没用。
解决:在子码和活码的保存逻辑里加一行$redis->del('live:' . $code),这是一条必须写进代码的规则,而不是靠人记得手动删。再确认响应头里有Cache-Control: no-store,并且在 CDN 上把短链路径设为不缓存。排查时用curl -I看返回头,X-Cache之类的字段能直接告诉你命中了哪一层。
5.2 微信里打开一片空白,其他浏览器正常
现象:用系统相机扫码能正常跳转,用微信扫一扫打开后是白屏,或者提示页面无法访问。
原因:微信内置浏览器对落地页有额外的要求,尤其是页面里有跳转、下载、外链的时候。另外如果短链域名是刚备案不久的新域名,风险提示的命中率也会偏高。还有一种纯技术原因:落地页是 HTTP,而微信里当前页面是 HTTPS,混合内容被拦。
解决:第一步先确认落地页本身在微信里能不能直接打开(不走短链),能打开说明问题在短链;打不开就是落地页的事。第二步检查短链响应头里有没有多余的X-Frame-Options,后台管理页的头部配置有时候会被全局中间件带到跳转接口上。第三步,短链域名和落地页域名尽量都上 HTTPS,并且证书链完整——缺中间证书在系统浏览器里能过,在部分内置浏览器里会直接失败,这个差异排查起来非常费劲。
5.3 高并发下子码计数和实际扫描量对不上
现象:活动结束后对账,子码 A 配了 70% 权重,日志里实际只有 55%,剩下的跑到 B 上去了。
原因:两种可能。一是当前权重存在了进程内变量,多台机器各算各的,分流自然偏。二是日志消费者丢数据——lPop之后、INSERT 之前进程被重启,那批数据就没了。
解决:当前权重必须落在共享存储(Redis Hash 或数据库行锁),这点在第 4.1 节已经说过。日志丢数据的问题,把lPop换成lRange加lTrim的两步操作,或者直接用 Redis 的LMOVE把元素挪到一个 processing 列表,消费成功后再删。多这一步,重启时最多重复几条,不会丢。
注意:如果队列积压严重,说明消费者跑得太慢或者挂了。加一个监控,
LLEN scan:queue超过一万就告警,不要等到活动结束才发现数据缺了一大块。
5.4 带 logo 的二维码扫不出来
现象:后台导出的二维码中间有个圆形 logo,自己用手机能扫,但用户反馈“扫半天没反应”,尤其是打印出来的物料。
原因:纠错等级不够,或者 logo 尺寸超标。二维码的容错能力是靠冗余数据换来的,H 级约 30%,意味着 logo 面积不能超过整码面积的约 20%,而且不能盖住三个定位角。另一个常见原因是导出时把静区裁掉了,四周没有留白,扫码器找不到边界。
解决:logo 尺寸控制在码面宽的 18% 以内,必须是纯色背景,不要用半透明。导出时保留 4 个模块的静区,印刷品在静区外再加至少 2 毫米白边。验证方式别只用一台手机,至少找三台不同品牌、不同年代的设备实际扫一遍,新机型摄像头好,老机型才是真实下限。
5.5 换短链域名之后,老码全废
现象:因为原域名出问题需要换,新域名配好了,新生成的码正常,但已经印出去的物料全部失效。
原因:二维码里编码的是完整 URL,域名一换,图案就得重画,物理物料没法改。这是活码方案的固有边界,不是配置问题。
解决:上线第一天就要留好后路。做法是短链域名独立于业务域名,并且提前把备用域名也解析好、证书配好,随时可以切。真要迁移时,在原域名上保留一条 302 到新域名的规则,尽量延长老域名的可用期。这一点在选型阶段没想清楚,后面是补不回来的,属于典型的“后悔药买不到”的问题。
6. 二次开发:加一个到期自动切备用码的钩子,以及上线前跑一遍的验证
源码包自带的功能通常到“建码、分流、统计”就结束了,但真实场景里还有一个高频需求:活动结束后,码不能变成 404,得自动指到一个“活动已结束”的说明页。这个逻辑加在跳转链路里会增加一次判断,加在定时任务里则几乎零成本,我一般选后者。
<?php // cron/expire_switch.php,每分钟跑一次 $fallback = 'https://qr.example.com/offline.html'; $rows = $pdo->query( "SELECT id, code FROM qr_live WHERE status = 1 AND expire_at IS NOT NULL AND expire_at < NOW()" )->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as $r) { // 停掉所有原子码,插入一条兜底,两步放在一个事务里 $pdo->beginTransaction(); $pdo->prepare("UPDATE qr_sub SET status = 0 WHERE live_id = ?")->execute([$r['id']]); $pdo->prepare( "INSERT INTO qr_sub (live_id, target_url, weight, cond_type, status) VALUES (?, ?, 1, 'default', 1)" )->execute([$r['id'], $fallback]); $pdo->prepare("UPDATE qr_live SET mode = 3 WHERE id = ?")->execute([$r['id']]); $pdo->commit(); // 最后才删缓存,顺序反了会出现短暂的空白期 $redis->del('live:' . $r['code']); $redis->del('cw:' . $r['id']); }这段代码有两个细节值得说。第一,三步写库必须在一个事务里,否则万一在“停原子码”和“插兜底”之间挂掉,这个活码就变成 0 条可用子码,扫出来是 404,比不切还糟。第二,删缓存要放在事务提交之后,先删的话,这中间的请求会把旧数据重新读回缓存,等于白删。mode改成 3 是为了让后续跳转走最短路径,不用再进分流算法。
上线前我固定跑一张表,四条验证缺一不可:
| 验证项 | 具体做法 | 通过标准 |
|---|---|---|
| 改址生效 | 后台改目标地址,立刻扫 | 3 秒内跳到新地址,响应头无缓存标记 |
| 权重分布 | 用curl循环请求 1000 次,统计落点 | 与配置权重偏差在 5% 以内 |
| 到期兜底 | 手动把expire_at设为过去时间,跑一次 cron | 老目标全停,落到兜底页,无 404 |
| 并发压测 | 用压测工具对短链打 500 并发 | P99 低于 100ms,Redis 无超时报错 |
这四条跑完,基本可以放心交出去。我印象最深的一次是压测那关没过:单机测试一切正常,五百并发一打,P99 直接冲到八百毫秒,最后定位到是hIncrByFloat那一段在同一个 Hash key 上争用。后来把权重分布相同的活码合并成一个计数器,问题才消失。从那以后我每次上线前,不管多小的改动,这张表都强制走一遍,宁可多花二十分钟,也不想在活动当天被人追着问“码怎么打不开”。希望这些内容能帮到你。
本文还有配套的精品资源,点击获取