☰
二维码活码系统设计:短码重定向、动态码管理与扫码统计
2026/11/3 2:56:38 网站建设 项目流程

简介:面向需要搭建动态二维码管理平台的开发者与站长,这套活码网站源码围绕内容可改、二维码图案不变的机制,给出从活码生成、后台内容维护到扫描跳转反馈的完整实现,可用于营销推广、产品追踪、会员服务与扫描数据统计等场景,适合有 PHP 基础的开发者二次开发或教学参考。压缩包共 2000 个文件、约 17.79MB,以 761 个 php 业务与接口文件为主体,配合 259 个 html 页面、247 个 js 脚本、132 个 css 样式及大量 gif、png 界面素材,另含 15 个 sql 建表脚本与多份 config 配置、functions 函数库,目录结构分为前台展示、后台管理与安装配置几条主线。已有 1049 人学习下载,可据此理解活码唯一标识与服务器请求的对应关系、数据库记录与跳转规则的联动方式,并借现成安装脚本与后台模块快速跑通环境,再按需替换跳转逻辑。

1. 活码不是换个图片链接:一张二维码背后到底在管什么

物料已经印出去了,活动链接要换,静态码只能整批重印——这是做线下投放最常见的一次翻车。二维码活码解决的就是这件事:码面图案固定不变,用户扫出来的只是一串短码,短码落到服务端再决定往哪跳。动态码管理管的不是图片,是这层映射关系:谁在什么时候、按什么比例、跳到哪个目标,以及跳了多少次。做门店物料、包装印刷、批量投放的团队,只要出现过一次“链接要改”的需求,就该把这层跳转捏在自己手里。下面从表结构、跳转服务、后台管理一路拆到踩坑点,代码都能直接跑。

2. 活码的数据模型:四张表决定你后面改不改得动

不少人写活码站点的第一版,是拿一个links表存code和url两列,能跳就行。上线两周后需求来了:要按比例分流做 A/B、要活动到期自动切、要看每天扫码量、要停用某个渠道而不删数据。这时才发现表结构从根上就没有留扩展位,只能停机改表、回填数据,血泪经验基本都出在这里。数据模型不是过度设计,是把“以后一定会变”的部分提前挪出主链路。

2.1 码面里到底该放什么:短链、长链与中间页的取舍

二维码的容量是有上限的,内容越长,码点越密。同样印在 2cm 见方的包装上,一段 300 字符的长链接很可能在低端机、弱光环境下扫不出来,而 30 字符以内的短码几乎一扫一个准。所以码面里只放https://你自己的域名/q/Ab3xK9这种形态,路径段控制在 8 位以内。

三种常见放法各有边界:

放法码面内容改动是否要重印适合场景
直接放目标长链完整业务 URL要一次性活动、不打算复用
短码重定向域名 + 6~8 位短码不要绝大多数投放场景
短码 + 中间页域名 + 短码,落地先出一个提示页不要需要在 App 内嵌浏览器里给操作指引

短码重定向是默认选项,中间页只在有明确“环境判断”需求时才加,因为多一次点击意味着实打实的流失率。中间页的价值不在美观,而在于它能拿到完整的环境信息:是不是内嵌 WebView、屏幕尺寸、是否支持某种能力,再决定是直接跳还是给一段操作说明。

还有一层常被忽略:短码所属的域名。这是你唯一一次“印死”在物料上的东西,选的时候要按三五年不换的规格去选,别拿一个临时子域名凑合。

2.2 四张表的职责划分:主表、目标表、日志表、汇总表

主表存“码”本身,一个短码一行,字段尽量少、尽量稳定,因为这张表是每次跳转都要命中的热表。目标表存“往哪跳”,一个码可以挂多个目标,这是活码和静态码最大的区别。日志表存“跳了多少次”,只追加不更新。汇总表存“按天按码的统计结果”,由日志表定时聚合得来,后台报表只读它。

为什么要拆日志和汇总?因为跳转是高频写,报表是低频读。如果每次扫码都去UPDATE qr_live SET hit = hit + 1,同一个热门码的全部请求会打在同一行上,行锁排队,QPS 上到几百就开始抖。日志表追加写没有行锁冲突,汇总放离线算,两条链路互不干扰。

目标表里qr_id + enabled建联合索引,是因为选目标时永远是“先按 qr_id 捞全部启用目标,再在内存里做权重/时间筛选”。别在这一步做数据库层面的随机排序,ORDER BY RAND()在大表上会全表扫描,是个经典的自杀写法。

