Tóm tắt vấn đề
Gần đây tôi phải điều tra một lỗi 401 xuất hiện không liên tục trên production. Nguyên nhân rất điển hình: một AWS Lambda lưu access token của service downstream vào biến ở cấp module, nhưng khi缓存 không hề lưu thời gian hết hạn, cũng không kiểm tra trước khi tái sử dụng. Với mô hình tái sử dụng execution environment (warm start) của AWS Lambda, điều này dẫn đến cùng một execution environment liên tục dùng một token đã hết hạn từ lâu, cho đến khi environment đó bị thu hồi.
Vấn đề này kết hợp hai yếu tố:
- Mô hình tái sử dụng execution environment của AWS Lambda - nhiều người chưa thực sự hiểu rõ
- Pattern cache phổ biến nhưng có lỗi: "chỉ kiểm tra có giá trị hay không, không kiểm tra đã hết hạn chưa"
TL;DR: Trong AWS Lambda, dùng let ở cấp module để lưu token (về bản chất là memory cache trong process) - biến này tồn tại xuyên suốt các lần gọi khi execution environment được tái sử dụng. Code chỉ kiểm tra "biến có giá trị không" chứ không kiểm tra "token còn hạn không". Kết quả: cùng một environment dùng token hết hạn để gọi downstream cho đến khi bị thu hồi. Nguyên nhân cốt lõi là sự kết hợp của: memory cache + không quản lý lifecycle + không kiểm tra expiration.
Bối cảnh
Có một AWS Lambda được kích hoạt bởi SQS, nhiệm vụ là upload file lên business API: tải file từ object storage, upload lên service, và trong quá trình đó callback liên tục để báo cáo tiến độ.
Việc báo cáo tiến độ cần token xác thực Authorization: Bearer <token>. Token này được lấy theo OAuth2 client_credentials (machine-to-machine). Để tránh mỗi lần báo cáo lại đi xin token từ server, code đã làm cache - và vấn đề nằm ở đây.
Code có lỗi
Module báo cáo tiến độ (đã đơn giản hóa):
// progressReporter.ts - Module báo cáo tiến độ
let authToken: string;
export const reportProgress = (taskId: string, payload: ProgressPayload) => {
processingQueue = processingQueue
.then(() =>
authToken
? Promise.resolve(authToken) // Có giá trị → tái sử dụng
: retrieveTokenFromStore().then(t => (authToken = t)) // Không có → lấy mới
)
.then(token =>
httpClient.post(reportEndpoint, payload, {
headers: { Authorization: `Bearer ${token}` },
})
);
};
Hàm retrieveTokenFromStore() đọc token đã cache từ DynamoDB:
// tokenStore.ts
export const retrieveTokenFromStore = () =>
new Promise<string>((resolve, reject) => {
dynamo.getItem(
{ Key: { pk: { S: 'auth_token' } }, TableName: TokenTable },
(err, data) => {
if (err || !data.Item) reject(err);
else resolve(data.Item.token.S ?? '');
}
);
});
Có hai lớp cache, nhưng cần phân rõ:
- Lớp 1 - Memory Cache (thủ phạm chính):
let authTokenlà biến cấp module, là một memory cache trong process. LogicauthToken ? đùng : lấy mớichỉ kiểm tra "biến có giá trị không" - một khi được gán giá trị, sau đó mãi mãi đi nhánh "tái sử dụng", không refresh, không kiểm tra hết hạn. - Lớp 2 - DynamoDB (đồng phạm): Token table thực tế có trường
expiryTimestamp, nhưngretrieveTokenFromStore()chỉresolvegiá trịtoken, hoàn toàn không đọc thời hạn. Đây là yếu tố放大器: khiến memory cache lấy ngay từ đầu một token đã gần hết hạn.
Nhìn vào code, trong mental model "một request / một process" thì có vẻ ổn: lấy một lần, cache, tái sử dụng. Nhưng AWS Lambda không hoạt động theo model đó.
Nguyên nhân gốc: Module-level cache + Execution Environment reuse
Nhiều người nghĩ AWS Lambda "mỗi lần gọi là môi trường mới, sạch sẽ, chạy xong là bị hủy". Nửa đầu đúng, nửa sau sai.
Mô hình thực thi thực tế của AWS Lambda:
- Function chạy trong một execution environment - AWS gọi chính xác như vậy, bên dưới là Firecracker microVM, không phải Docker container.
- Sau khi gọi xong, AWS không hủy ngay environment mà "đóng băng" (freeze) và giữ lại một thời gian; lần gọi sau "giải phóng" (thaw) và tái sử dụng (gọi là warm start), tránh chi phí khởi động lạnh (cold start).
- Code ngoài handler (module top-level, global variables,
let authToken) chỉ chạy một lần khi environment khởi tạo; trạng thái chúng nắm giữ sẽ sống sót qua tất cả các lần gọi tiếp theo trong environment đó.
Cold start: Tạo mới execution environment → Chạy init (authToken = undefined)
│
┌────────┴─────────────────────────────────────────┐
│ Call#1 Call#2 Call#3 ... Call#N │
│ ↑ Lần đầu gán token cho authToken │
│ ↑ Sau đó all hit "có giá trị", ko refresh │
└───────────────────────────────────────────────────┘
│
Sau idle → environment bị thu hồi → cold start lần sau
Một execution environment có thể sống vài phút đến vài giờ (AWS không guarantee), và khi high concurrency sẽ có nhiều environment chạy song song, mỗi cái có một bản sao authToken độc lập, cache vào các thời điểm khác nhau, lão hóa khác nhau.
Đây là ý nghĩa thực sự của "tái sử dụng expired token từ instance cũ": "instance" ở đây không phải object instance, mà là execution environment được tái sử dụng; "cache" được tái sử dụng chính là biến cấp module đó.
Nhận thức quan trọng: let authToken không phải biến thông thường, mà là một memory cache. Trong AWS Lambda, vòng đời của biến cấp module = vòng đời của execution environment (không kiểm soát được, có thể vài giờ), và được tất cả các lần gọi trong environment đó chia sẻ. Đặt token vào mà không quản lý hết hạn = đánh cược "environment sống ngắn hơn token" - cuối cùng sẽ thua.
Nói cách khác: Ngay khi bạn viết let xxxToken trong AWS Lambda, bạn đang làm memory cache mà không nhận ra rằng cần phải có expiration management.
Worst case path
- Lần gọi đầu tiên của environment,
authTokenrỗng → đọc từ DynamoDB, lúc này token có thể đã dùng hết phần lớn vòng đời (vì lớp 2 cũng không check hết hạn) - Token này bị "đóng đinh" vào biến cấp module
- Tất cả lần gọi trong vài giờ tiếp theo của environment đều dùng lại token này
- Một khi token hết hạn, mọi lần báo cáo đều dùng token hết hạn → 401
Triệu chứng: Tại sao lỗi không liên tục, khó reproduce
Đặc điểm điển hình của bug này:
- Cold start lại là "tốt". Khi traffic thấp, environment hay bị thu hồi, hầu như mỗi lần gọi đều cold start, lấy token mới - bug bị che hoàn toàn.
- Chỉ khi có traffic liên tục mới lộ ra. Chỉ khi environment tồn tại đủ lâu để vượt qua thời gian hết hạn token, các lần gọi sau mới mang token hết hạn, gây 401. Message SQS nào bị ảnh hưởng phụ thuộc vào nó được assign vào environment nào - nên chỉ một phần messages fail, không phải tất cả.
- Retry không cứu được. Code có axios retry bọc ngoài, nhưng retry đọc cũng là token trong cùng module, cùng một token hết hạn, retry N lần đều 401, hết retry quota rồi message vào DLQ. Điểm này đặc biệt tai hại: retry làm log phức tạp hơn mà không giải quyết gì.
Giải pháp đúng: Cache token phải kèm expiration + buffer an toàn
Nguyên tắc sửa: Khi cache token, lưu luôn thời gian hết hạn, kiểm tra trước khi tái sử dụng, và để buffer an toàn để refresh sớm.
// Cache token kèm thời hạn trong memory
let tokenCache: { credential: string; expiresAtMs: number } | undefined;
const BUFFER_MS = 60_000; // Buffer an toàn: coi như hết hạn trước 60s
async function fetchValidToken(): Promise<string> {
const now = Date.now();
if (tokenCache && now < tokenCache.expiresAtMs - BUFFER_MS) {
return tokenCache.credential; // Chưa hết hạn thì dùng
}
const { access_token, expires_in } = await requestTokenFromIdp();
tokenCache = {
credential: access_token,
expiresAtMs: now + expires_in * 1000,
};
return access_token;
}
Các điểm quan trọng
- Thời hạn lấy từ chính token: Dùng
expires_intừ server, hoặc decode JWT lấy claimexp, không tự định TTL cố định. - Phải có buffer an toàn:
now < expiresAtMs - BUFFER_MS. Vì giữa "kiểm tra xong" và "token thực sự được dùng" có độ trễ (network, retry, call chậm); dùng sát điểm hết hạn rất dễ bị lỗi giữa chừng. - DB layer cũng phải check: Nếu có DynamoDB làm cache chia sẻ cross-environment, khi đọc từ DB cũng phải
now < expiryTimestampmới chấp nhận, hết hạn thì coi như không có, fetch lại. - Chống stampede: Nhiều requests đồng thời phát hiện hết hạn, cùng đi xin token, vừa lãng phí vừa có thể trigger rate limit. Service thường trực dùng lock / semaphore + double-check; AWS Lambda trong một environment concurrency không nhiều, nhưng cross-environment có DB layer giúp收敛.
So sánh: Khi移植sang service thường trực thì làm đúng thế nào
Thú vị là sau đó logic này được移植sang một backend service thường trực (C#), phiên bản đó lại làm đúng expiration management, có thể dùng làm đối chiếu:
// Sau khi lấy token mới: set absolute expiry theo JWT exp, buffer 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 PersistToDbAsync(cacheKey, accessToken, expireAt.ToUnixTimeSeconds());
}
// Khi đọc từ DB cache: kiểm tra trước
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now < row.ExpiryTimestamp && row.Credential is { Length: > 0 })
{
_memoryCache.Set(cacheKey, row.Credential,
DateTimeOffset.FromUnixTimeSeconds(row.ExpiryTimestamp));
return row.Credential;
}
return null;
Phiên bản C# làm đúng 3 điều mà phiên TS không làm:
- Dùng
IMemoryCache.Set(key, value, absoluteExpiration)set absolute expiry, đến thời điểm tự失效, tránh hoàn toàn "luôn luôn hit giá trị cũ" - Để 1 giờ buffer (
exp - 1h) - DB layer đọc có kiểm tra
now < ExpiryTimestamp, hết hạn thì coi như không có.
Lưu ý: Buffer phải nhỏ hơn thời gian hiệu lực của token. Nếu viếtexp - 1hmà token chỉ có 10 phút,expireAtsẽ tính ra thời điểm trong quá khứ → cache vừa set đã bị coi là expired → mỗi lần đều phải fetch lại, cache vô dụng. Buffer phải theo thời gian thực của token, không định cứng.
Checklist kinh nghiệm
-
- AWS Lambda module-level / static → sống trong execution environment, xuyên call,寿命 không kiểm soát được
- Service thường trực singleton / static field → sống trong process, đến khi restart
- Cả hai đều là "cross-request state trong process", cache có时效 data thì hại như nhau.