Agent X-Ray
RuntimeNotesAbout
Notes/代码工程/Encore/第9章

第9章:对象存储与缓存 —— Bucket 与 CacheCluster

4 分钟 · 更新于 2026-09-02

文件与缓存是后端的两大"隐性基础设施"。Encore 把它们同样收进声明式原语:Bucket 映射 S3/GCS,CacheCluster 映射 Redis,附带一套很少见的类型安全键空间设计。


一、对象存储:Bucket

声明

typescript
import { Bucket } from "encore.dev/storage/objects";

export const ticketAttachments = new Bucket("ticket-attachments", {
  versioned: false,   // 是否保留对象历史版本
});

包级变量(老规矩);本地由框架模拟,云上映射 Amazon S3、Google Cloud Storage 或任意 S3 兼容服务(MinIO 等,自托管时在 infra 配置指定)。

七个核心操作

typescript
// 上传
const data = Buffer.from(pdfBytes);
const attributes = await ticketAttachments.upload("itinerary-ORD123.pdf", data, {
  contentType: "application/pdf",
});

// 下载
const bytes = await ticketAttachments.download("itinerary-ORD123.pdf");

// 列举(异步迭代器)
for await (const entry of ticketAttachments.list({})) {
  console.log(entry.name, entry.size);
}

// 删除
await ticketAttachments.remove("itinerary-ORD123.pdf");

// 属性与存在性
const attrs = await ticketAttachments.attrs("itinerary-ORD123.pdf");
const exists = await ticketAttachments.exists("itinerary-ORD123.pdf");

错误类型:ObjectNotFound(对象不存在)、PreconditionFailed(上传前置条件不满足)、ObjectsError(基类)。

公开桶与公开 URL

typescript
export const cdnAssets = new Bucket("cdn-assets", {
  public: true,
  versioned: false,
});

const url = cdnAssets.publicUrl("banner.png");   // 直接可访问的 URL

签名 URL:客户端直传/直下

大文件让客户端直连对象存储,不过服务器中转:

typescript
// 直传:给前端一个限时上传地址
const uploadUrl = await ticketAttachments.signedUploadUrl("user-42/receipt.jpg", {
  ttl: 7200,   // 秒
});

// 直下:给前端一个限时下载地址(桶保持私有)
const downloadUrl = await ticketAttachments.signedDownloadUrl("itinerary-ORD123.pdf", {
  ttl: 7200,
});

典型流程:前端请求你的 API → API 鉴权后返回 signed URL → 前端直接 PUT/GET 对象存储。服务器带宽零占用,权限窗口受 TTL 约束。

桶引用:最小权限传递

把桶访问权传给其他模块时,不传整个桶对象,传带权限约束的引用

typescript
import { Uploader } from "encore.dev/storage/objects";

// 只授予上传权限
const uploaderRef = ticketAttachments.ref<Uploader>();

可选权限:Downloader / Uploader / Lister / Attrser / Remover / ReadWriter(组合权限)。引用必须在服务内创建——静态分析器据此把"哪个服务对哪个桶有什么权限"画进应用模型,云上生成对应的最小 IAM 策略。这是"声明即权限"的少见实现:权限收敛不靠运维配置,靠代码里的类型参数。

二、缓存:CacheCluster 与键空间

声明集群

typescript
import { CacheCluster } from "encore.dev/storage/cache";

const cluster = new CacheCluster("app-cache", {
  evictionPolicy: "allkeys-lru",
});

// 其他服务引用同一集群
const sameCluster = CacheCluster.named("app-cache");

驱逐策略:allkeys-lru(默认)/ noeviction / allkeys-lfu / allkeys-random / volatile-lru / volatile-lfu / volatile-ttl / volatile-random——即 Redis 原生策略集。

云上映射托管 Redis(AWS ElastiCache / GCP Memorystore);本地是内存模拟,约 100 个键上限——本地只验证逻辑,不做容量测试。

键空间 (Keyspace):类型安全的 Redis

Encore 不让你直接拼 Redis key 字符串,而是声明键空间:键的结构、值的类型、过期策略都进类型系统:

typescript
import { CacheCluster, StringKeyspace, IntKeyspace, StructKeyspace, expireIn } from "encore.dev/storage/cache";

const cluster = new CacheCluster("app-cache", { evictionPolicy: "allkeys-lru" });

// 字符串值:session token
const sessions = new StringKeyspace<{ sessionId: string }>(cluster, {
  keyPattern: "session/:sessionId",
  defaultExpiry: expireIn(3600 * 1000),   // 毫秒
});

await sessions.set({ sessionId: "abc123" }, "user-42");
const uid = await sessions.get({ sessionId: "abc123" });   // string | undefined,miss 返回 undefined
await sessions.delete({ sessionId: "abc123" });