主表里mode字段决定选择策略,常见取值是 1 单目标、2 权重轮换、3 时间切换。把它放在主表而不是单独建规则表,是因为策略数量有限且互斥,一列枚举足够;真到了需要组合条件(地区 + 时间段 + 权重)的时候,再抽一张规则表,那时你的量级也撑得起这次重构了。

2.3 建表 SQL:从零到第一条活码

下面这段在 MySQL 5.7 及以上、MariaDB 上都能直接执行。字符集统一utf8mb4,别用utf8,短码里万一混进用户自定义后缀会踩到四字节字符的坑。

-- 主表:一个短码一行,是每次跳转都要查的热表,字段刻意保持精简 CREATE TABLE qr_live ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, code CHAR(8) NOT NULL COMMENT '短码,二维码码面只承载它', title VARCHAR(64) NOT NULL DEFAULT '' COMMENT '后台备注名,便于检索', mode TINYINT NOT NULL DEFAULT 1 COMMENT '1单目标 2权重轮换 3时间切换', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', expire_at DATETIME NULL COMMENT '整体失效时间,NULL 表示长期有效', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_code (code) -- 跳转链路的唯一入口,必须是唯一索引 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 目标表:一个码可挂多个落地页,权重与时间窗都在这里 CREATE TABLE qr_target ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, qr_id BIGINT UNSIGNED NOT NULL, url VARCHAR(1024) NOT NULL COMMENT '落地页地址,含协议头', weight SMALLINT NOT NULL DEFAULT 100 COMMENT '权重轮换时的相对比例', start_at DATETIME NULL COMMENT '时间切换的生效起点', end_at DATETIME NULL COMMENT '时间切换的生效终点', enabled TINYINT NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY idx_qr_enabled (qr_id, enabled) -- 选目标固定是这两列一起过滤 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 日志表:只追加不更新,避免热码上的行锁排队 CREATE TABLE qr_hit_log ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, qr_id BIGINT UNSIGNED NOT NULL, target_id BIGINT UNSIGNED NOT NULL DEFAULT 0, ip_hash CHAR(16) NOT NULL DEFAULT '' COMMENT 'IP 加盐哈希,不存明文', ua_type TINYINT NOT NULL DEFAULT 0 COMMENT '0未知 1移动 2桌面 3内嵌', hit_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_qr_time (qr_id, hit_at), KEY idx_time (hit_at) -- 定期清理历史日志靠它 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 汇总表:后台报表只读这张,按天按码一行 CREATE TABLE qr_stat_daily ( qr_id BIGINT UNSIGNED NOT NULL, stat_date DATE NOT NULL, hit_cnt INT UNSIGNED NOT NULL DEFAULT 0, uv_cnt INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (qr_id, stat_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

四个点的逻辑值得说清楚。主表的uk_code是唯一索引,跳转时是WHERE code = ?的等值查询,走唯一索引是常数级开销,这是整条链路能扛住并发的地基。目标表的idx_qr_enabled覆盖了“取某个码的全部启用目标”这个固定动作,别再额外查weight > 0之类的条件把索引废掉。日志表的idx_qr_time服务于“查某个码最近七天”的后台查询,而idx_time服务于清理任务,两个索引方向不同,别图省事只建一个。汇总表用(qr_id, stat_date)做联合主键,天然去重,聚合时可以直接ON DUPLICATE KEY UPDATE。

插入第一条活码并验证索引命中:

INSERT INTO qr_live (code, title, mode) VALUES ('Ab3xK9', '包装盒正面码', 2); INSERT INTO qr_target (qr_id, url, weight) VALUES (LAST_INSERT_ID(), 'https://example.com/landing-a', 70), (LAST_INSERT_ID(), 'https://example.com/landing-b', 30); -- 确认走的是 uk_code 而不是全表扫,type 应该是 const EXPLAIN SELECT id, mode, status FROM qr_live WHERE code = 'Ab3xK9';

EXPLAIN里type那一列如果出现ALL,说明索引没建对或者字段类型不匹配——最常见的错是把code建成VARCHAR(8)而查询时传了CHAR补空格,或者字符集不一致导致索引失效。上线前花两分钟看一眼执行计划,比上线后半夜收到告警强。

3. 跳转服务的最小实现:短码解析、302 与命中日志

模型有了,接下来是真正被扫的那一层。这一层的要求很朴素:延迟低、不能挂、改目标立刻生效。任何在这条链路上加“顺手做点别的”的冲动,最后都会变成压测时的一个瓶颈。

