第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 | 值类型 | 特有操作 |
|---|
| StringKeyspace | string | get / 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 抛 CacheMiss;setIfNotExists 冲突抛 CacheKeyExists。
keyPattern 的工程价值
keyPattern: "flight/:flightNo/:date" 把 Redis 最常见的事故源——手拼 key 时的拼写不一致、参数漏拼——变成编译期问题:键的字段名和类型都在泛型里,拼错编译不过。等于把团队的"Redis key 命名规范文档"直接写成了类型。
三、两原语的选型边界
| 需求 | 用什么 |
|---|
| 行程单 PDF、发票、用户上传凭证 | Bucket(私有 + signedDownloadUrl) |
| 前端静态资源 / 公开图片 | Bucket(public: true)或 api.static |
| 会话、验证码、限流计数 | CacheCluster(短 TTL keyspace) |
| 航班搜索结果缓存 | StructKeyspace + 分钟级过期 |
| 必须不丢的业务数据 | 都不是——进 SQLDatabase;缓存与对象存储都不承诺持久语义(volatile 策略下缓存会被驱逐) |
四、常见误区
- 把缓存当持久存储——驱逐策略随时可能清掉数据,业务真相只能在数据库。
- 本地压测缓存容量——本地内存实现 100 键上限,行为与真 Redis 不同。
- 大文件走 API 中转上传下载——用 signed URL 直传直下。
- 桶设 public: true 存私密文件,靠"URL 猜不到"保安全——私有桶 + signedDownloadUrl 才是正解。
- 到处传整个 Bucket 对象——用 ref<权限>() 传最小权限引用,让 IAM 随代码收敛。
- get() 后不处理 undefined——miss 不抛错,静默拿到 undefined 继续算会出 NaN 类事故。
- 手拼 key 字符串绕过 keyspace——放弃了整个类型安全设计。
五、实践练习
- 建 ticket-attachments 私有桶:上传行程单接口(服务端生成 PDF 后 upload)+ 获取下载链接接口(返回 2 小时 TTL 的 signedDownloadUrl)。
- 把下载接口改成"前端直传"流程:新增申请上传接口返回 signedUploadUrl,用 curl PUT 一个文件验证。
- 用 StructKeyspace 给航班查询接口加 5 分钟缓存:先查缓存、miss 落库再回填,在 trace 里对比命中与未命中的耗时。
- 用 IntKeyspace.increment + expireDailyAt 实现"每航线每日搜索次数"统计。
- 用 setIfNotExists 实现一个 60 秒的简易分布式锁(对账任务防并发),思考它比数据库行锁弱在哪里。
六、总结
- Bucket 七操作覆盖对象存储全场景;public 桶给公开资源,私有桶 + signed URL 给受控访问。
- ref<权限>() 把最小权限做成类型参数,IAM 策略由静态分析生成。
- CacheCluster 映射 Redis,本地为 100 键内存模拟;驱逐策略即 Redis 原生策略。
- 键空间是 Encore 缓存设计的精华:key 结构、值类型、过期策略全部进类型系统。
- 缓存不持久、对象存储不查询、数据库不放大文件——三者边界清晰不越位。
请继续阅读:第10章:鉴权。
原始资料引用