做小程序这几年,被问得最多的一个技术问题不是性能也不是框架,而是"那个圆形的码怎么生成"。这里说的圆形码就是小程序太阳码,配合二维码生成这套能力,几乎每个带线下场景、带渠道推广、带分销裂变的小程序都会用到。它看着只是一张图,背后却牵扯到凭证管理、接口调用、参数压缩、二进制流处理、对象存储、缓存去重一整条链路。我见过太多团队在这上面栽跟头:接口返回一堆乱码 JSON 却当成图片存了进去、scene 参数超长被截断导致扫码进错页面、同一张码重复生成把存储费用堆到离谱。这篇就把从配置到存储的完整实现拆开讲,包含参数计算、代码实现和一堆只有踩过才知道的坑,适合已经能写业务代码但还没系统做过图片生成链路的开发者,也适合想搞清楚太阳码到底怎么用的小程序新手。
1. 先把几种码分清楚,别在选型上返工
1.1 太阳码、小程序码、普通二维码的形态差异
很多人把"太阳码"和"小程序码"混着叫,其实在平台的能力体系里,带参数进入小程序的码有好几种,形态和能力各不相同,选错了后期返工成本很高。
太阳码是民间叫法,指的就是那种圆形、中心带小程序头像、外圈是放射状点阵的码。它的正式身份是"小程序码",由平台服务端接口生成,扫码后直接拉起小程序并跳转到指定页面。因为造型像太阳,做线下物料的同学就这么叫开了。
普通二维码就是我们熟悉的方形码,任何扫码工具都能识别。如果想让普通二维码也能打开小程序,需要走"普通链接二维码打开小程序"的配置流程,在后台把某个链接前缀和小程序页面做映射,然后用这个链接去生成二维码。这条路的优势是可以用任何第三方二维码库生成,完全不受平台接口限制,劣势是配置繁琐、规则变更需要后台操作、而且对链接前缀有归属校验要求。
小程序码接口本身又分两个:一个走 path 参数、带参数路径,生成数量有上限;一个走 scene 参数,数量无上限但 scene 长度被死死卡在 32 个可见字符。绝大多数业务场景应该用后者,因为前者那十万级的数量上限在分销、一人一码这类场景下根本不够用。
三者最直观的差别在于:太阳码只能被平台自己的扫码入口识别,你用普通的扫码 App 去扫,大概率只会看到一段无法解析的内容;而普通二维码是通用标准,任何扫码器都认。这个差异直接决定了你的落地场景——做户外广告、做包装印刷,如果用户可能用手机自带相机扫,那普通二维码的兼容性更好;如果是小程序内部长按识别、或者在平台生态内传播,太阳码的视觉识别度和品牌感更强。
1.2 能力边界对比表与选型建议
把关键差异拉成一张表,选型的时候对着看就行。
| 维度 | 小程序码(太阳码) | 普通二维码 |
|---|---|---|
| 生成方式 | 平台服务端接口 | 任意二维码库本地生成 |
| 数量限制 | scene 版无上限,path 版约十万级 | 无限制 |
| 参数容量 | scene 最多 32 个可见字符 | 取决于纠错等级和版本,容量大得多 |
| 扫码入口 | 平台内扫码、长按识别 | 任意扫码工具 |
| 视觉表现 | 圆形,可透明底,品牌感强 | 方形,可加 logo,通用 |
| 生成依赖 | 必须服务端调用,需要 access_token | 完全离线,前端也能生成 |
| 失败排查难度 | 高,返回二进制和 JSON 混在一起 | 低,纯本地逻辑 |
选型上有几条我自己的判断标准,实测下来比较稳:
- 用户全程在平台生态内流转(分享卡片、小程序内长按、公众号文章里贴图),优先太阳码,识别率高、点击转化好。
- 需要印在包装、海报、户外广告上,且用户可能用系统相机扫,老老实实做普通二维码,或者两个都放。
- 需要携带超过 32 字符的业务参数(比如带签名、带时间戳、带多字段),别硬凑太阳码,改用"短码映射 + 服务端查表"的方案,后面第 4 章会详细讲。
- 一人一码、渠道码这种量大且参数简单的场景,闭眼用 scene 版太阳码。
还有一点容易被忽略:太阳码中间那块区域是平台自动放的头像位,你没法自己塞 logo。想要品牌定制感,只能靠line_color调颜色、is_hyaline开透明底,再在外面套一层自己设计的海报底图。这个后面讲合成的时候会提到。
2. 整体链路拆解:为什么要把生成、存储和下发拆成三段
2.1 实时生成和预生成的成本账
新手最容易写出来的代码是这样的:用户请求海报接口,接口内部同步调一次码生成接口,拿到 buffer 直接返回给前端。本地跑没问题,一上线就崩。原因有三层。
第一层是接口耗时不可控。码生成接口是跨公网调用,正常两三百毫秒,网络抖动或者对方限流的时候三五秒都回不来。用户端等三秒看一张白图,体验直接崩。而且这个调用是同步阻塞的,你的 Node 进程会被这个 await 挂住,QPS 一上来线程池就打满了。
第二层是额度浪费。同一批用户、同一个渠道、同一张海报,参数完全一样,码图就是同一张。每次都重新调接口重新生成,纯属浪费。平台侧对这类接口虽然没有明确的一天多少次限制,但短时间高频调用一定会被限流,返回的错误还不一定是标准的,排查起来很痛苦。
第三层是存储和回源逻辑混乱。码图生成出来之后是二进制 buffer,你得存下来给前端一个可访问的 URL。如果每次都重新生成,等于每次都在做一次"生成 + 上传",存储里会堆一堆内容完全相同的文件,CDN 回源也会乱。
所以成熟的做法一定是三段式:生成段负责调接口拿到 buffer 并落盘,存储段负责命名、去重、写入对象存储并返回路径,下发段负责把路径拼成 URL 给到前端,同时挂上缓存。三段之间用"业务 key"串起来,业务 key 可以是渠道 ID、用户 ID、活动 ID 的组合,只要保证同一个业务语义下 key 唯一就行。
这个拆分最大的好处是可以异步化。海报生成、渠道码批量生成这类场景,完全可以把生成动作丢进队列,前端轮询或者走消息通知,用户不用干等。预生成则更进一步,活动上线前把一批码全部生成好存起来,线上只做一次数据库查询和 URL 拼接,响应时间压到个位数毫秒。
2.2 目录与命名的前置约定
在写第一行代码之前,先把存储路径的规则定死,这是后面所有逻辑的基础。
我推荐的规则是:{业务线}/{环境}/{yyyy}/{mm}/{dd}/{业务key前两位}/{业务key}.{ext}。举个具体的,poster/prod/2025/06/18/a3/a3f9c2...b7.jpg。
这么设计的原因很实在。按日期分层,是因为码图有很强的时效性,活动结束之后这批码基本没人访问了,按日期做生命周期清理(比如保留 90 天)比按业务 ID 清理容易得多。按 key 前两位分片,是为了避免单目录下文件数过多——对象存储虽然号称可以放无限多文件,但某些控制台在列举目录的时候会被几十万文件卡死,做数据统计和批量操作会非常难受。带环境前缀是基本素质,不然测试环境的垃圾文件混进生产桶,清理的时候心惊胆战。
扩展名要跟实际内容一致。太阳码接口返回的二进制是 JPEG 格式(即便你开了透明底,返回的仍然是 JPEG,透明区域会体现为白色或者被你的背景色填充),所以扩展名写.jpg。普通二维码库一般返回 PNG,写.png。这个细节看着小,但有些对象存储会根据扩展名决定Content-Type,写错了浏览器里直接变成下载而不是展示。
还有一个约定:数据库里存相对路径,不存完整 URL。完整 URL 里带着域名,一旦换 CDN 域名、加签名参数、切测试环境,就得全表 UPDATE。存/poster/prod/2025/06/18/a3/xxx.jpg,下发的时候用配置的域名前缀拼一下,灵活得多。这个习惯我从第一个项目开始就坚持,后来每次迁移存储服务都省了大量时间。
3. 服务端基础配置:凭证、token 与并发控制
3.1 配置项清单与安全底线
服务端调码接口需要的配置项不多,但每一项都有讲究。最核心的是应用凭证,通常是一对 ID 和密钥。这对东西必须放在环境变量或者配置中心,绝对不能进代码仓库,更不能下发到小程序端——小程序端的代码是能被反编译的,密钥泄露等于把整个应用的控制权交出去。
除了凭证,还需要准备这几个配置:
- 接口域名:平台开放接口的地址,从官方文档里查,不要写死在自己的代码里,用常量或者环境变量包一层,方便切换。
- 对象存储的访问配置:密钥、桶名、区域、访问域名,同样走环境变量。
- 本地临时目录:生成过程中的 buffer 如果需要落临时文件(比如要做图片合成),得有个明确的临时目录,而且要有清理机制。
- 环境标识:用来决定生成的码指向正式版、体验版还是开发版。这个参数在接口里是独立的,忘了传的话,测试环境生成的码扫出来跳到线上,会闹出"测试数据污染生产"的事故。
关于凭证的保管,还有一个经验:按环境隔离应用。正式环境和测试环境用两个不同的应用凭证,各自的码互不相通。这样即便测试环境的密钥不小心泄露了,影响范围也可控。有些团队图省事一个应用跑三个环境,出问题的时候连是哪个环境调的都查不出来。
3.2 access_token 的缓存与刷新实现
调码接口的每一请求都要带 access_token,而这个 token 有有效期,且平台对获取 token 的接口有调用频率限制。这就意味着你绝不能每次生成码都去换一次 token,必须缓存。
缓存的关键点有三个:全局唯一、过期前刷新、刷新时加锁。
全局唯一好理解,一个应用在一个环境里只应该有一个有效 token。过期前刷新,是因为 token 的有效期是给了一段窗口(通常是两小时),但实际失效时间可能因为各种原因提前,所以要在过期前几分钟就主动刷新,留出缓冲。加锁是为了防止并发——如果十个请求同时发现 token 过期,一起去刷新,就会触发频率限制,反而全部失败。
下面是一个可以直接用的实现思路(Node.js),用内存加分布式锁的方式:
const TOKEN_KEY = 'wxa:access_token'; const REFRESH_LOCK = 'wxa:access_token:lock'; const EXPIRE_BUFFER = 300; // 提前 5 分钟刷新 async function getAccessToken() { // 1. 先读缓存 const cached = await redis.get(TOKEN_KEY); if (cached) return cached; // 2. 缓存没有,尝试抢锁 const locked = await redis.set(REFRESH_LOCK, '1', 'NX', 'EX', 15); if (!locked) { // 没抢到锁,等一会儿再读,避免所有请求一起打接口 await sleep(200); const retry = await redis.get(TOKEN_KEY); if (retry) return retry; throw new Error('token 刷新中,请重试'); } try { const res = await fetch( `${OPEN_API}/cgi-bin/token?grant_type=client_credential` + `&appid=${APP_ID}&secret=${APP_SECRET}` ); const data = await res.json(); if (!data.access_token) { throw new Error(`获取 token 失败: ${JSON.stringify(data)}`); } const ttl = data.expires_in - EXPIRE_BUFFER; await redis.set(TOKEN_KEY, data.access_token, 'EX', ttl); return data.access_token; } finally { await redis.del(REFRESH_LOCK); } }这段代码里有几个细节值得说。expires_in拿到之后减去缓冲时间再写入,这是必须的,不然 token 会在你最不希望的时刻失效。锁的过期时间设成 15 秒,比接口超时时间长一点,防止锁一直不释放导致死锁。没抢到锁的请求不是直接失败,而是等 200 毫秒再读一次缓存,这个"等待重试"的写法能把并发尖峰削平,实测下来比直接抛错友好得多。
如果没有 Redis,用进程内单例加 Promise 复用也能凑合,但多实例部署的时候每个实例各自持有一份 token,浪费额度不说,还容易出现某个实例刷新后其他实例的旧 token 失效的诡异情况。生产环境还是老老实实上分布式缓存。
3.3 失效重试与限流的兜底
即使做了缓存,token 还是可能提前失效。常见原因是同应用的另一个服务也在刷 token,把你这边的挤掉了。这时候接口会返回明确的错误码,表示凭证无效。
处理策略是:识别到失效错误码,强制清掉缓存,重新获取一次,并且只重试一次。不要写成循环重试,那会在凭证真出错的时候把接口打爆。
限流的处理更微妙。平台对获取 token 的接口有每日调用上限,对码生成接口也有并发限制。被限流的时候返回的内容往往不是标准的业务错误,可能是一个空响应或者非 JSON 内容。所以你的解析逻辑必须是防御性的:先看 HTTP 状态码,再看响应头里的内容类型,最后才尝试按 JSON 解析。任何一个环节不对,都要当作失败处理,并且把原始响应内容打进日志——注意是打日志不是抛给用户,原始内容里可能包含凭证信息。
我一般的做法是给码生成接口套一层简单的熔断:连续失败超过阈值就快速失败,等冷却时间再放行。这样上游业务至少不会因为下游挂掉而整体雪崩,用户体验上是"稍后重试"而不是转圈转半分钟。
4. 太阳码生成实战:参数逐个拆给你看
4.1 scene 和 page 这两个参数最容易出事
太阳码接口的参数不算多,但scene和page这两个能占掉一半的坑。
page是扫码后落地的页面路径,规则很硬:不能以斜杠开头、不能带查询参数、必须是已经发布的页面路径、默认值是小程序首页。我第一次用的时候写了个/pages/detail/index?id=1,直接报错,排查了半天才想起来参数不该放在这里。
还有一个隐藏问题:如果落地页是 tabBar 页面,扫码进入后底部的 tab 状态可能不对,用户点返回会回到一个莫名其妙的位置。经验做法是给自己的业务页做一层中转,扫码进中转页,在中转页里解析参数再跳转,虽然多一次跳转,但状态干净。
scene是参数载体,限制是最多 32 个可见字符,且只支持数字、大小写字母和一小部分特殊符号。注意"可见字符"这四个字,中文、空格、emoji 全部不行。我见过有人在 scene 里直接塞 JSON,长度超了被静默截断,结果扫码进来的用户全部落在默认参数上,查了一整天才发现问题。
更阴的是长度超限的表现:接口不一定报错,有时候返回的是一张能扫但参数残缺的码。所以长度校验必须在自己这边做,超过 32 直接抛异常,不要依赖接口兜底。
特殊符号的支持范围也窄,常见的! # $ & ' ( ) * + , / : ; = ? @ - . _ ~这些可以,但%和&在某些情况下会因为 URL 编码的二次处理出问题。最稳妥的做法是:scene 里只放字母数字,需要多字段就用分隔符拼,分隔符用_或者-,别用特殊符号。
4.2 完整实现与二进制落地的关键判断
下面是服务端生成太阳码并落地的完整实现,重点看错误判断那段:
const fs = require('fs/promises'); const path = require('path'); async function generateSunCode({ scene, page, envVersion = 'release', width = 430 }) { // 1. 参数自检,别把问题留给接口 if (!scene || scene.length > 32) { throw new Error(`scene 非法,长度必须在 1-32 之间,当前 ${scene.length}`); } if (page && page.startsWith('/')) { throw new Error('page 不能以斜杠开头'); } const token = await getAccessToken(); const res = await fetch( `${OPEN_API}/wxa/getwxacodeunlimit?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scene, page, width, auto_color: false, line_color: { r: 0, g: 0, b: 0 }, is_hyaline: false, env_version: envVersion, check_path: true, }), } ); // 2. 关键:成功返回二进制图片,失败返回 JSON,必须分开处理 const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json')) { const err = await res.json(); throw new Error(`生成失败: ${err.errcode} - ${err.errmsg}`); } const buffer = Buffer.from(await res.arrayBuffer()); // 3. 再用魔数兜底校验一次,防止 content-type 不准 const isJpeg = buffer[0] === 0xff && buffer[1] === 0xd8; const isPng = buffer[0] === 0x89 && buffer[1] === 0x50; if (!isJpeg && !isPng) { throw new Error(`返回内容不是图片,前 16 字节: ${buffer.slice(0, 16).toString('hex')}`); } return buffer; }这段代码的核心是那个内容类型判断。这是新手最容易踩的坑:接口成功时返回的是图片二进制,失败时返回的是 JSON 结构,两者共用同一个 HTTP 200 状态码。如果你不判断,直接把 buffer 存成.jpg,存进去的就是一段 JSON 文本,前端加载出来是破图,后端日志里啥也看不出来。
我在判断上加了两层保险:先看响应头的内容类型,再用文件头魔数校验。JPEG 的魔数是FF D8,PNG 是89 50。双保险的原因是某些中间层(比如你自己的网关、某些 HTTP 客户端)可能会改写响应头,只看头部会漏判。魔数判断是最后的底线,只要不是图片就抛错,绝不让脏数据进存储。
参数上再补几个说明。width的取值范围有上下限,默认值一般在 430 左右,做海报合成的时候建议给到 430 到 600 之间,太小了放大失真,太大了文件体积暴涨,而且太阳码本身是矢量风格的图形,分辨率高到一定程度收益递减。auto_color和line_color控制线条颜色,auto_color打开是让平台自动配色,实际出来的颜色有时候和品牌色差很远,我一般关掉自动配色,自己指定 RGB。is_hyaline开透明底,适合做合成——但记住前面说的,输出格式可能仍然是 JPEG,透明区域会变成白色,做合成时背景要用白色或者浅色,别用深色底,不然会看到一大块白。
4.3 参数超过 32 字符怎么办:短码映射方案
业务参数一旦复杂起来,32 个字符就是硬墙。常见的解法是短码映射:服务端生成一个短 ID(比如 8 位 base62 字符串),把完整的业务参数存到数据库或者 Redis 里,scene 只放这个短 ID。用户扫码进来,小程序拿到短 ID,调接口换回完整参数。
这套方案的实现要点:
- 短 ID 生成要保证唯一,可以用自增 ID 转 base62,也可以用雪花算法。自增 ID 转码的好处是有序、可反查,坏处是暴露业务量,介意的话加个随机种子打散。
- 映射记录要有过期时间。活动结束之后这条映射就没用了,Redis 里设个 TTL 自动清理,数据库里的记录按活动维度归档,别无限膨胀。
- 换取参数的接口要做防刷。别人拿到短 ID 可以无限次调你的接口换参数,加个简单的频率限制。
- 短 ID 在 scene 里仍然要符合字符集要求,base62 天然满足(纯字母数字),这点比用 UUID 好——UUID 里的横杠虽然也支持,但多了几个字符,浪费宝贵的长度。
还有一种更省事的做法:参数本身就是业务主键。比如渠道分销场景,scene 里直接放渠道 ID 和一个校验位,长度绰绰有余。只有当参数确实多字段、确实需要携带签名的时候,才上短码映射。不要为了炫技把所有场景都做成映射,多一次查询就多一份延迟和故障点。
4.4 小程序端拿到 scene 之后怎么解析
这一步坑也不少。用户扫码进入小程序,参数不会直接出现在options的普通字段里,而是被放在scene这个特殊字段中,而且值是做过 URL 编码的。
所以小程序端的第一件事是解码:
Page({ onLoad(options) { let raw = options.scene || ''; if (raw) { raw = decodeURIComponent(raw); } // raw 可能是 "c_10086" 这种形式,自己按约定拆分 const [type, id] = raw.split('_'); this.handleEntry(type, id); }, });有三个细节值得单独说。
一是decodeURIComponent必须调,不调的话带特殊字符的参数会解析错。但要注意别调两次,重复解码会把本来就含有%的合法内容解坏,所以只在有 scene 的时候解一次。
二是兼容普通进入方式。用户可能是从分享卡片、从历史记录、从搜索进入的,这些入口没有 scene,但可能有其他参数。解析逻辑要写成"优先取 scene,没有再看普通参数",否则扫码进来的用户和点链接进来的用户走两套逻辑,很容易出分支 bug。
三是参数校验不能省。scene 的内容是可以被构造的,虽然普通用户改不了,但你不能假设拿到的短 ID 一定在库里。查不到就引导到默认页,别让页面卡在 loading 状态。
5. 普通二维码生成与带 logo 合成
5.1 纠错等级和尺寸的算法
普通二维码是纯本地生成,没有接口依赖,但参数选不对,扫不出来或者扫得慢。
核心参数是纠错等级。二维码规范里有四档,大致对应能容忍的损坏比例:L 档约 7%,M 档约 15%,Q 档约 25%,H 档约 30%。要在二维码中间放 logo,必须用 H 档,因为中间那块被盖住的部分就靠纠错能力补回来。但纠错等级越高,同样内容需要的模块数越多,码就越密。所以我的一般原则是:不放 logo 用 M,放 logo 用 H,内容特别长又必须放 logo 的时候考虑减小 logo 占比。
尺寸的算法更值得展开。二维码的本质是一个 N×N 的模块矩阵,N = 4 × 版本号 + 17。版本号由内容长度和纠错等级决定,比如一段 60 字符的链接在 H 档下大概对应版本 8,也就是 49×49 个模块。再加上四周的静区(quiet zone,一般是 4 个模块宽),总模块数就是 57。
如果你直接指定输出宽度 430 像素,430 ÷ 57 ≈ 7.54,不是整数。渲染器会做插值,模块边缘就糊了,扫码成功率下降。正确的做法是让输出宽度是总模块数的整数倍,比如 57 × 8 = 456 像素,每个模块精确 8 个像素。或者干脆输出 SVG 矢量格式,浏览器渲染时自动对齐,怎么缩放都清晰。
静区这个参数也经常被忽略。有些设计稿为了省地方,把二维码四周的白边裁得干干净净,结果贴在深色背景上就扫不出来。静区的作用是给扫码器定位用,绝对不能裁。库里的 margin 参数默认通常是 4,保持默认就好,除非你有非常明确的理由。
5.2 生成代码与清晰度调优
以 Node.js 为例,用成熟的二维码库,代码很简短,关键是参数:
const QRCode = require('qrcode'); async function generateQr(content, { size = 456, logo } = {}) { const buffer = await QRCode.toBuffer(content, { errorCorrectionLevel: 'H', // 要放 logo 必须用 H type: 'png', width: size, // 尽量取模块数的整数倍 margin: 4, // 静区,别动 color: { dark: '#000000', // 前景色 light: '#FFFFFF', // 背景色,必须不透明 }, }); return buffer; }几个实操提醒。前景色和背景色的对比度要高,深色配浅色最稳,反过来(白码黑底)有些扫码器支持不好,别拿这个冒险。背景色不要用透明,虽然规范里允许,但一旦背景是深色页面,透明区域会显示成页面底色,等于黑码配黑底,直接扫不出来。
如果追求更精细的控制,Python 的二维码库也能做同样的事,思路一致。真正影响清晰度的不是库的选择,而是模块对齐和静区这两个参数。
5.3 中间加 logo 的安全边界
加 logo 是做品牌感的常见需求,但有明确的安全边界。
占比别超过 20%,这是个经验阈值。二维码的纠错能力是按面积算的,H 档能容忍 30% 的面积损坏,但那是在理想条件下。实际印刷、屏幕显示、摄像头对焦都会损耗,留出余量才稳。超过 20% 之后,扫码成功率会明显下降,尤其是低端机和老旧扫码器。
logo 放正中间。因为纠错能力的分布不是均匀的,中心的损坏比边缘更容易恢复,而且三个定位角在三个角落,中间是相对安全的区域。
不要给 logo 加不透明的白底再盖上去,那等于扩大了遮挡面积。如果 logo 本身带底色,先用图片处理把底色改成白色或者和二维码背景一致的颜色,再合成。合成之后一定要自己拿手机扫一遍,别只做代码层面的校验——肉眼看起来没问题的图,在特定屏幕亮度下就是扫不出来。
实现上用图片处理库做一次合成即可,读取二维码 PNG,把 logo 缩放后贴到中心位置,输出新图。记得合成之后再走一遍尺寸检查,别让 log o 的贴图改变了整体尺寸导致模块不对齐。
6. 图片存储落地:命名、去重与读取
6.1 对象存储的目录规划
前面说过路径规则,这里补一下实际写入时要注意的几点。
写入前先确认桶的访问权限。展示类的码图一般需要公开可读,但不要图省事把整个桶设成完全公开。更稳妥的做法是桶私有,通过带签名的临时 URL 访问,或者前面挂一层 CDN 做回源鉴权。签名 URL 的缺点是有有效期,存进数据库就不合适了,所以我的做法是:库里存相对路径,下发时由统一的图片服务生成访问 URL,这样鉴权策略变化的时候只改一处。
上传时显式设置 Content-Type。有些 SDK 会根据扩展名自动推断,有些不推断。不设的后果是浏览器把图片当成application/octet-stream,直接触发下载而不是展示。这个坑非常经典,排查的时候看着 URL 能打开就以为是好的,实际上打开是下载框。
设置合适的 Cache-Control。码图是典型的"一次生成、长期不变"的资源,缓存时间可以设得很长,比如一年。但要注意,如果路径用的是业务 key 而不是内容哈希,同一路径的内容有可能被覆盖(比如活动改了配色重新生成),那缓存时间就要短一些,或者干脆换路径。这就引出了下一个问题:去重。
6.2 幂等与去重
幂等的意思是:同一个业务 key 调用生成接口一百次,存储里只应该有一份文件。
实现上分两层。第一层是查库,业务表里记录了某个 key 对应的图片路径,先查,有就直接返回,没有才走生成。这层挡掉了绝大多数重复请求,但还是有并发问题——两个请求同时查库都是空,然后同时生成,同时上传,最后一份被覆盖。
第二层是内容哈希去重。生成出来的 buffer 算一次哈希,用哈希值作为文件名的一部分或者单独建一张哈希到路径的映射表。太阳码在参数完全相同时生成的图内容是一致的,所以哈希也一致,天然去重。这一层的好处是即便路径不同,相同内容也只占一份存储空间。
还有一个细节:分布式锁或者数据库唯一索引。并发写同一个 key 的时候,用唯一索引兜底,冲突了就回查,这是最省心的方案。比在应用层做锁简单,也不依赖缓存组件。
我实际项目里的做法是:业务表对(业务类型, 业务 ID, 环境)建唯一索引,生成前先 upsert 一条状态为"生成中"的记录,拿到记录之后再去做耗时的生成动作,成功后更新为"已完成"并写入路径。这样并发的第二个请求会直接撞到唯一索引,重试的时候读到"生成中"状态,就等一会儿再查,不会重复生成。这套逻辑写起来比想象中简单,但能省掉大量莫名其妙的重复文件。
6.3 访问地址管理与缓存头
下发 URL 的时候别把路径直接拼死在前端。正确的姿势是后端返回相对路径,或者返回一个已经拼好的完整 URL,前端直接用。如果让前端自己拼域名,一旦换域名,小程序发版审核周期的代价就够你受的。
图片服务这层建议做三件事:统一前缀、按需裁剪、访问日志采样。统一前缀是为了切换方便;按需裁剪是因为同一张码图在列表页要小图、在详情页要大图,原图直出浪费带宽;访问日志采样能帮你发现哪些码从来没人扫过,做清理决策的时候有数据支撑。
缓存头这边,我之前踩过一次坑:为了"保险"把 Cache-Control 设成了 no-cache,结果每次请求都回源,CDN 形同虚设,带宽费用翻了好几倍。后来改成public, max-age=31536000, immutable才正常。immutable 这个指令告诉浏览器"这个 URL 的内容永远不会变",配合内容哈希命名是最佳组合。
7. 常见问题与排查速查
7.1 高频报错与定位路径
把最容易出问题的现象整理成一张表,出问题的时候对着看。
| 现象 | 最可能的原因 | 排查方法 |
|---|---|---|
| 生成的"图片"打不开 | 存进去的是 JSON 错误响应 | 检查 buffer 前两个字节是不是图片魔数 |
| 扫码后跳转到首页而不是目标页 | page 参数格式错误或页面未发布 | 检查是否以斜杠开头、是否带参数、是否已发布 |
| 扫码进来参数丢失 | scene 超长被截断或含非法字符 | 打印 scene 长度和原文,只保留字母数字 |
| 接口返回凭证无效 | token 被其他服务挤掉或提前过期 | 清缓存重新取,只重试一次 |
| 偶发限流错误 | 并发太高或无缓存频繁换 token | 加分布式缓存和熔断 |
| 图片模糊扫不出 | 输出尺寸不是模块数整数倍 | 计算模块数后取整数倍宽 |
| 深色背景下扫不出 | 静区被裁或背景透明 | 保留 margin,背景设不透明浅色 |
| 存储费用异常增长 | 重复生成,未做幂等 | 查重复路径和内容哈希分布 |
7.2 几张踩坑记录
有几个坑我觉得值得单独拎出来说,因为文档里基本不会写。
坑一:透明底配深色海报。开了透明底生成太阳码,以为能完美叠加到深色海报上,结果导出的 JPEG 把透明区域填成了白色,叠上去一块白斑。这是因为输出格式本身不支持透明通道。解决办法是要么用浅色背景,要么生成后再用图片库做一次格式转换和背景填充,转成真正带透明通道的 PNG。
坑二:本地开发用的是测试环境的码,扫码跳到正式版。这个纯粹是配置没隔离,环境参数没传或者传错了。教训是:环境标识必须从配置读,且本地默认值应该是开发版,而不是正式版。默认值选错方向,出事概率会高很多。
坑三:批量生成把接口打限流了。一次性生成五千张渠道码,用 Promise.all 全并发打出去,前几百个成功,后面的全是限流错误,而且因为并发太高,重试也全失败。改法很简单:加个并发控制器,一次最多跑 5 个,跑完一批再下一批。五千张码大概几分钟就完事,比全并发快且稳。
坑四:文件名带中文导致部分客户端加载失败。业务 key 里混了中文,URL 编码之后变得很长,某些客户端处理不好。教训是文件名一律用哈希或者纯 ASCII 的业务 ID,中文只存在数据库的展示字段里。
坑五:图片换了但 CDN 还是旧图。路径没变,内容变了,CDN 缓存没刷新,用户看到的是老图。这个的根治办法就是"内容变了就换路径",用内容哈希命名,让 URL 天然跟随内容变化。
8. 规模化之后值得做的几件事
8.1 批量生成队列化
渠道码、一人一码这类场景,量级一上来就不能用同步接口了。改成队列化:请求进来先建任务记录,返回任务 ID,后台 worker 消费任务做生成和上传,前端轮询任务状态或者订阅消息。这样做的好处是生成速度可以通过加 worker 横向扩展,单个任务失败也可以重试而不影响整体。
队列的选型不用太复杂,用现成的消息队列或者基于数据库的任务表都能实现。关键是任务要幂等,同一个业务 key 的任务重复消费不能产生两份文件,这点还是要靠前面的唯一索引来保证。
8.2 成本与清理策略
码图这类资源最大的成本不是存储本身,而是访问流量。几个能明显降本的点:列表和详情用不同的尺寸,别都用原图;CDN 缓存命中率要盯,命中率低说明缓存头或者 URL 设计有问题;过期的码图按日期前缀做生命周期规则自动清理,别手动删。
还有个小技巧:码图这种内容确定性极强的资源,完全可以放在一个独立的、配置了长缓存的子域名下,和业务接口的域名分开。这样缓存策略互不干扰,做 CDN 配置的时候也清晰。
我自己在这个链路上最大的体会是:生成逻辑本身很简单,难的是把幂等、缓存、错误判断这些"边上"的东西做扎实。我见过好几个项目,功能上线很快,但一到活动大促就各种重复文件、破图、扫码进错页,最后花的时间比一开始就做对多得多。所以如果你正准备动手,建议先把存储路径规则和错误判断这两个东西定下来,后面的代码会顺很多。