3.1/q/:code这一层只做四件事

一个短码请求进来,按顺序是:查码 → 判有效 → 选目标 → 记日志并返回重定向。就这四步,别再加了。

查码走唯一索引,单次在毫秒以下;判有效看status和expire_at;选目标按mode分支;记日志必须是异步的,不能让它拖慢重定向。顺序也别乱,先判有效再选目标,可以省掉一次无谓的目标查询。

重定向状态码用 302,不要用 301。301 是永久重定向,浏览器会把它缓存下来,之后用户再扫同一个码,请求根本不会到你的服务器,直接走浏览器里的缓存——你改了目标链接,用户看到的还是旧的。这个坑我在一个活动里踩过,改完目标半小时内还有人反馈“跳的是老页面”,排查了半天才想起是 301 的锅。301 唯一的优点是省服务器流量,而活码的核心诉求恰恰是“随时能改”,这对矛盾没有折中空间。

响应头里补两行:Cache-Control: no-store和Pragma: no-cache,把 CDN、反向代理、App 内嵌浏览器这几个环节的缓存都堵死。

3.2 用 Node.js + SQLite 把跳转链路跑起来

下面这段是能直接node server.js跑起来的最小版本。生产环境把 SQLite 换成 MySQL 只需改驱动和几句 SQL,逻辑不变。

// server.js —— 最小可跑的活码跳转服务 const express = require('express'); const Database = require('better-sqlite3'); const crypto = require('crypto'); const app = express(); const db = new Database('./liveqr.db'); // 预热:查码语句只编译一次,避免每次请求重复解析 SQL const stmtCode = db.prepare( 'SELECT id, mode, status, expire_at FROM qr_live WHERE code = ?' ); // 目标按 qr_id 一次性捞回,权重筛选放到内存,避免数据库端随机排序 const stmtTargets = db.prepare( 'SELECT id, url, weight, start_at, end_at FROM qr_target WHERE qr_id = ? AND enabled = 1' ); // 日志异步落盘,这里用简单的队列,生产建议换成消息队列 const stmtLog = db.prepare( 'INSERT INTO qr_hit_log (qr_id, target_id, ip_hash, ua_type) VALUES (?, ?, ?, ?)' ); function pickTarget(list, mode) { const now = Date.now(); // 时间切换优先:命中时间窗的直接返回,没命中再往下走权重 if (mode === 3) { const hit = list.find(t => { const s = t.start_at ? new Date(t.start_at).getTime() : 0; const e = t.end_at ? new Date(t.end_at).getTime() : Infinity; return now >= s && now <= e; }); if (hit) return hit; } // 权重轮换:展开成前缀和区间,随机数落在哪段就选谁 const total = list.reduce((s, t) => s + (t.weight > 0 ? t.weight : 1), 0); let r = Math.random() * total; for (const t of list) { r -= (t.weight > 0 ? t.weight : 1); if (r < 0) return t; } return list[list.length - 1]; } app.get('/q/:code', (req, res) => { const code = String(req.params.code || '').toUpperCase().slice(0, 8); if (!/^[2-9A-HJ-NP-Z]{4,8}$/.test(code)) return res.status(404).end(); const qr = stmtCode.get(code); if (!qr || qr.status !== 1) return res.status(404).send('码已失效'); if (qr.expire_at && new Date(qr.expire_at).getTime() < Date.now()) { return res.status(410).send('码已过期'); } const targets = stmtTargets.all(qr.id); if (!targets.length) return res.status(404).send('未配置目标'); const t = pickTarget(targets, qr.mode); // 日志失败绝不能影响跳转,这里 try/catch 兜住 try { const ip = (req.headers['x-forwarded-for'] || req.ip || '').split(',')[0].trim(); const ipHash = crypto.createHash('md5').update(ip + 'SALT').digest('hex').slice(0, 16); stmtLog.run(qr.id, t.id, ipHash, 0); } catch (e) { /* 静默 */ } res.set('Cache-Control', 'no-store'); res.redirect(302, t.url); }); app.listen(8080, () => console.log('live qr on :8080'));

几处参数和逻辑要交代清楚。stmtCode用prepare预编译,作用是让 SQLite/MySQL 复用执行计划,高频接口上能省掉可观的解析开销。code先toUpperCase()再校验正则,是因为短码字符集里排除了0/O/1/I/L这类易混字符,统一大写可以避免用户手输或系统截取时的大小写不一致问题——正则里[2-9A-HJ-NP-Z]正好 32 个字符,和第四章的字符集严格对应。

