当前位置: 首页 > news >正文

AWS Lambda 执行环境复用与内存缓存 token 过期的坑

最近复盘一段线上偶发 401 的问题,根因很有代表性:一个 AWS Lambda 把下游服务的 access token 缓存在模块级变量里,但缓存时既不记过期时间、也不在复用前检查过期。在 AWS Lambda 复用执行环境(暖启动) 的执行模型下,这会导致同一个执行环境一直复用一份早就过期的 token,直到该环境被回收为止。

这个坑本身不复杂,但它把两件事叠在了一起:

  1. 一个很多人没真正理解的 AWS Lambda 执行环境复用模型;
  2. 一个很常见的 「只判断有没有,不判断过没过期」 的缓存写法。

[!warning] 一句话讲清这个 bug
在 AWS Lambda 里用模块级变量(本质就是一份进程内 / 内存缓存) 存 token,这个变量会随执行环境被复用而跨调用一直存活;偏偏代码只判断变量有没有值、从不判断 token 过没过期——于是同一个执行环境会拿着一份过期 token 反复打下游,直到环境被回收
罪魁祸首就是这份 AWS Lambda 进程内内存缓存:内存缓存 + 不管生命周期 + 不查过期,三者叠加才出的事。

下面按 场景 → 出问题的代码 → 根因 → 症状 → 正确做法 来过一遍,最后给一份 checklist。文中代码做了脱敏,保留了原始结构。

场景

有一个由 SQS 触发的 AWS Lambda,负责把文件投递到下游的业务 API:从对象存储下载文件、上传给业务服务,并在过程中不断回调业务 API 上报进度

上报进度需要带 Authorization: Bearer <token>,这个 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()resolvevalue,完全没读过期时间。它的角色是放大器:让内存缓存在初始化时取到的「初值」就可能是一份临近过期的 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 的那一刻,你就已经在做内存缓存了,只是没意识到要给它配过期管理而已。

把两层叠起来看,最坏路径是:

  1. 执行环境第一次调用,accessToken 为空 → 从 DynamoDB 读一份 token,此时它可能已经用掉了大半生命周期(因为第二层也不查过期);
  2. 这份 token 被钉死在模块级变量里;
  3. 该执行环境接下来几小时的所有调用,统统复用这份 token;
  4. 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 版没做的事:

  1. IMemoryCache.Set(key, value, absoluteExpiration) 给缓存项设了绝对过期,到点条目自动失效,从根上避免「永远命中旧值」;
  2. 留了 1 小时安全裕度(exp - 1h);
  3. DB 兜底读取时显式校验 now < ExpirationUnixTimeSeconds,过期就回源。

⚠️ 一个细节:安全裕度必须小于 token 的有效期。像 exp - 1h 这种写法,如果哪天 token 本身只签发 10 分钟,expireAt 会算到过去 → 缓存项一写进去就被当成已过期 → 每次都回源、缓存形同虚设。裕度应当随 token 实际寿命来定,而不是写死。

经验总结 / checklist

把这次的教训提炼成几条,下次写类似代码可以对照检查:

    • AWS Lambda 模块级变量 / 全局状态 → 活在执行环境里,跨调用存活,寿命不可控;
    • 常驻服务的单例 / 静态字段 → 活在进程里,直到重启;
    • 这两者本质都是「跨多次请求复用的进程内状态」,缓存有时效的数据时坑是一样的。
http://www.jsqmd.com/news/983653/

相关文章:

  • 基于BERT的招聘骗局识别工具包:含训练代码、检测系统与毕设文档全套
  • MySQL 库表操作 +数据类型+ 基础概念全梳理----《Hello MySQL!》(2)
  • 旧AI体系的终结:哲学、技术与文明三重崩塌机制的系统分析——基于贾子理论的系统研究报告
  • Joplin笔记软件终极指南:免费开源跨平台隐私笔记解决方案
  • 2026年上海检测机构/力学性能/化学性能/失效分析/无损检测PAUT/风电在役/老化与金属材料检测公司权威推荐榜单 - 品牌发掘
  • 快速查看GBase 8a数据库的数据分布情况小技巧
  • okbiye:论文双维度优化工具,击破重复率与 AI 痕迹两大毕业关卡
  • 无锡带数据报表的GEO优化公司TOP3|2026实测对比+行业FAQ - wxxwlm
  • 世界模型:一文讲清楚AI下一个十年的核心战场
  • 2000-2023年各省普通高等学校在校学生数数据
  • 用gwpy处理引力波数据
  • 打破MCS51开发壁垒:CH55xduino如何让廉价USB微控制器成为Arduino生态新宠
  • 视觉驱动UI自动化技术演进:跨平台AI测试框架的架构重塑与实践路径
  • 想对接师大中高教育专属班主任?官方咨询电话公布 - GEO代运营aigeo678
  • AI Agent 面试题 874:如何设计Agent辅助的测试用例自动生成系统?
  • 嵌入式硬件设计实战:从K50数据手册到可靠电路与驱动开发
  • TranslucentTB中文界面设置全攻略:让你的任务栏透明化工具说中文
  • 2026年江阴律师推荐榜单:合同纠纷/离婚律师/经济纠纷/民间借贷/劳动法律师/交通事故/公司顾问律师实力之选 - 企业推荐官【官方】
  • Linux:线程概念和线程控制
  • 2026年了,你还只会调用API?手把手教你从零搭建Transformer模型,硬核代码复现(含位置编码、多头注意力、残差连接全解析)
  • D2DX:让《暗黑破坏神2》在现代PC上流畅运行的终极优化方案
  • 开源行为验证码解决方案:构建智能人机识别防线,拦截99.2%自动化攻击
  • Skill规范及设计优化方法
  • 2026年 江阴律师推荐榜单:合同纠纷/离婚律师/经济纠纷/民间借贷/劳动法律师/交通事故/电子商务及公司顾问律师深度解析 - 企业推荐官【官方】
  • 2026跨省寄大件,哪个快递最便宜?全网比价指南 - 快递物流资讯
  • 5步掌握播客批量下载:打造你的离线音频库
  • 范式跃迁与体系重构:贾子理论主导下的AI新旧体系迭代变革——“旧AI体系已死”:范式转移的必然性
  • 5060显卡跑yolov8模型:5060的显卡怎么去跑yolov8模型?试了好几个cuda版本都不行...如何解决?
  • 从零训练一个小型语言模型
  • 小程序毕设项目:基于spring boot的校园二手交易平台系统小程序 (源码+文档,讲解、调试运行,定制等)