DynamoDB 提供稳定的个位数毫秒响应,但当应用需要反复读取同一批数据时,网络往返和请求计费仍然存在优化空间。客户端缓存把常用数据放在离业务逻辑更近的位置,可以减少对 DynamoDB 表的压力,同时降低读取延迟。不过,缓存并非银弹,它引入了一致性判断、失效时机和内存管理等一系列新问题。

为什么需要客户端缓存
DynamoDB 按请求计费,读请求以 RCU(Read Capacity Unit)为单位消耗。对于读取频繁但更新较少的数据,例如商品详情、用户配置或热门排行榜,如果每次请求都直达 DynamoDB,不仅会增加 RCU 消耗,还会放大网络延迟。尤其在流量高峰期,单表或单个分区键可能成为热点,触发限流,进一步拖慢整体响应。
在应用侧引入缓存后,大部分重复读取可以直接命中内存或共享缓存,跳过 DynamoDB 请求。以 Node.js 服务为例,若将热门查询结果缓存 30 秒,命中率往往能达到 80% 以上,这意味着只有不到 20% 的请求会真正访问 DynamoDB。延迟方面,本地内存命中通常低于 1 毫秒,而 DynamoDB 即使表现优秀也需要数毫秒的网络往返。对于 p95、p99 延迟敏感的业务,这种差异非常明显。
不过,缓存也带来三个必须解决的问题:第一是数据一致性,DynamoDB 中的数据更新后,缓存副本可能仍在旧值;第二是失效策略,过期时间太短收益有限,太长则脏读风险增加;第三是缓存自身的可用性和内存占用。这些问题在分布式环境下会被进一步放大,需要结合业务容忍度来设计。
Node.js 内存级 LRU 缓存实现
对于单实例、低并发的应用,最简单有效的做法是在进程内维护一个 LRU(Least Recently Used)缓存。Node.js 自带的 Map 对象可以按插入顺序遍历键,借助这一点能够快速实现容量淘汰和 TTL 过期。下面的代码封装了一个基础的内存缓存类,并包装 DynamoDB 的 GetItem 操作。
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient, GetCommand } = require('@aws-sdk/lib-dynamodb');
class MemoryCache {
constructor(maxSize = 1000, ttlMs = 60000) {
this.cache = new Map();
this.maxSize = maxSize;
this.ttlMs = ttlMs;
}
get(key) {
const item = this.cache.get(key);
if (!item) return undefined;
if (Date.now() - item.createdAt > this.ttlMs) {
this.cache.delete(key);
return undefined;
}
// 命中后移动到末尾,保持最近使用顺序
this.cache.delete(key);
this.cache.set(key, item);
return item.value;
}
set(key, value) {
if (this.cache.size >= this.maxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, { value, createdAt: Date.now() });
}
delete(key) {
this.cache.delete(key);
}
}
const client = new DynamoDBClient({ region: 'us-east-1' });
const docClient = DynamoDBDocumentClient.from(client);
const cache = new MemoryCache(500, 30000);
async function getItemWithCache(tableName, key) {
const cacheKey = `${tableName}:${JSON.stringify(key)}`;
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
const result = await docClient.send(new GetCommand({
TableName: tableName,
Key: key,
}));
if (result.Item) {
cache.set(cacheKey, result.Item);
}
return result.Item;
}
module.exports = { getItemWithCache };
这段代码的关键在于缓存键的设计。使用表名和主键的 JSON 字符串作为唯一标识,既能避免不同表之间的键冲突,也能区分复合主键的不同组合。容量控制上,当缓存大小达到上限时,Map 中第一个键就是最久未使用的项,直接删除即可实现简单 LRU。TTL 则通过比较创建时间和当前时间来判断,超过阈值立即失效。
内存缓存的优点是零外部依赖、延迟极低,但局限也很明显。首先,进程重启后缓存全部丢失,需要重新预热;其次,多实例部署时,不同实例之间的缓存互相独立,同一数据可能在不同实例上出现多个版本,导致一致性问题。如果业务规模较小、允许短暂不一致,这种方案已经足够;如果要求严格一致或实例数量较多,就需要考虑分布式缓存。
基于 Redis 的分布式缓存方案
当应用以集群或多实例方式运行时,内存缓存无法共享,更新失效也难以广播。此时引入 Redis 作为统一的缓存层是更合理的选择。Redis 支持 TTL、原子操作和持久化,Node.js 生态中也有成熟的客户端库。下面的示例展示如何用 Redis 缓存 DynamoDB 查询结果,并在数据更新时主动删除缓存。
const redis = require('redis');
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient, GetCommand, UpdateCommand } = require('@aws-sdk/lib-dynamodb');
const redisClient = redis.createClient({ url: 'redis://127.0.0.1:6379' });
redisClient.on('error', function(err) {
console.error('Redis Client Error', err);
});
async function initRedis() {
await redisClient.connect();
}
initRedis();
const ddbClient = new DynamoDBClient({ region: 'us-east-1' });
const docClient = DynamoDBDocumentClient.from(ddbClient);
const CACHE_TTL_SECONDS = 60;
async function getItemWithRedis(tableName, key) {
const cacheKey = `ddb:${tableName}:${JSON.stringify(key)}`;
const cached = await redisClient.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const result = await docClient.send(new GetCommand({
TableName: tableName,
Key: key,
}));
if (result.Item) {
await redisClient.set(cacheKey, JSON.stringify(result.Item), {
EX: CACHE_TTL_SECONDS,
});
}
return result.Item;
}
async function updateItemWithInvalidation(tableName, key, updateExpression, values) {
const result = await docClient.send(new UpdateCommand({
TableName: tableName,
Key: key,
UpdateExpression: updateExpression,
ExpressionAttributeValues: values,
ReturnValues: 'ALL_NEW',
}));
const cacheKey = `ddb:${tableName}:${JSON.stringify(key)}`;
await redisClient.del(cacheKey);
return result.Attributes;
}
module.exports = { getItemWithRedis, updateItemWithInvalidation };
与内存缓存相比,Redis 版本的核心差异在于所有服务实例共享同一份缓存数据,更新操作完成后立即删除对应缓存键,后续读取会重新从 DynamoDB 获取最新值。TTL 通过 EX 参数设置,避免某条缓存长期驻留导致数据过于陈旧。序列化方面,DynamoDB 返回的 Item 对象通常是普通 JSON 结构,直接使用 JSON.stringify 和 JSON.parse 即可。
分布式缓存需要额外处理三类典型问题。缓存穿透是指查询一个不存在的数据,缓存和数据库都没有,导致每次请求都打到 DynamoDB;可以通过缓存空值并设置较短 TTL 来缓解。缓存击穿是指某个热点数据过期瞬间,大量并发请求同时穿透到数据库;常见做法是加互斥锁,只允许一个请求回源,其余等待结果。缓存雪崩则是大量键同时过期,造成后端压力骤增;可以通过给 TTL 增加随机偏移来分散过期时间。
缓存一致性:更新与失效策略
缓存一致性的核心矛盾在于:数据库更新和缓存删除不是原子操作,无论先执行哪一步,都可能在并发场景下出现短暂不一致。最常用的模式是 Cache-Aside(旁路缓存):读请求先查缓存,未命中则查数据库并写入缓存;写请求直接更新数据库,然后删除对应缓存。下次读取时自然会回源并重新填充最新数据。
在 Cache-Aside 模式下,删除缓存和更新数据库的先后顺序会影响不一致窗口。先更新数据库再删除缓存,可能出现更新读旧值的情况;先删除缓存再更新数据库,则可能在删除后、更新完成前有其他请求读到旧值并写回缓存。针对后者,可以采用延迟双删策略:更新数据库后延迟几百毫秒再次删除缓存,以覆盖并发写回。不过,这些方案都无法做到绝对一致,只能尽量缩短不一致窗口。
对于一致性要求较高的场景,可以利用 DynamoDB Streams 和 Lambda 实现自动失效。当表中的数据发生变化时,DynamoDB 会生成流记录,Lambda 函数监听这些记录并删除 Redis 中对应的缓存键。这种方案将失效逻辑从业务代码中剥离,实现最终一致性,但会增加系统的复杂度和运营成本。对于大多数中小型应用,主动在写操作中删除缓存已经足够。
对比 DynamoDB Accelerator(DAX)与自建缓存
DynamoDB Accelerator(DAX)是 AWS 官方提供的托管缓存服务,兼容现有 DynamoDB API,应用改动很小。DAX 采用内存缓存集群,可以将读取延迟从毫秒级降低到微秒级,并且无需自行维护缓存一致性,因为 DAX 会在数据写入 DynamoDB 后自动更新缓存。不过,DAX 的成本较高,只支持特定实例类型,且对部分 API 和条件写入有限制。
自建缓存方案,如 Redis 或应用内缓存,优势在于灵活性和成本可控。你可以根据业务特点自由选择 TTL、淘汰策略和数据结构,甚至在不同的表上使用不同的缓存策略。劣势是需要自己处理缓存失效、集群高可用、监控告警和故障恢复。对于已经使用 Redis 作为其他用途的团队来说,复用现有基础设施的成本很低。
决策时可以从三个维度考虑:延迟要求、数据变更频率和运维能力。如果业务要求极低的 p99 延迟且预算充足,DAX 是更省心的选择;如果对成本敏感、数据更新不频繁,并且团队已经具备 Redis 运维经验,自建缓存往往性价比更高。混合方案也很常见:对最热门的少量数据使用 DAX,对其余数据使用 Redis 或内存缓存。
生产环境建议与监控指标
无论采用哪种缓存方案,都需要关注几个关键指标:缓存命中率、缓存条目数量、过期速率、回源次数以及缓存访问延迟。命中率可以直接反映缓存收益,如果命中率长期低于 50%,说明 TTL 设置过短或数据访问模式不适合缓存。回源次数突然飙升可能意味着缓存雪崩或某些热键失效,需要及时排查。
安全方面,缓存中可能包含敏感业务数据,Redis 实例应部署在 VPC 内,开启认证和 TLS 加密。内存缓存中的敏感对象应在日志输出时脱敏,避免通过错误日志泄露。同时,建议为缓存操作设置超时和降级逻辑:如果 Redis 暂时不可用,应用可以直接回源 DynamoDB,而不是整体报错。
最后,客户端缓存不是万能优化。对于写入频繁、读取较少的场景,缓存命中率低,维护成本反而高于收益。对于需要强一致性的交易数据,缓存可能带来难以接受的风险。在引入缓存前,先分析实际访问模式和业务容忍度,再选择合适的缓存层次和失效策略,才能让 DynamoDB 与 Node.js 的组合发挥最大价值。