pickTarget里时间切换排在权重之前,是刻意的:活动切换这种场景通常有明确的起止时间,优先保证时间语义,比保证比例重要。weight做了一次> 0 ? weight : 1的兜底,防止后台误填 0 权重导致这个目标永远选不中、看起来像“目标丢了”。

日志用try/catch静默包住,是因为写日志失败是次要故障,而跳转失败是主要故障,主次不能颠倒。生产环境把这段换成写入本地队列再由独立进程消费,接口路径上就不该有同步 IO。

3.3 缓存放哪:本地 Map、Redis 与“干脆不缓存”的边界

短码是典型的读多写极少数据,天然适合缓存。但缓存加错地方,就会变成“改了目标不生效”的元凶。

本地 Map 缓存适合单实例、量不大的场景,实现就二十行:get时判断过期时间,set时带 TTL,命中就直接返回,未命中再查库。TTL 设 60 秒是个比较稳的折中——改完目标最多一分钟生效,对绝大多数业务都能接受;如果你的业务要求秒级生效,那就得在后台改目标时主动delete掉对应 key,光靠 TTL 不够。

Redis 适合多实例部署。所有实例共享同一份缓存,后台改目标时DEL liveqr:code:Ab3xK9,下一个请求自然回源。要注意的是缓存穿透:如果攻击者用随机短码高频请求,每次都会击穿到数据库。解决办法是查不到也缓存一个空值,TTL 设短一点,比如 10 秒。

还有一种情况建议干脆不缓存:总码量不到几千、QPS 不到几百的时候,唯一索引查询本身就够快,加缓存引入的一致性问题和运维成本反而更高。别为了“架构完整”给自己找事,先压测,看瓶颈在哪再加。

4. 动态码管理后台:批量生成、轮换策略与命中统计

前面是“能跳”,这一章是“能管”。后台不是给开发看的,是给运营每天点的,所以它的核心不是界面好看,是每个操作都不能把线上跳转搞挂。

4.1 短码长度与字符集:从碰撞概率反推

字符集选 32 个字符:数字 2-9 共 8 个,大写字母去掉I、O后共 24 个,合计 32。去掉易混字符的收益很直接——线下物料被用户手动输入、客服口头报码、系统 OCR 识别时,0和O、1和I造成的误判能少一大半。

长度按容量倒推。6 位 32 字符集的组合数是32^6 ≈ 10.7 亿,7 位是 343 亿。按生日问题估算,要保证碰撞概率低于万分之一,6 位能支撑的码量在十万级,7 位能到千万级。绝大多数投放场景 6 位绰绰有余,超过十万张码再上 7 位。

生成时有个细节必须处理:如果字符集长度不整除 256,直接byte % 32会产生取模偏差,靠前的字符出现概率更高。32 整除 256,这个特定字符集是无偏的;但只要你换了字符集(比如 36 位字母数字混排),就必须改成拒绝采样——超出可整除范围的高值字节直接丢弃重取。

// 批量生成短码,带唯一性校验与拒绝采样 const ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // 32 个,去掉 0 O 1 I const BASE = ALPHABET.length; function genCode(len = 6) { // 32 整除 256,此字符集天然无偏;换成 36/62 位字符集需保留下面的拒绝逻辑 const limit = 256 - (256 % BASE); let out = ''; while (out.length < len) { for (const b of crypto.randomBytes(len * 2)) { if (b >= limit) continue; // 落在不可整除区间,丢弃重取 out += ALPHABET[b % BASE]; if (out.length === len) break; } } return out; } // 批量插入:冲突就重试,最多三轮,避免死循环 function batchCreate(db, count, title) { const ins = db.prepare('INSERT INTO qr_live (code, title) VALUES (?, ?)'); const exist = db.prepare('SELECT 1 FROM qr_live WHERE code = ?'); let ok = 0; while (ok < count) { let code = genCode(6), tries = 0; while (exist.get(code) && tries++ < 3) code = genCode(6); if (tries >= 3) continue; ins.run(code, title + '-' + (ok + 1)); ok++; } return ok; }

limit那一行是关键。256 % 32 === 0,所以limit就是 256,所有字节都可用;一旦把BASE改成 36,limit变成 252,253 到 255 三个值被丢弃,分布才均匀。批量插入用先查后插而不是直接靠唯一索引报错重试,是因为异常路径的成本远高于一次索引查询,十万量级的批量生成能差出好几秒。tries上限三轮,是为了防极端情况下死循环。

