
1. 并发同一个二维码被扫爆之后服务端发生了什么做扫码类工具之前我对并发的理解停留在接口能被压测工具打穿这个层面。真正上线后我才发现扫码场景的流量模型跟普通网站完全不是一回事。普通网站是慢慢爬坡扫码活动是主持人喊一句大家扫一下屏幕上的码然后所有人同时掏手机、同时打开页面、同时建连接流量是一根垂直线根本没有给你自动扩容的时间窗口。我做的这个多人扫码网页娱乐工具玩法不复杂场地里贴一个二维码用户扫码进入同一个 H5 房间可以参与抢答、抽奖、上墙弹幕。技术栈是 Vue 3 Node.js Redis Nginx服务部署在一台云服务器上。上线前我只在内部用十来个人测过当时觉得一切正常。结果第一场真实活动的流量一进来最先扛不住的居然不是业务代码而是最基础的连接层和数据写入层这两个坑都属于并发问题。1.1 坑一所有人掐着时间建连Nginx 先撑不住了第一场活动开始后大概十几秒我的手机就开始连续弹告警。登录服务器一看Nginx 错误日志在疯狂刷worker_connections are not enough同时报Too many open files。用户侧的表现是扫码后一直转圈、白屏过一会儿手动刷新又能打开但过几秒又卡住体验非常差。先说原因。一个用户扫码进入 H5 房间不是只发一个请求而是会做三件事拉取页面静态资源、调用活动配置接口、建立 WebSocket 长连接。注意最后这一步我是设计成进入房间就建立长连接并保持的用户不退出房间这个连接就不释放。现场几百人同时扫码瞬时并发的是几百个页面请求 几百个接口请求 几百个长连接量级一下子被放大。Nginx 默认每个 worker process 的连接数上限是 1024如果 worker_processes 没调好总连接数很快就被占满了再加上 Linux 系统默认的文件描述符限制socket 也开不出来。我的排查链路是这样的先看 Nginx 错误日志定位到连接数问题再用ss -s查看系统连接状态发现 ESTABLISHED 数量是垂直上升的TIME_WAIT 也堆了一大片基本确认就是短时间建连请求太多 长连接不释放叠加导致的。确认问题后第一件事是快速止血调大 Nginx 的worker_connections把系统ulimit -n和进程的文件描述符上限调高。但这里我要多说一句调大参数只是把水管加粗如果进水的速度远大于排水速度迟早还是会爆。所以真正治本的是两件事。第一前端在扫码进入后加一个 1 到 3 秒的随机延迟。用户对这个延迟几乎无感因为扫码后本来就需要 loading但这几秒能把建连请求均匀打散错开流量尖峰效果立竿见影。第二后端对房间容量做控制房间人数达到阈值后新用户先进入一个等待页不建立 WebSocket等房间内有人退出或扩容后再自动进场这样长连接数就不会无限增长。顺带说一下压测。当时我用 JMeter 做了多轮压测重点验证这套方案到底能承受多少人同时扫码。刚开始我也不知道并发数怎么确认才科学后来总结出一个可以复用的方法先单独压最核心的进入房间接口从 20 个线程开始逐步往上加观察 TPS 和错误率的拐点再做全链路压测模拟加载静态资源 调用接口 建立 WebSocket的完整动作看整体吞吐和 P95 延迟。判断标准是在错误率低于 0.1%、P95 延迟低于 1 秒的前提下系统能承受的峰值并发线程数而不是把系统压到挂掉之前那个数。1.2 坑二抢答和抽奖在并发下多中了一份这个坑比连接数更隐蔽也更致命。活动里设置了一等奖 1 个名额但我后来查中奖记录发现一等奖的获奖记录有 2 条对应两个不同用户。现场当时两个用户都弹出了恭喜中奖场面可以用尴尬来形容。原因拆开其实很经典接口逻辑是先查后写先查询剩余名额如果大于 0 就插入一条中奖记录再扣减名额。两个人同时提交时两个请求都查到了剩余名额等于 1于是都通过了判断各自往下写了一条记录。另一方面我只在前端做了按钮 disabled没有做后端幂等控制用户快速连点两次时第二次请求其实已经发出去了。前端防抖只是让用户看不到第二次点击并不能阻止请求到达服务器。这类并发写问题标准解法是三层一起上第一层数据库唯一约束兜底。中奖记录表加(activity_id, user_id)唯一索引如果同一个用户重复插入数据库会直接报错这是最底层的防线。别嫌它笨真出问题时它是最可靠的保险。第二层Redis 原子操作。名额扣减不要放在业务代码里用查询再扣减而是放到 Redis 里用DECR或 Lua 脚本完成。Redis 单线程执行命令天然避免并发写冲突扣减后如果返回值小于 0说明名额已经没了。这个方案我后来一直留着。第三层请求幂等。前端每个请求带一个requestId由时间戳加随机数生成后端在处理前先查这个requestId是否已经处理过处理过就直接返回上次的结果不再重复写入。这套机制对抢答、抽奖、点赞这类高频提交场景都适用。第三个经验是并发控制一定要分层。网络层限流、应用层原子操作、数据库层约束每一层都有各自的职责不要指望某一层能解决所有问题更不要依赖用户不会快速点两次这种理想假设。演示环境永远只有一两个人点真实活动现场全是连点狂魔。2. SEO搜索流量进不来分享卡片也不显示做扫码工具的人容易有一个思维定式觉得这类产品靠二维码和私域传播不需要 SEO。我一开始也是这么想的直到上线一周后用site:域名查询收录情况发现只有首页一条记录活动页一个都没收录。更直接的影响是把活动链接发到微信或者钉钉群里时消息卡片光秃秃的只有一串 URL没有标题也没有缩略图打开率肉眼可见地低。这让我意识到扫码娱乐工具的 SEO 不止是搜索引擎收录的问题。分享到社交平台时链接能不能正确展示标题和缩略图本质上也是爬虫抓取问题只不过抓取方从百度变成了微信、钉钉这类平台的爬虫。如果 meta 信息不对流量入口就会直接断掉。2.1 坑三活动页首屏是空壳搜索引擎一个都不收录当时我的页面是用 Vue 写的 SPA构建后的 index.html 基本只有一个div idapp/div页面里所有内容都是 JS 在浏览器端渲染出来的。搜索引擎的蜘蛛在抓取时出于成本考虑并不会完整执行所有 JavaScript有些第三方爬虫甚至完全不执行 JS。结果就是爬虫访问每个活动页时看到的都是一个空壳没有标题、没有描述、没有正文自然也就不会收录。我用搜索引擎的抓取诊断工具看过返回的 HTML 里连一个活动名字都没有全是空的 root 节点。这里需要说明为什么我没用 SSR。对扫码娱乐这类工具来说互动逻辑非常重真正实时渲染的是房间状态、抢答倒计时、抽奖结果这部分逻辑做 SSR 成本很高而且没必要。我需要做的只是把活动介绍这个静态信息给到搜索引擎和社交爬虫互动部分仍然留给前端。我最后用的方案是轻量服务端注入。活动页 URL 是/activity/:id的形式Node 中间件根据:id从 Redis 缓存里查出活动标题、简介、时间和封面图直接拼装到返回的 HTML 模板里包括title、meta description、og:title、og:description、og:image。这样蜘蛛拿到的 HTML 是有内容的用户实际打开后看到的前端页面也不受影响。如果你不想动服务端代码还有一个更省事的兜底方案给每个活动页生成一个独立的静态介绍页内容是活动标题、规则、时间和入口按钮按钮指向真正的互动 H5 页面。搜索引擎收录静态页用户点击进入互动页。这个方法一小时内能搞定对个人小项目尤其友好。但有一个原则要记住不管是预渲染还是服务端注入meta 信息和首屏内容必须在 HTML 返回时就已经存在不能依赖前端脚本动态修改。爬虫不执行脚本它只看原始 HTML。2.2 坑四Hash 路由让活动链接变成了裸链接这个坑跟路由模式有关。我最初为了省事给活动页用了 hash 路由链接长这样https://xxx.com/#/activity/123。结果就是把这个链接发到微信里卡片完全不带标题和缩略图搜索引擎把所有活动页都当成同一个 URL 处理只收录首页。原因很直接URL 里#后面的内容不会发送到服务端服务端收到请求时看到的永远只是https://xxx.com/自然不知道用户访问的是哪个活动也就没法返回对应的 meta 信息。社交平台爬虫抓取链接时同样拿不到具体活动的数据。就算前端在router.afterEach里动态改了document.title爬虫也执行不到这一步。这个问题的修复分成几步一是把路由从createWebHashHistory改成createWebHistory让 URL 变成干净的https://xxx.com/activity/123。二是 Nginx 增加一条配置保证用户直接访问/activity/123或者刷新页面时不 404location / { try_files $uri $uri/ /index.html; }三是每个活动页在服务端返回对应的 meta 信息这块跟坑三的解法是同一套逻辑。四是加canonical标签指向活动页的规范地址防止同一个活动被多个 URL 收录导致权重分散。改完之后活动链接在微信和钉钉里分享能正常显示标题、描述、缩略图搜索引擎的收录也陆续恢复了。这个坑当时让我意识到分享链接的体验其实是这类工具最重要的传播路径很多用户不会把二维码转发给别人他们看到的是一段文字配一个链接如果链接像裸奔一样没有信息别人根本不知道点进去是什么。3. PWA加分项差点变成减分项的两个缓存与兼容坑做这个工具时我觉得加 PWA 是妥妥的加分项用户扫码后可以添加到主屏幕下次从桌面图标直接进入更像一个 AppService Worker 能缓存静态资源二次打开速度快很多。但 PWA 的本质是对缓存的精确控制控制不好就会出现两个问题一个是永远更新不了的旧页面另一个是 iOS 上装了跟没装一样的割裂体验。3.1 坑五Service Worker 把接口数据缓存成了永久版这个坑出现得很隐蔽。第一场活动结束后我修改了房间名称、题目和奖品配置重新发布。结果那些通过 PWA 打开的老用户看到的还是旧房间名、旧题目、旧奖品。他们在地址栏里手动刷新也无效必须清理站点数据才能恢复。如果用户根本不知道有清站点数据这个操作那就相当于永远被困在旧版本里。问题出在 Service Worker 的缓存策略上。我当时为了追求二次打开秒开把所有 fetch 请求都接入了缓存逻辑包括/api/room/:id这类动态接口而且用的还是 CacheFirst 策略——先查缓存有就直接返回没有才走网络。这等于把活动数据缓存成了永久版。更麻烦的是sw.js 文件本身被 Nginx 设置了长缓存导致用户拿到的是旧版本的 Service Worker旧版本 SW 又继续执行旧缓存逻辑于是问题形成了一个闭环。正确的做法是给缓存策略做分级带 hash 的静态资源比如app.4f6a1e.js、chunk.2d3c8f.css用 CacheFirst文件内容变了 hash 跟着变缓存不会污染。HTML 文档和活动入口页面用 NetworkFirst优先走网络网络失败才用缓存。动态接口数据用 NetworkFirst 或者干脆不缓存确保用户每次进入都能拿到最新的活动配置。同时sw.js 文件本身的响应头要设置Cache-Control: no-cache保证浏览器每次都去服务器检查有没有新版本的 SW。在 SW 内部我用版本号管理缓存名检测到 SW 更新时调用self.skipWaiting()和clients.claim()让新版本尽快接管页面。这里有个调试技巧开发时在 Chrome DevTools 的 Application 面板里勾选 Update on reload可以让每次刷新都重新检查 SW。我之前没勾的时候改完 SW 代码经常测不出效果一度以为是代码写错了浪费了不少时间。3.2 坑六iOS 上添加到主屏幕之后打开的仍是普通网页Android 上测试 PWA 一切正常但 iOS 一起问题。用户在 Safari 里点分享-添加到主屏幕桌面确实生成了图标点开后却发现顶部有地址栏、底部有工具栏完全没有独立 App 的样子。而且更头疼的是这个桌面版 PWA长时间不更新永远停留在用户第一次打开时的版本。iOS 的 PWA 支持一直很别扭。manifest里的display: standalone在某些 iOS 版本上不生效或表现不稳定Safari 对 Service Worker 的更新策略比 Android 保守得多用户回到主屏打开 PWA 时iOS 可能不会主动向服务器请求最新的 sw.js所以版本更新滞后是非常常见的问题。我的处理原则是降级 引导 不依赖。降级是指 PWA 只作为高频用户的增强项不做成核心路径。扫码后默认就是普通 H5 使用PWA 安装不上也不影响任何核心功能。引导是指检测到用户在 iOS Safari 里且当前不是 standalone 模式时弹一个添加到主屏幕的使用引导层一步步告诉用户怎么操作。这个引导层我会等用户完成一次互动后再出现避免一进来就打断体验。不依赖是对 iOS 端做更保守的缓存策略关键接口完全禁用缓存静态资源也倾向 NetworkFirst绝不让用户长期看到旧版本。同时在应用启动时调一个/api/version接口做版本检查发现版本落后就提示请完全关闭应用后重新打开。还有一点容易被忽略很多用户扫码后其实是在微信内置浏览器里打开的微信对 PWA 的支持非常有限manifest 和 SW 基本不生效。所以这类工具的核心功能必须在不依赖 PWA 的情况下也完整可用PWA 是锦上添花不能是雪中送炭的必需品。4. 打包脚本发布流程里最不起眼的两个定时炸弹前面三个方向的坑至少都是线上运行时的显性问题能感知到。打包脚本的坑不一样它发生在发布环节不会在你的页面里报错但会在你最想快速发版本的时候突然卡你一下。我这次遇到的两个问题一个让用户在发布后反复加载旧代码一个让发布脚本从本地换到服务器直接失效。4.1 坑七CDN 缓存和构建指纹没对齐线上反复加载老代码这个坑的表现非常迷惑。我发布完新版本后用无痕窗口验证显示一切正常可同事在正常浏览器里打开控制台加载的还是几天前的 JS 文件名。我一度以为是发布没成功重复跑了几遍流程问题依旧。后来检查 CDN 控制台才发现index.html 被 CDN 缓存了线上返回的 HTML 里引用的仍然是旧版本的 JS 文件跟服务器上构建产物新不新完全无关。拆开看原因就清楚了静态资源打包后带内容指纹比如app.4f6a1e.js文件内容变了指纹就变这部分没有问题。问题全出在 index.html 上它本身没有指纹如果 CDN 对 text/html 类型做了哪怕几分钟的缓存用户拿到的就一直是旧 HTML。旧 HTML 引用旧 JS新版本自然永远不会出现在用户面前。我当时的发布脚本只负责上传构建产物、刷新静态文件目录完全没有刷新 index.html 的 CDN 缓存等于发布了一半。修复方式分四步第一步给 index.html 的响应头设置Cache-Control: no-cache让它每次回源验证不能直接命中 CDN 缓存。注意 no-cache 不是不缓存而是使用前必须确认有效性。第二步在 CDN 配置里把 HTML 的缓存策略改成遵循源站或者直接设成 0 秒缓存。第三步在发布脚本里增加缓存刷新阶段上传完构建产物后调用 CDN 的 OpenAPI 主动刷新 index.html、sw.js 以及所有活动页 HTML。第四步发布完成后加一个版本冒烟验证用命令行请求线上地址检查返回的 HTML 里引用的 JS 文件指纹是否与刚构建产物的指纹一致不一致就直接报警。这里还有一个连带坑如果你的项目同时用了 Service Workersw.js 文件的缓存也必须按同样的思路处理否则会出现一种死循环——SW 控制着页面的缓存策略但 sw.js 本身被 CDN 缓存了用户永远拿不到新版本的 SW于是永远用旧策略控制页面。这两个文件是发布脚本里最需要重点关照的对象。4.2 坑八脚本在本地正常服务器上一执行就报错最后一个坑发生在我把 release.sh 从本地挪到服务器时。在 macOS 上我把整个发布流程写成脚本本地执行一路绿灯上传产物、刷新 CDN、检查版本全部正常。结果部署到一台 CentOS 服务器上执行时直接报syntax error near unexpected token {。我以为是简单的语法问题改完又遇到路径找不到、环境变量为空整个发布脚本在服务器上成了一个个要现补的洞。原因很典型。脚本第一行写的是#!/bin/sh但服务器上的/bin/sh实际是 dash不是 bash。dash 不支持 bash 的数组语法比如arr(a b c)这种写法直接语法错误。这只是表面问题后面还有三颗暗雷一是脚本里用了大量相对路径比如rm -rf ./dist。本地在项目根目录执行没问题服务器上 CI 的工作目录可能是完全不同的路径一执行要么找不到目录要么更危险指向了错误的位置。二是 Node 版本不一致。本地是 Node 18服务器上是 Node 14构建出的代码在语法转换上存在差异上线后偶尔报错非常难排查。三是环境变量缺失。发布脚本里要调 CDN 刷新接口token 我直接写在脚本里本地没问题但服务器上的执行环境根本没有这个变量发布到一半才发现刷新接口返回 401回滚也不是继续也不是。修复方式也是几条实战经验第一脚本统一用#!/bin/bash不要把 bash 脚本用 sh 方式执行。在 CI 配置里也显式调用bash release.sh避免不同系统的默认 shell 差异。第二所有路径都要基于脚本所在目录换算。脚本开头先执行cd $(dirname $0)/..以后所有的相对路径都从项目根目录出发这样不管从哪个目录调用都能稳定运行。第三复杂逻辑不要用 Shell 的一堆 grep/sed 去处理尽量写成 Node 脚本。Node 对路径处理、JSON 解析、HTTP 请求的支持比 Shell 可靠得多跨平台、跨 shell 的行为也一致。第四固定 Node 版本。package.json 里写engines字段仓库里放 .nvmrcCI 里按指定版本安装服务器上如果无法升级就在脚本开头检查node -v版本不对直接退出并提示。第五发布密钥通过环境变量注入脚本开头做空值检查缺失就立刻报错退出绝不能发到一半才发现凭据不对。最后还有一个很容易被忽略的原则发布脚本必须幂等。它可以被重复执行且每次执行的结果一致。否则线上出问题的时候你不敢再跑一遍那才是发布流程里最让人焦虑的状态。回头看这 8 个坑真正难解决的技术点其实不多大多是提前没有按真实场景验证造成的。扫码工具天然带有瞬间流量、长期运行、链接被到处转发这几个特性这决定了你必须提前想清楚并发峰值、SEO 元信息、缓存策略和发布一致性。如果你也在做类似的东西建议在开发阶段就把这几项列进自测清单用压测工具模拟扫码尖峰、给活动页做服务端注入 meta、在 iOS 真机上验证 PWA、发布脚本加上缓存刷新和版本校验。每一件事单独看都不难但它们叠加起来就是上线后顺利跑完和全程救火的分水岭。