场景

有一个由 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() 只 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 的那一刻,你就已经在做内存缓存了,只是没意识到要给它配过期管理而已。

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

  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 及任何会复用进程的运行时里,模块级 / 全局变量 = 一份你没显式声明的内存缓存。往里放 token 前先问自己:它会过期吗?过期了谁来刷新?——本文这个 bug 的全部教训就在这一句。
  • 凡是缓存有 TTL 的东西(token / 签名 / 临时凭证),一定要连过期时间一起缓存,复用前检查过期。「判断有没有值」≠「判断能不能用」。
  • 过期时间取自数据本身(expires_in / JWT exp),不要自己拍固定 TTL。
  • 安全裕度提前刷新,别卡着过期点用;裕度要小于有效期。
  • 想清楚缓存活在哪一层、活多久:
    • AWS Lambda 模块级变量 / 全局状态 → 活在执行环境里,跨调用存活,寿命不可控;
    • 常驻服务的单例 / 静态字段 → 活在进程里,直到重启;
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