4.2 三种轮换策略各自适合什么场景

权重轮换是 A/B 测试的标准做法,把 100 的流量按 70/30 分给两个落地页,跑一周看转化。它的坑在于“真随机”在小样本下偏斜严重——一天只有 50 次扫码时,按 50/50 分很可能出现 35/15 这种分布,运营看到报表会以为代码有问题。解决办法是按天、按码做累积样本量提示,样本低于阈值时报表标灰,别让人误读。

时间切换适合活动场景:早 9 点到晚 9 点跳活动页,其余时间跳常规页。它的边界是时区,数据库里存 UTC、代码里按业务时区转换,别在 SQL 里直接比字符串时间,那是最容易出错的地方。

按设备类型兜底属于第三类:主目标正常时都跳主目标,检测到内嵌 WebView 时改跳一个带“在浏览器中打开”提示的中间页。这类策略不需要独立的mode,挂在选择函数末尾做二次拦截就够了。

三种策略的取舍本质是:权重关心“比例”,时间关心“时刻”,设备关心“环境”。混在一起写会让选择函数变成一团乱麻,正确做法是分层判断,每层职责单一。

4.3 命中统计:日志追加加定时聚合

报表要能回答两个问题:今天这个码被扫了多少次、其中多少人是不重复的。

日志表里ip_hash存的是加盐 MD5 前 16 位,不存明文 IP,既满足去重需求又不落隐私。UV 的口径是“同一 ip_hash 同一天算一次”,用COUNT(DISTINCT ip_hash)算,别用COUNT(*)冒充。

聚合任务建议每小时跑一次,把上一小时的数据落到qr_stat_daily:

-- 按天聚合,幂等写法,重复跑不会翻倍 INSERT INTO qr_stat_daily (qr_id, stat_date, hit_cnt, uv_cnt) SELECT qr_id, DATE(hit_at) AS d, COUNT(*), COUNT(DISTINCT ip_hash) FROM qr_hit_log WHERE hit_at >= ? AND hit_at < ? GROUP BY qr_id, DATE(hit_at) ON DUPLICATE KEY UPDATE hit_cnt = hit_cnt + VALUES(hit_cnt), uv_cnt = uv_cnt + VALUES(uv_cnt);

?传的是上一小时的起止时间,窗口边界左闭右开,避免整点数据被算两次。ON DUPLICATE KEY UPDATE让这段可以重复执行,任务失败重跑不会产生脏数据。用VALUES()的写法在 MySQL 8.0.20 之后会有告警,可以换成别名写法,但兼容性上老写法更稳。

日志表要有清理策略。按hit_at建了索引,就能按天批量删:保留 90 天,每天凌晨删 90 天前那一天的,每批限制 5000 行,分多次删,别一条DELETE删几百万行把主从延迟拉爆。

4.4 后台最该有的四个操作

第一是改目标,改完必须主动清缓存,而不是等 TTL 过期。第二是停用码,只改status不删数据,因为历史日志还关联着qr_id。第三是复制码,复制时生成新短码但复制全部目标配置,这是运营最高频的动作。第四是导出,把码、标题、当前目标、累计扫码量导成表格,供线下物料对接用。

这四个操作里,只有“改目标”和“停用码”会直接影响线上,所以它们都该走二次确认,并且记录操作日志——出问题时能查到是谁在什么时候改的,这比什么都重要。

5. 避坑与排查:改完不生效、被刷爆、跳不过去的五个坑

5.1 坑一:改了目标,用户扫出来还是旧页面

现象是后台显示已更新,实测扫码跳的还是旧链接,换个手机又正常。原因大概率是 301 永久重定向被浏览器或中间层缓存了,也可能是 CDN 把 302 响应也缓存了。解决是把状态码改成 302,响应头显式加Cache-Control: no-store,同时检查 CDN 的缓存规则里有没有把/q/路径配成“跟随源站”——有些默认规则会把所有 200/301 缓存,302 反而漏网。改完在浏览器无痕窗口和另一台设备上各验一次。

5.2 坑二:短码被批量扫描,日志表一天涨几千万行

