资讯动态

活码系统实现原理与高并发短链跳转实践

发布时间:2026/10/28 4:45:05 来源:尧图企业网站定制
去年年中我一个做连锁门店运营的朋友急吼吼地找来说印了五万张门店海报上面那个二维码扫进去是活动页结果活动档期提前两周链接一换五万张物料全成了废纸。这件事让我再次确认了一件事绝大多数人对二维码的认知还停在印上去就定死的阶段而实际上活码这套东西早就是运营基础设施了。这篇文章不聊玄学就把活码到底是什么、活码系统在服务端是怎么跑起来的、实现原理里哪些坑必须提前知道一次讲透。核心关键词就三个活码、活码系统、实现原理。适合三类人看——做运营但不懂技术的、写业务代码但没搭过完整链路的、以及被扫码统计不准折磨过的。看完你至少能做到两件事知道一个活码请求在你服务器上完整走了哪几跳以及自己动手搭一个能上线的最小可用版本。1. 先把活码的本质说清楚它从来不是更高级的二维码1.1 静态码的死穴恰恰是活码的起点二维码在物理层面就是一堆黑白方块它的内容编码方式决定了同一个图案永远解出同一段字符串这是数学层面的事改不了。静态码的问题不是不够高级而是它把业务信息直接焊死在了印刷品上印的是哪个链接扫出来就是哪个链接链接失效、活动下线、域名更换全部要重新出物料。活码的解法很朴素——二维码里只放一个永远不会变的短地址真正的目标地址存在服务端。扫码的那一刻用户先到你的服务器上问一句这个码现在该去哪儿服务器查一下配置再把他转到当前的目标。二维码本身是个门牌号门后面站着谁随时可以换。这个设计一旦成立二维码就从一次性印刷品变成了长期有效的入口。同一张码可以跟着活动档期换落地页可以按渠道换不同的承接页可以在目标服务临时故障时自动兜底到备用页面。物料的生命周期从这次活动拉长到这个门店这是活码最本质的价值其它所有功能都是从这个点长出来的。1.2 活码真正卖的其实是三件事可改、可统计、可分流很多人以为活码就是能改链接这只占三分之一。一套完整的活码系统价值集中在三个能力上而且这三个能力的技术实现难度是递增的。可改是最基础的本质是一次配置更新实现上一个数据库 update 就完事。可统计开始有难度因为统计要准就得把每一次扫码落到日志里还要处理去重、防刷、归因稍微做糙一点报表数据就会离谱到运营不敢用。可分流最难它要求跳转层具备实时决策能力同一个码在不同时间、不同地域、不同设备、不同渠道进来的人可能被送到完全不同的地方而且这个决策过程必须快到用户无感。三者叠加活码系统本质上就是一个带实时决策能力的轻量网关。想明白这一层你后面做架构设计的时候就不会把它当成一个小小的短链服务来对待了。1.3 一个常见误区活码不是用来绕过什么的市面上有不少对活码的描述带着点灰色想象好像活码天生就是用来做审核前后不一致的工具。这种理解既不对也不安全。正经业务里的活码需求90% 以上来自三个真实痛点物料印多了改不了、多渠道投放算不清账、单个承接页扛不住突发流量。举个正常得不能再正常的例子一家做本地生活服务的团队在二十个门店各贴一张码用户扫码后要先判断他是不是在门店附近、当前是不是营业时间、今天这个门店有没有排值班顾问然后才决定把他送到预约页还是客服页。这套逻辑每天可能变三次但码只能印一次。这就是活码最典型、最健康的应用场景。注意任何涉及内容与实际不一致、刻意规避平台规则的用法都不在本文讨论范围内。活码系统的设计前提是目标内容是真实、合规、可追溯的规则配置本身就该留审计日志。2. 系统架构拆解一次扫码请求到底走了几跳2.1 四层结构入口层、决策层、落地层、数据层我把活码系统拆成四层这个拆法用了几年扩展性最好。入口层负责接收扫码请求也就是那个短地址对应的路由。它要做的事情极简解析路径里的短码做最基础的合法性校验然后往上抛。这一层必须轻因为它直面用户任何一个多余的操作都会拖慢首字节时间。决策层是大脑拿着短码去查配置、跑规则、选目标、记日志返回一个跳转指令。落地层是最终承接页可能是外部页面也可能是你自己的一套 H5、小程序路径、App 唤起链接。数据层负责配置存储、缓存、扫描日志、聚合报表。分层的意义在于每一层的扩容方式和故障模式完全不同。入口层是无状态的加机器就行决策层依赖缓存要考虑缓存击穿落地层通常是别人的系统你控制不了它的响应速度数据层则有写入放大问题一次扫码可能写两三条记录。混在一起做出事的时候你连从哪儿查起都不知道。2.2 为什么短链跳转是当前最稳的技术方案活码的实现路线上主流有两条短链跳转和中间页承载。前者是服务器直接返回 302浏览器自己跳过去后者是先返回一个你自己的 H5 页面页面加载完再用 JS 跳或者引导用户点击。短链跳转的优势是快、省、稳。整个链路只有一次 HTTP 往返用户几乎感知不到中间这一跳服务端压力也小一次请求就是一次 KV 查询加一次重定向。它的短板是拿不到太多用户侧信息跳转发生之后你就失去控制了。中间页的优势是可控性强你能在页面上做加载动画、做微信环境判断、做授权引导、做异常提示甚至能在跳转前先采一轮数据。代价是多一次页面加载弱网环境下体感明显变慢而且页面本身也可能被拦截或加载失败。我的实际选择是默认走 302只在明确需要用户交互的场景才启用中间页。比如目标是微信小程序的 scheme 链接某些环境下直接跳会失败这时候才需要中间页做降级引导。这个默认值定下来之后系统复杂度会下降一大截。2.3 数据模型四张表撑起全部业务不管用什么数据库核心模型就四张表理解这四张表的关系整个系统就通了。表作用关键字段量级预估码表一个码一行存短码和全局配置short_code、strategy、status、fallback_url、version万级到十万级目标表一个码对应多个落地目标code_id、url、weight、enabled、条件表达式码数的 1 到 10 倍规则表分流条件与优先级code_id、priority、match_type、match_value码数的 0 到 5 倍扫描日志每次扫码一行code_id、ts、ip_hash、ua、target、cost_ms日增百万级以上码表是主表短码上有唯一索引这是整个系统的查询入口。目标表用 code_id 做外键关联一个码挂几个目标取决于分流策略单一跳转就一条A/B 测试就两三条按门店分流可能几十条。规则表是可选的高级能力简单场景可以先把规则揉进目标表的条件字段里。扫描日志一定要单独放最好按月分表或者直接写进时序库因为它跟配置数据的读写特征完全相反——配置是读多写极少日志是纯写。实操心得日志表千万别和码表放同一个库实例。我见过一个项目日志写入把磁盘 IO 打满导致配置查询也变慢最后跳转超时。物理隔离或者至少逻辑隔离这是底线。3. 核心细节短码、跳转、规则、埋点这四件事怎么做对3.1 短码生成别用随机用发号器短码是整个系统的钥匙它的设计直接决定了容量和冲突风险。常见字符集是 62 个0-9、a-z、A-Z长度 6 位的话空间是 62 的 6 次方约 568 亿这个量级对绝大多数业务来说几十年用不完。关键在于怎么生成。随机生成 查重是最容易想到的做法但它有个隐患随着存量码增多碰撞概率按生日悖论上升。算一下当已发码量达到 100 万时随机生成一个 6 位码撞车的概率约为 100 万 / 568 亿 ≈ 0.0176%单次看着很低但你是每发一个码就要试一次日发码一万的话平均每天要处理一两次碰撞重试逻辑上必须写重试循环而且高并发下容易出现唯一索引冲突报错。更稳的做法是自增发号 进制转换。数据库自增 ID 或者 Redis 的 INCR 拿一个单调递增的整数再转成 62 进制字符串。这样天然无冲突不用查重性能也好。代价是短码是连续的别人可以通过遍历猜测你有哪些码——如果你的码涉及私密内容需要在短码上加一层混淆比如对 ID 做一次可逆的位混洗再转进制。const ALPHABET 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ; // 把自增 ID 转成 62 进制短码 function encodeBase62(num) { if (num 0) return ALPHABET[0]; let out ; while (num 0) { out ALPHABET[num % 62] out; num Math.floor(num / 62); } return out; } // 位混洗让连续 ID 生成的短码看起来分散 function scramble(id) { // 与一个奇数常量做乘法再取模保持双射 const PRIME 2654435761; const SPACE 56800235584; // 62^6 return (id * PRIME) % SPACE; }这段代码里 scramble 那步是可选的。它的作用是让 1、2、3 生成的短码在字符层面看起来毫无规律但依然是双射不会撞车。如果你的业务对短码可猜测性敏感加上它就够了不要上加密算法那会拖慢每一次跳转。长度选择上6 位是甜点。5 位只有 9.1 亿空间看着够用但如果业务涉及多租户、多环境共用一套短码体系很快就会紧张。7 位以上没有必要短码越长用户手动输入的体验越差虽然扫码场景下用户不输入但客服口述、人工核对的时候长码很烦。3.2 跳转状态码301 和 302 的选择能毁掉整个系统这是活码系统里最容易踩、后果最严重的一个坑。用错了你的可改能力直接归零。301 是永久重定向浏览器和中间各级缓存会把它记死。用户第一次扫码被 301 到 A 页面之后你在后台把目标改成 B这个用户再扫浏览器根本不会来问你直接从本地缓存跳到 A。而且这个缓存可能是几个月甚至永久清都清不掉。用户会认为你们的码坏了而你后台看着配置明明是对的排查到崩溃。302 是临时重定向语义上告诉客户端这次先这么走下次还是来问我。这正是活码需要的语义。307跟 302 类似区别是 307 严格保持原请求方法302 在某些历史实现里会把 POST 降级成 GET。扫码场景全是 GET两者等价我用 302 多一些兼容性最好。app.get(/c/:code, async (req, res) { const rule await loadRule(req.params.code); if (!rule || rule.status ! active) { return res.status(404).type(html).send(FALLBACK_HTML); } const target decide(rule, req); res.set(Cache-Control, no-store, no-cache, must-revalidate); res.set(Pragma, no-cache); res.redirect(302, target); });除了状态码响应头也必须管住。Cache-Control: no-store这一行是强制的不加的话运营商网关、企业代理、部分浏览器都可能自作主张缓存跳转结果。我遇到过一次特别诡异的案例某个办公网络里所有用户扫码都跳到了旧地址查了两天才发现是出口网关缓存了 302 响应。加上 no-store 之后就再没出现过。3.3 规则引擎优先级、命中顺序、兜底一个都不能少规则引擎决定了同一个人扫码为什么去了 A 而另一个人去了 B。设计上把握三条原则基本不会出乱子。第一条条件必须可枚举、可解释。常见的匹配维度就那几个时间区间、地域基于 IP 归属、设备类型iOS/Android/PC、来源渠道短码后面带参数、扫码次数用于灰度。不要设计成可以写任意表达式的引擎那玩意儿上线之后没人敢改出问题也没人看得懂。用结构化的 JSON 描述条件前端配得出来后端也校验得住。第二条优先级必须显式。一个码上可能同时配了营业时间判断和渠道分流如果都命中谁说了算我的做法是给每条规则一个 priority 数字小的先匹配第一个命中的规则决定结果命中后立即短路不再往下走。这比所有规则叠加计算好调试一百倍。第三条必须有兜底。规则没命中、目标全部下线、配置读不到这些情况一定会发生。兜底分两级一级是码级别的 fallback_url一个绝对可用的地址二级是系统级的错误页展示当前访问人数较多请稍后再试这种温和提示。宁可让用户看到一个正常的提示页也不要给他一个浏览器原生的 404 或者 502。{ code: aB3xK9, strategy: weight, fallback: https://example.com/default, rules: [ { priority: 10, type: time, value: { start: 09:00, end: 21:00 }, target: T1 }, { priority: 20, type: device, value: ios, target: T2 } ], targets: { T1: { url: https://example.com/store, weight: 100 }, T2: { url: https://example.com/app, weight: 100 } } }按权重分流的时候有个细节值得说用 IP 做哈希比纯随机好。纯随机会导致同一个用户今天看到 A 版本、明天看到 B 版本做 A/B 测试的时候数据会被污染。用 IP 或者设备标识做哈希取模能保证同一个用户稳定落在同一组测试结论才可信。代价是流量分布可能不够均匀尤其是办公楼这种大量用户共用出口 IP 的场景这时候可以把哈希因子换成 IP User-Agent 的组合。3.4 统计埋点UV 去重和防刷是两件不同的事统计这部分我见过太多项目做得一塌糊涂最后运营拿着报表来问为什么昨天说一千人扫码今天变成八百了。问题出在三个地方。第一日志写入要异步。跳转是用户可感知的路径日志是后台的事绝对不能在跳转链路上同步写数据库。正确做法是丢进消息队列或者本地内存缓冲批量刷盘跳转请求只负责把数据塞进去就返回。这样即使日志系统挂了跳转依然正常只是暂时少数据。第二UV 去重要有明确的窗口。同一个用户一天扫十次算一次还是十次这取决于业务口径但必须在系统里定死。我的做法是用码ID 日期 设备指纹作为去重键写进 Redis 的 HyperLogLog 或者 SET过期时间设成 25 小时覆盖跨天边界。设备指纹用 IP UA 的哈希不采集任何个人身份信息够用且合规。第三防刷和去重不是一回事。去重是统计口径问题防刷是安全问题。有人用脚本一秒扫一万次去重逻辑会把它们算成一个 UV但你的服务器已经被打了一万次请求。所以限流必须做在入口层按 IP 做令牌桶超过阈值直接返回 429 或者一个静态提示页连规则都不用跑。指标计算方式常见坑PV每次请求计数爬虫、预加载会虚高UV设备指纹去重同一 WiFi 下大量用户被合并转化落地页回传事件跨域丢失 referrer分渠道效果短码携带渠道参数参数被中间页吞掉提示微信内置浏览器里部分安卓机型会在页面加载前预取链接导致 PV 比真实扫码高出一截。判断 UA 里是否含MicroMessenger并过滤掉明显的预取特征能把这个误差压下去不少。4. 动手搭一个最小可用活码服务4.1 技术选型和环境准备最小可用版本的选型原则是依赖越少越好能单机跑起来最好。我用 Node.js Express 做 Web 层Redis 做缓存和限流MySQL 存配置。这套组合的部署门槛低本地一条命令就能起。# 依赖清单 npm init -y npm i express ioredis mysql2 qrcode uuidRedis 在这一版里承担三个职责配置缓存、限流计数、UV 去重。MySQL 只存持久化配置。生产环境还要加一层本地内存缓存LRU让热点码的查询完全不打网络这个后面讲。4.2 建表与短码发放先建两张核心表。注意字符集用 utf8mb4短码字段加唯一索引。CREATE TABLE qr_code ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, short_code VARCHAR(12) NOT NULL, name VARCHAR(64) NOT NULL, strategy VARCHAR(16) NOT NULL DEFAULT single, status TINYINT NOT NULL DEFAULT 1, fallback_url VARCHAR(512) NOT NULL, targets JSON NOT NULL, rules JSON NULL, expire_at DATETIME NULL, version INT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_short_code (short_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE scan_log ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, code_id BIGINT UNSIGNED NOT NULL, short_code VARCHAR(12) NOT NULL, ts DATETIME(3) NOT NULL, ip_hash CHAR(32) NOT NULL, ua_type VARCHAR(16) NOT NULL, target_url VARCHAR(512) NOT NULL, cost_ms SMALLINT NOT NULL, KEY idx_code_ts (code_id, ts) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;targets 和 rules 直接存 JSON这一版不做拆表。理由是配置数据量小、读多写极少JSON 字段省掉了关联查询一次 select 就能拿到完整规则跳转链路上少一次 IO。等配置复杂到需要按规则维度做统计的时候再拆也不迟。发码接口的核心逻辑就是拿自增 ID 转短码然后插库。app.post(/api/code, async (req, res) { const { name, fallbackUrl, targets } req.body; const [result] await pool.execute( INSERT INTO qr_code (short_code, name, fallback_url, targets) VALUES (?, ?, ?, ?), [PENDING, name, fallbackUrl, JSON.stringify(targets)] ); const shortCode encodeBase62(scramble(result.insertId)); await pool.execute(UPDATE qr_code SET short_code ? WHERE id ?, [shortCode, result.insertId]); res.json({ id: result.insertId, shortCode }); });先插占位再更新是为了拿到自增 ID 才能算短码。两次写看着笨但对发码这种低频操作完全够用。如果追求一次写入可以用 Redis 预分配一段 ID 空间本地算好短码再一次性入库。4.3 跳转主链路三级缓存与降级跳转接口是整个系统的心脏它必须满足两个要求快P99 控制在 30ms 内、不能挂任何依赖故障都要能降级。const localCache new Map(); // 进程内缓存 const LOCAL_TTL 5000; async function loadRule(shortCode) { const hit localCache.get(shortCode); if (hit Date.now() - hit.t LOCAL_TTL) return hit.v; const cached await redis.get(qr:${shortCode}).catch(() null); if (cached) { const v JSON.parse(cached); localCache.set(shortCode, { v, t: Date.now() }); return v; } const [rows] await pool.execute( SELECT * FROM qr_code WHERE short_code ? AND status 1 LIMIT 1, [shortCode] ); if (!rows.length) return null; const v rows[0]; await redis.setex(qr:${shortCode}, 300, JSON.stringify(v)).catch(() {}); localCache.set(shortCode, { v, t: Date.now() }); return v; }三级缓存的设计意图很明确。进程内缓存挡住绝大部分请求TTL 设短5 秒是为了配置变更能快速生效Redis 挡住跨实例的重复查询TTL 设 300 秒MySQL 是最终真相源只在缓存全空时被打到。这个结构下一个热点码的查询有 99% 以上在内存里就结束了。但缓存带来一个必须解决的问题改了配置怎么让它立刻生效。我的做法是版本号 主动失效双保险。码表里那个 version 字段每次更新加一跳转时如果发现缓存里的 version 比数据库里的小这个比较放在后台任务里做就主动删缓存。同时管理端改完配置后直接redis.del一次。进程内缓存那 5 秒的延迟基本可以接受如果业务要求秒级生效就上 Redis 的 pub/sub 广播失效消息让每个实例清掉自己的本地缓存。至于降级逻辑要写死Redis 挂了直接查 MySQL加个连接池上限保护MySQL 也挂了返回一个静态的兜底 HTML里面放一个默认跳转链接。这条路径永远不会返回 500。4.4 二维码生成与物料导出服务端生成二维码这一步参数选择有讲究。const QRCode require(qrcode); async function genQr(shortCode, opts {}) { const url https://s.example.com/c/${shortCode}; return QRCode.toBuffer(url, { errorCorrectionLevel: H, // 30% 容错中间可放 logo type: png, width: opts.width || 800, margin: 2, color: { dark: #000000, light: #FFFFFFFF } }); }纠错等级选 H30% 容错是因为运营一定要在码中间放品牌 logoH 级别允许遮挡约 30% 的面积还能正确识别。如果确定不放 logoM 级别15%就够了图案会更稀疏远距离扫码更容易。margin 设 2 是指四周留出两个模块的空白这个空白是扫码器识别定位图案的必要条件设成 0 会导致部分老旧设备识别失败。宽度 800px 是印刷和屏幕两用的折中值。屏幕展示用 300px 就够印刷物料建议按实际尺寸换算一般要求码的物理边长不小于 2cm海报上的码不小于 3cm。4.5 压测与容量估算上线前一定要压一遍。我用 wrk 跑命令很直接wrk -t4 -c200 -d60s --latency http://127.0.0.1:3000/c/aB3xK9单机 4 核 8G加了本地缓存之后QPS 跑到 8000 以上很轻松P99 延迟 6ms 左右。关掉本地缓存只走 RedisQPS 掉到 3000 上下P99 涨到 15ms。这两个数字的意义在于你可以据此估算机器数量。容量估算的方法我说一下。假设日扫码量 100 万其中 80% 集中在 8 小时内峰值系数取 3应对突发推广那么峰值 QPS ≈ 1000000 × 0.8 ÷ (8 × 3600) × 3 ≈ 83。这个量级一台机器加一个 Redis 完全扛得住。真正需要担心的不是日常流量而是物料集中上线那一刻的尖峰比如一场直播里主播喊了一句扫码领券30 秒内进来五万人瞬时 QPS 能到 1600 以上。这种情况靠限流 排队页兜住比单纯堆机器划算。5. 排查实录那些让你怀疑人生的线上问题5.1 改了配置不生效的三层排查法这是最高频的问题没有之一。用户反馈我后台明明改了扫出来还是老的排查按这三层往下走基本五分钟内能定位。第一层看响应头。打开浏览器开发者工具看那个 302 响应的状态码和Cache-Control。如果是 301问题就在状态码上客户端缓存了改服务端没用。如果是 302 但带了max-age或者Expires问题在缓存头上。这两个都对往下走。第二层看缓存。直接连 Redis 执行GET qr:短码看里面的 version 和目标地址是不是新的。如果是旧的说明缓存没被清掉检查管理端的失效逻辑是不是漏了或者多实例环境下 pub/sub 消息有没有送达。第三层看数据库。查SELECT * FROM qr_code WHERE short_code ?确认写入真的成功了。我遇到过事务没提交、连接用了只读从库、ORM 缓存没刷新这几种情况都表现为后台显示成功但库里没变。5.2 微信内打开白屏或提示已停止访问微信内置浏览器对跳转链路有一些额外限制症状通常是页面白屏、一直转圈或者弹出提示。排查方向有几个。先确认目标域名本身在微信里能不能正常打开有些域名在微信环境里访问受限这跟活码系统没关系是目标侧的问题。再确认跳转是不是跨域太多次微信对连续跳转的容忍度有限302 套 302 再套 302很容易在某一步断掉。我的经验是把跳转链控制在两跳以内绝不搞链式跳转。还有一个隐蔽的坑部分安卓机在微信里对 302 到非 HTML 资源比如直接跳到图片、PDF处理异常。如果目标是群二维码图片不要直接 302 到图片地址而是返回一个极简的 HTML 页面页面里用img标签加载图片。用户体验一样兼容性天差地别。5.3 扫码统计对不上账运营说系统显示一千次扫码实际后台只收到八百个注册差的两百去哪了。这个问题要先分清是统计问题还是转化问题。如果落地页是外部系统中间那一跳会丢失 referrer你得在跳转时把渠道参数拼到目标 URL 上让对方系统自己记录。如果目标页是自己的检查页面加载失败率——弱网下 302 之后的页面加载失败扫码日志已经记了但用户什么都没看到。最后一种情况是预加载某些浏览器和微信会提前请求链接产生幽灵 PV这在报表上体现为扫码量高但停留时长为零的记录特别多。现象最可能原因处理动作改了目标不生效301 被客户端缓存改 302加 no-store部分用户跳旧地址出口网关缓存响应头强制 no-store微信内白屏链路过长或目标受限压到两跳内换 HTML 中转扫码量虚高预加载/爬虫UA 过滤 IP 限流偶发 502配置库连接耗尽加本地缓存 连接池上限短码 404发码事务未提交检查事务边界与主从延迟5.4 短码被遍历的风险如果短码是连续自增生成的理论上别人可以从000001开始一路扫下去把你所有的码都摸出来。这个风险等级取决于你的内容敏感性。低敏感的公开活动码被遍历了也无所谓反而多几个访问。高敏感的码比如一对一的服务预约、内部文档入口就必须做防护。我的做法有两层一是在短码上加位混洗让它不可预测二是给短码加一个校验位非法短码在入口层就被拒掉根本不会打到数据库。校验位的做法很简单取短码字符的哈希模一个质数拼在末尾验证时重算一遍对比即可。注意任何防护手段都不能替代权限校验。如果短码指向的是私密内容落地页本身必须有登录态校验活码只负责指路不负责开门。6. 高并发下的两个生死线缓存一致性 and 防刷6.1 缓存一致性宁可短暂不一致也不能不一致到用户分布式缓存最难的不是怎么存是什么时候删。经典的先更新数据库再删缓存在活码场景下有个变种问题并发读写的时候读请求可能把旧值又写回缓存。我的处理原则是接受秒级不一致拒绝分钟级不一致。具体做法是更新数据库时把 version 加一删除缓存读请求回源时带上 version 条件写入如果发现要写回的 version 比缓存里已有的旧就放弃写入。这个逻辑不复杂但能挡住 99% 的脏数据回填。另外本地缓存那 5 秒 TTL 是刻意的妥协。它意味着配置变更最坏情况下 5 秒后生效换来的是绝大部分请求完全不碰网络。如果业务真的要求立即生效就把 TTL 降到 1 秒或者用 pub/sub 主动清代价是每个实例都要维护一条订阅连接。6.2 防刷限流要做在离用户最近的地方限流的层次我一般做三层。第一层在网关或者 Nginx按 IP 做粗粒度限流每秒 20 次超了直接返回 429这一层挡住绝大多数脚本。第二层在应用内按短码做维度限流防止单个热门码被打爆影响全局用 Redis 的滑动窗口实现。第三层是行为识别同一个 IP 在极短时间内扫遍几十个不同短码这是典型的遍历行为直接进黑名单一段时间。limit_req_zone $binary_remote_addr zoneqrlimit:10m rate20r/s; server { location /c/ { limit_req zoneqrlimit burst40 nodelay; limit_req_status 429; proxy_pass http://qr_backend; } }burst 设 40 是为了容忍正常的突发比如一个会议室里几十个人同时扫码。nodelay 表示突发部分立即处理不排队避免用户等待。这个配置我用了很久误伤率很低。还有一点容易被忽略限流返回的内容也要设计。直接返回 Nginx 默认的 429 页面用户看到一堆英文体验很差。配一个自定义错误页写一句当前访问人数较多请稍后重试再放一个手动重试按钮观感完全不同。6.3 日志写入的量级控制日扫码百万级的系统日志表一年就是三亿多行不处理肯定出问题。三个手段配合用。按时间分区或者分表按月切历史表只保留聚合结果。写入走批量本地攒够 200 条或者满 1 秒刷一次把单条 insert 变成批量 insert写入效率能提升一个数量级。明细日志保留 90 天足够排查问题更早的数据只留按天聚合的统计结果。这么一套下来磁盘占用和查询压力都可控。7. 我在实际项目里踩过的几个坑说几个文档里不会写、但真会让人加班的细节。第一个坑短码大小写敏感。MySQL 默认的排序规则utf8mb4_general_ci是不区分大小写的这意味着aB3xK9和ab3xk9会被认为是同一个码。如果短码里同时用大小写字母查询会命中错误的记录。解决办法是把短码字段的排序规则改成utf8mb4_bin或者在应用层统一转成小写再存。我倾向前者语义更清晰。第二个坑二维码的边距不能省。有次设计把码裁得只剩图案本身四周白边全切了结果一部分老款安卓机扫不出来。二维码四周的静区是识别定位图案的必要条件至少留两个模块宽度这个不能妥协。第三个坑iOS 的智能推荐会预取链接。短信、备忘录里的链接iOS 可能会提前请求一次导致你收到一条扫码记录但实际没人扫。判断依据是 UA 里没有真实设备特征、请求没有 Referer、耗时极短。这类请求在统计时按特征过滤掉别让它污染数据。第四个坑别在跳转链路上做任何同步的外部调用。我见过一个实现跳转之前先调一次第三方接口做设备识别结果第三方接口抖动整个活码系统跟着超时。跳转链路上只能有内存查询和缓存查询任何外部依赖都必须异步化或者前置。第五个坑管理端和跳转端要物理隔离。配置修改、报表查询这些操作会消耗数据库连接和 CPU跟跳转抢资源。条件允许的话管理端单独一套服务、单独一个只读从库跳转端只保留最必要的读权限。这个隔离做完之后管理端跑一个全量报表也不会让用户扫码变慢。最后分享一个我一直在用的自检清单每次上新码之前过一遍状态码是不是 302、响应头有没有 no-store、fallback_url 是不是一个真的能打开的地址、规则优先级有没有冲突、短码字段的排序规则是不是 bin、日志写入是不是异步的。这六项都过上线基本不会出大事。真正让活码系统翻车的从来不是架构多复杂而是这些最基础的地方少做了一步。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