马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
迩来复盘一段线上偶发 401 的题目,根因很有代表性:一个 AWS Lambda 把鄙俚服务的 access token 缓存在模块级变量里,但缓存时既不记逾期时间、也不在复用前查抄逾期。在 AWS Lambda 复用实行情况(暖启动) 的实行模子下,这会导致同一个实行情况不绝复用一份早就逾期的 token,直到该情况被采取为止。
这个坑本身不复杂,但它把两件事叠在了一起:
- 一个许多人没真正明白的 AWS Lambda 实行情况复用模子;
- 一个很常见的 「只判定有没有,不判定过没逾期」 的缓存写法。
[!warning] 一句话讲清这个 bug
在 AWS Lambda 里用模块级变量(本质就是一份历程内 / 内存缓存) 存 token,这个变量会随实行情况被复用而跨调用不绝存活;偏偏代码只判定变量有没有值、从不判定 token 过没逾期——于是同一个实行情况会拿着一份逾期 token 反复打鄙俚,直到情况被采取。
罪魁罪魁就是这份 AWS Lambda 历程内内存缓存:内存缓存 + 不管生命周期 + 不查逾期,三者叠加才出的事。
下面按 场景 → 出题目的代码 → 根因 → 症状 → 准确做法 来过一遍,末了给一份 checklist。文中代码做了脱敏,保存了原始结构。
场景
有一个由 SQS 触发的 AWS Lambda,负责把文件投递到鄙俚的业务 API:从对象存储下载文件、上传给业务服务,并在过程中不绝回调业务 API 上报进度。
上报进度须要带 Authorization: Bearer ,这个 token 是用 OAuth2 的 client_credentials(呆板到呆板)模式拿到的 access token。为了不每次上报都去授权服务器换一次 token,代码做了缓存——题目就出在这个缓存上。
出题目的代码
上报模块大抵长如许(已简化、脱敏):- // report.ts —— 进度上报模块
- let accessToken: string; // ← 模块级变量
- export const report = (treatmentId: string, body: ReportBody) => {
- reportingPromise = reportingPromise
- .then(() =>
- accessToken
- ? Promise.resolve(accessToken) // 有值 → 直接复用
- : readTokenFromDb().then(v => (accessToken = v)) // 没值 → 去取一次
- )
- .then(token =>
- http.post(url, body, {
- headers: { Authorization: `Bearer ${token}` },
- })
- );
- };
复制代码 readTokenFromDb() 从 DynamoDB 的 token 表里读出之前缓存好的 token:- // readTokenFromDb.ts
- export const readTokenFromDb = () =>
- new Promise<string>((resolve, reject) => {
- db.getItem(
- { Key: { id: { N: '1' } }, TableName: AccessTokenTable },
- (err, data) => {
- if (err || !data.Item) reject(err);
- else resolve(data.Item.value.S ?? ''); // ← 只取 value,没看 expiration
- }
- );
- });
复制代码 这里有两层缓存,但主次要分清——真正的坑在内存缓存这一层:
- 第一层 · 内存缓存(真正的首恶):let accessToken 这个模块级变量,本身就是一份历程内内存缓存。accessToken ? 复用 : 去取 的判定条件只是「这个变量有没有值」——一旦被赋过值,反面永世走「复用」分支,既不革新、也不看逾期。叠加下面要讲的实行情况复用,这就是 401 的根。
- 第二层 · DynamoDB(帮凶 / 放大器):token 表里实在存了 expirationUnixTimeSeconds,但 readTokenFromDb() 只 resolve 了 value,完全没读逾期时间。它的脚色是放大器:让内存缓存在初始化时取到的「初值」就大概是一份相近逾期的 token,使第一层的题目更早、更频仍地袒露。
本文重点是第一层的内存缓存;第二层只是顺带提一句,别被它带偏了注意力。
单看代码逻辑,在「一次哀求 / 一次历程」的心智模子下好像没毛病:取一次、缓存、复用。但 AWS Lambda 不是这个模子。
根因:模块级内存缓存 + 实行情况复用
许多人对 AWS Lambda 的直觉是「每次调用都是全新、干净的情况,跑完就烧毁」。前半句对,后半句错。
AWS Lambda 真实的实行模子是如许的:
- 你的函数运行在一个实行情况(execution environment) 里——AWS 官方就是这么叫的,底层是 Firecracker microVM,并不是 Docker 那种容器(以是本文不消「容器」这个俗称)。
- 一次调用竣事后,AWS 不会立刻烧毁这个情况,而是把它「冻结」起来保存一段时间;下一次调用直接「解冻」复用(即暖启动),从而避开冷启动的初始化开销。
- handler 函数之外的代码(模块顶层、全局变量、let accessToken 这种)只在情况初始化时实行一次;它们持有的状态会在该情况处置惩罚的全部后续调用之间存活。
- 冷启动:新建执行环境 → 跑一次模块初始化(accessToken 此时 undefined)
- │
- ┌────────┴─────────────────────────────────────────────┐
- │ 调用#1 调用#2 调用#3 ... 调用#N (复用同一个执行环境) │
- │ ↑ 第一次把 token 赋给 accessToken │
- │ ↑ 之后全部命中「有值 → 复用」分支,token 再不刷新 │
- └───────────────────────────────────────────────────────┘
- │
- 环境闲置一段时间后被回收 → 下次再来才会冷启动、重新初始化
复制代码 一个实行情况可以存活几分钟到几小时(AWS 没有允许确定值),而且高并发时会同时存在多个实行情况,每个都有本身独立的一份 accessToken,各安闲差别时间点缓存、各自老化。
这就是「复用旧 instance 里缓存的逾期 token」的真正寄义:这里的 "instance" 不是对象实例,而是谁人被复用的实行情况;而被复用的「缓存」,就是谁人模块级变量。模块级变量是把双刃剑——它正是用来复用数据库毗连、SDK client 这类无时效资源的好地方;可一旦拿去缓存 有 TTL 的东西(token、署名、短期根据),又不带逾期管理,就成了 bug。
[!important] 关键认知:let accessToken 不是平凡变量,而是一份内存缓存
在 AWS Lambda 里,这个模块级变量的生命周期 = 实行情况的寿命(你无法控制,大概几小时),并被该情况处置惩罚的全部调用共享。把 token 放进去却不管逾期,即是在赌「实行情况活得比 token 短」——这个赌注早晚会输。
换句话说:AWS Lambda 里写下 let xxxToken 的那一刻,你就已经在做内存缓存了,只是没意识到要给它配逾期管理而已。
把两层叠起来看,最坏路径是:
- 实行情况第一次调用,accessToken 为空 → 从 DynamoDB 读一份 token,此时它大概已经用掉了泰半生命周期(由于第二层也不查逾期);
- 这份 token 被钉死在模块级变量里;
- 该实行情况接下来几小时的全部调用,齐备复用这份 token;
- token 一旦逾期,后续每一次上报都拿着逾期 token 打鄙俚 → 401。
症状:为什么偶发、为什么难复现
这类 bug 的范例特性就是「偶发、和流量相干、当地复现不出来」:
- 冷启动反而是"好的"。低流量时实行情况频仍被采取,险些每次调用都冷启动、重新取 token,题目被完全粉饰。
- 连续流量才会袒露。只有当某个实行情况存活时间跨过了 token 有效期,它后续的调用才会带着逾期 token,出现 401。到底哪条 SQS 消息倒霉,取决于它被分配给了哪个实行情况——以是是「一部门消息失败」,不是全挂。
- 重试也救不了。这段代码外貌套了 axios 重试,但重试读的照旧模块里那份同一个逾期 token,于是重试 N 次满是 401,白白耗光重试次数,消息终极进 DLQ 或被重新投递。这一点尤其坑:重试机制让日志更丢脸,却没办理任何题目。
排查时如果只盯着「迩来改了什么业务逻辑」,很轻易绕远——由于代码大概几个月没动,厘革的只是流量形态(某天流量上来了,实行情况活得更久了)。
准确做法:缓存 token 肯定要带逾期 + 留安全裕度
修复思绪就一句话:缓存 token 时把逾期时间一起存下来,复用前先查抄逾期,并留一段安全裕度提前革新。- // 内存里缓存 token + 过期时间
- let cached: { token: string; expiresAtMs: number } | undefined;
- const SKEW_MS = 60_000; // 安全裕度:提前 60s 视为过期,避免"刚好卡点"
- async function getToken(): Promise<string> {
- const now = Date.now();
- if (cached && now < cached.expiresAtMs - SKEW_MS) {
- return cached.token; // 没过期才复用
- }
- const { access_token, expires_in } = await fetchTokenFromIdp();
- cached = {
- token: access_token,
- expiresAtMs: now + expires_in * 1000, // 或解码 JWT 的 exp claim
- };
- return access_token;
- }
复制代码 几个要点:
- 逾期时间从 token 本身来:用授权服务器返回的 expires_in,或解码 JWT 的 exp claim,不要本身拍一个固定的 TTL(拍小了浪费变更次数,拍大了照旧逾期)。
- 必须留安全裕度(skew / buffer):now < expiresAtMs - SKEW_MS。由于「查抄通过」到「token 真正被鄙俚用到」之间有耗时(网络、重试、慢调用);卡着逾期点用,极易在途中失效。
- DB 兜底层也要查逾期:如果像本例一样有 DynamoDB 做跨情况共享缓存,从 DB 读出来后同样要 now < expirationUnixTimeSeconds 才认,逾期了就当没有、回源重取。
- 防并发回源(stampede):多个调用同时发现逾期、同时去授权服务器换 token,既浪费又大概触发限流。常驻服务里通常用一把锁 / SemaphoreSlim + 双重查抄;AWS Lambda 单个实行情况内并发有限,影响小一些,但跨情况时 DB 兜底层能起到肯定的收敛作用。
对照:同一套逻辑移植到常驻服务后是怎么做对的
故意思的是,这段逻辑厥后被移植到一个常驻的后端服务(C#)里,那一版反而把逾期管理做对了,恰好可以当反面课本的「正面临照」:- // 取到新 token 后:按 JWT 的 exp 设绝对过期时间,留 1h 裕度
- var jwt = new JwtSecurityTokenHandler().ReadJwtToken(accessToken);
- if (jwt.Payload.Exp is > 0 expSeconds)
- {
- var expireAt = DateTimeOffset.FromUnixTimeSeconds(expSeconds) - TimeSpan.FromHours(1);
- _memoryCache.Set(cacheKey, accessToken, expireAt); // 绝对过期,到点自动失效
- await SaveToDbAsync(cacheKey, accessToken, expireAt.ToUnixTimeSeconds());
- }
- // 从 DB 兜底读取时,先校验没过期
- long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
- if (now < row.ExpirationUnixTimeSeconds && row.Value is { Length: > 0 })
- {
- _memoryCache.Set(cacheKey, row.Value,
- DateTimeOffset.FromUnixTimeSeconds(row.ExpirationUnixTimeSeconds));
- return row.Value;
- }
- return null; // 过期了 → 当作没有,回源重新授权
复制代码 它做对了三件 TS 版没做的事:
- 用 IMemoryCache.Set(key, value, absoluteExpiration) 给缓存项设了绝对逾期,到点条目自动失效,从根上克制「永世掷中旧值」;
- 留了 1 小时安全裕度(exp - 1h);
- DB 兜底读取时显式校验 now < ExpirationUnixTimeSeconds,逾期就回源。
⚠️ 一个细节:安全裕度必须小于 token 的有效期。像 exp - 1h 这种写法,如果哪天 token 本身只签发 10 分钟,expireAt 会算到已往 → 缓存项一写进去就被当成已逾期 → 每次都回源、缓存形同虚设。裕度应当随 token 现实寿命来定,而不是写死。
履历总结 / checklist
把这次的辅导提炼成几条,下次写雷同代码可以对照查抄:
- (头号辅导)在 AWS Lambda 及任何会复用历程的运行时里,模块级 / 全局变量 = 一份你没显式声明的内存缓存。往里放 token 前先问本身:它会逾期吗?逾期了谁来革新?——本文这个 bug 的全部辅导就在这一句。
- 凡是缓存有 TTL 的东西(token / 署名 / 暂时根据),肯定要连逾期时间一起缓存,复用前查抄逾期。「判定有没有值」≠「判定能不能用」。
- 逾期时间取自数据本身(expires_in / JWT exp),不要本身拍固定 TTL。
- 留安全裕度提前革新,别卡着逾期点用;裕度要小于有效期。
- 想清晰缓存活在哪一层、活多久:
- AWS Lambda 模块级变量 / 全局状态 → 活在实行情况里,跨调用存活,寿命不可控;
- 常驻服务的单例 / 静态字段 → 活在历程里,直到重启;
- 这两者本质都是「跨多次哀求复用的历程内状态」,缓存偶然效的数据时坑是一样的。
- 模块级 / 全局变量得当放无时效的复用资源(毗连、client);放偶然效数据时务必带逾期管理。
- 多层缓存(内存 + DB/Redis)要每一层都校验逾期,不能假设上游存进来的就是希奇的。
- 注意重试与逾期的交互:如果失败根因是「缓存里的 token 逾期了」,无脑重试只会拿同一份逾期 token 反复失败——重试前应触发革新。
- 这类 bug 偶发、与流量相干、当地难复现,排查时别只盯代码改动,也要看流量形态和实行情况/历程的存活时长。
免责声明:如果侵犯了您的权益,请联系站长及时删除侵权内容,谢谢合作!qidao123.com:ToB企服之家,中国第一个企服评测及软件市场,开放入驻,技术点评得现金. |