现象是磁盘告警,qr_hit_log增长曲线变成垂直。原因是短码规律性太强(比如自增转 36 进制),被人写脚本顺序遍历;或者某个码被放到了公开页面被爬虫反复抓。解决办法有三层:短码必须随机生成不用自增;跳转接口加基于 ip_hash 的滑动窗口限流,单 IP 每分钟超过 60 次直接返回 429;日志落库前做一次采样,同一 ip_hash 同一秒内的重复请求只记一条。限流阈值别设太死,真实场景里一个门店的 WiFi 出口 IP 后面可能挂着几十个顾客同时扫码。

5.3 坑三:App 内嵌浏览器里点不动、白屏

现象是同一张码,在系统相机扫码能正常打开,在某个 App 里扫就白屏或停在空白页。原因是内嵌 WebView 对某些跳转、某些脚本能力有限制,或者目标页本身做了 UA 判断。解决办法是把判断逻辑放在短码服务里:识别到 UA 含内嵌标识时,跳一个轻量中间页,页面上只放一个按钮和一行提示,引导用户用系统浏览器打开。中间页别放任何依赖复杂脚本的东西,纯 HTML 加一个<a>就够,越简单越不容易出问题。

5.4 坑四:权重设了 50/50,实际跑出来 80/20

现象是 A/B 两个目标权重相同,一天下来扫码量差了四倍。原因是样本量太小,随机分流在小样本下波动本来就大;另一种可能是流量本身有偏,比如某个渠道的用户集中在某个时段。解决办法是别用当天的数据下结论,至少累积到每个目标 300 次以上再看比例;同时在后台把“样本量不足”的报表标灰。如果确实需要严格均分,可以改成按ip_hash取模的确定性分流,同一个用户永远落到同一侧,这样分布更稳,也方便做用户级归因。

5.5 坑五:主库改完,从库还在读旧数据

现象是后台保存成功,跳转服务却还在跳旧链接,刷新几次偶尔又对了。原因是跳转服务读的是从库,主从复制有延迟。解决办法是让跳转服务的关键读走主库,或者改完目标后延迟一小段时间再清缓存,把复制窗口盖过去。更彻底的做法是缓存里存版本号,后台改完把版本号加一,服务端比对版本号不一致就回源,不依赖时间窗口去赌。

6. 从能跑到敢用:灰度切换、限流兜底与可回滚的验证方法

一套活码系统真正上线前,我一般会做三件事,顺序不能颠倒。

第一件是影子验证。新服务部署好但先不接流量,把老系统的跳转日志导一份出来,用新服务的解析逻辑离线跑一遍,比对每一条短码选出来的目标是否一致。这一步能抓出字符集不匹配、权重边界、时间窗时区这三类问题,成本极低,收益极高。

第二件是灰度切量。按短码维度切,先切 1% 的码到新服务,观察 24 小时的重定向成功率、P95 延迟、日志写入量三条曲线。切量维度别按 IP 或地域切,因为同一个码可能被不同地区的人扫,切分维度不一致会导致同一个码一会儿新一会儿旧,排查起来像玄学。

第三件是留回滚开关。配置中心里放一个redirect_engine的开关,值是新/旧,出问题时改一下立刻回到老链路,不用重新发版。这个开关的读取频率很高,务必做本地缓存加长轮询,别每次跳转都去请求配置中心,那等于给自己加了一个单点。

限流这块的兜底策略值得单独说。接口层面用令牌桶,按ip_hash维度限速,桶容量 20、每秒补 1 个,超出的返回 429 并带Retry-After。数据库层面给跳转服务的连接池设上限,一般 20 到 30 就够,因为单次查询是唯一索引等值查,耗时极短,连接池开太大反而会把数据库压垮。缓存层再加一道熔断:连续 50 次回源失败就进入半开状态,直接返回 503 而不是把请求全打到数据库上。

压测的指标口径也统一一下:关注 P95 和 P99,别看平均值。平均值 5ms 但 P99 是 800ms 的系统,用户体验一样糟糕。

说个我自己的教训。早期做的一个版本,为了“性能”,把短码和目标的映射全量加载进内存,后台改目标时发一条广播让所有实例刷新。单机测试完美,上到三台机器就出问题——广播丢了一条,那台机器的用户改完之后一直跳旧链接,而且没有任何日志,完全是个黑匣子。后来改成缓存带版本号、定时比对,问题才消失。活码这东西,慢一点没关系,不一致才是要命的。

如果你正准备做这套东西,建议先只实现主表加单目标跳转,把 302、日志、限流这三件事做扎实,权重轮换和时间切换等有真实需求再加。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询