// 数值:计数器
const searchCount = new IntKeyspace<{ route: string }>(cluster, {
  keyPattern: "search-count/:route",
});
await searchCount.increment({ route: "PEK-LAX" }, 1);

// 结构体:缓存整个对象
interface FlightCache { flightNo: string; price: number; }
const flightCache = new StructKeyspace<{ flightNo: string; date: string }, FlightCache>(cluster, {
  keyPattern: "flight/:flightNo/:date",
  defaultExpiry: expireIn(5 * 60 * 1000),
});
await flightCache.set({ flightNo: "CA981", date: "2026-09-01" }, { flightNo: "CA981", price: 458800 });

键空间家族全表:

Keyspace值类型特有操作
StringKeyspacestringget / set / delete
IntKeyspace整数increment / decrement
FloatKeyspace浮点increment
StructKeyspace任意接口get / set / delete
StringListKeyspace / NumberListKeyspace列表pushLeft / pushRight / popLeft / popRight / getRange / items
StringSetKeyspace / NumberSetKeyspace集合add / remove / contains / items

过期与写入选项

typescript
import { expireIn, expireInSeconds, expireInMinutes, expireInHours, expireDailyAt, neverExpire, keepTTL } from "encore.dev/storage/cache";

await sessions.set(key, value, { expiry: expireInMinutes(30) });  // 覆盖默认过期
await sessions.setIfNotExists(key, value);   // 不存在才写(抢锁场景)
await sessions.replace(key, value);          // 存在才写,miss 抛 CacheMiss

错误与缺失语义:get() miss 返回 undefined(不抛错);replace() miss 抛 CacheMisssetIfNotExists 冲突抛 CacheKeyExists

keyPattern 的工程价值 keyPattern: "flight/:flightNo/:date" 把 Redis 最常见的事故源——手拼 key 时的拼写不一致、参数漏拼——变成编译期问题:键的字段名和类型都在泛型里,拼错编译不过。等于把团队的"Redis key 命名规范文档"直接写成了类型。

三、两原语的选型边界

需求用什么
行程单 PDF、发票、用户上传凭证Bucket(私有 + signedDownloadUrl)
前端静态资源 / 公开图片Bucket(public: true)或 api.static
会话、验证码、限流计数CacheCluster(短 TTL keyspace)
航班搜索结果缓存StructKeyspace + 分钟级过期
必须不丢的业务数据都不是——进 SQLDatabase;缓存与对象存储都不承诺持久语义(volatile 策略下缓存会被驱逐)

四、常见误区

  1. 把缓存当持久存储——驱逐策略随时可能清掉数据,业务真相只能在数据库。
  2. 本地压测缓存容量——本地内存实现 100 键上限,行为与真 Redis 不同。
  3. 大文件走 API 中转上传下载——用 signed URL 直传直下。
  4. 桶设 public: true 存私密文件,靠"URL 猜不到"保安全——私有桶 + signedDownloadUrl 才是正解。
  5. 到处传整个 Bucket 对象——用 ref<权限>() 传最小权限引用,让 IAM 随代码收敛。
  6. get() 后不处理 undefined——miss 不抛错,静默拿到 undefined 继续算会出 NaN 类事故。
  7. 手拼 key 字符串绕过 keyspace——放弃了整个类型安全设计。

五、实践练习

  1. ticket-attachments 私有桶:上传行程单接口(服务端生成 PDF 后 upload)+ 获取下载链接接口(返回 2 小时 TTL 的 signedDownloadUrl)。
  2. 把下载接口改成"前端直传"流程:新增申请上传接口返回 signedUploadUrl,用 curl PUT 一个文件验证。
  3. StructKeyspace 给航班查询接口加 5 分钟缓存:先查缓存、miss 落库再回填,在 trace 里对比命中与未命中的耗时。
  4. IntKeyspace.increment + expireDailyAt 实现"每航线每日搜索次数"统计。
  5. setIfNotExists 实现一个 60 秒的简易分布式锁(对账任务防并发),思考它比数据库行锁弱在哪里。

六、总结

  1. Bucket 七操作覆盖对象存储全场景;public 桶给公开资源,私有桶 + signed URL 给受控访问。
  2. ref<权限>() 把最小权限做成类型参数,IAM 策略由静态分析生成。
  3. CacheCluster 映射 Redis,本地为 100 键内存模拟;驱逐策略即 Redis 原生策略。
  4. 键空间是 Encore 缓存设计的精华:key 结构、值类型、过期策略全部进类型系统。
  5. 缓存不持久、对象存储不查询、数据库不放大文件——三者边界清晰不越位。

请继续阅读:第10章:鉴权


原始资料引用



本章目录
一、对象存储:Bucket二、缓存:CacheCluster 与键空间三、两原语的选型边界四、常见误区五、实践练习六、总结原始资料引用Related Documents
苏ICP备2025204887号-2