Epic Stack 图片存储架构:从 SQLite BLOB 迁移到 Tigris 对象存储的完整实践
【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack
Epic Stack 项目曾在 SQLite 中以 BLOB 形式直接存储上传图片,本篇文章以决策文档 docs/decisions/040-tigris-image-storage.md 为主线,完整还原这一架构决策的背景、迁移方案与落地实现。读完你将掌握:Tigris(S3 兼容对象存储)的完整配置方式、无 SDK 的 AWS SigV4 签名请求实现原理、SQLite 仅存元数据的混合存储模型,以及本地离线开发与测试的 Mock 机制。
一、为什么放弃在 SQLite 中存放图片二进制
在转向 Tigris 之前,Epic Stack 的图片处理遵循 docs/decisions/018-images.md 中的决策:将用户上传的图片以二进制 BLOB 形式直接存入 SQLite 数据库。当时的理由很充分——SQLite 官方甚至专门论证过从数据库读取小文件可能比文件系统更快,配合 LiteFS 还能免费获得多节点复制存储。
但该方案存在明确的硬伤,也正是 040 号决策文档开篇列出的四条核心痛点:
- 数据库膨胀:二进制数据显著增大 SQLite 文件体积,备份复杂度随之上升;
- 性能衰减:大块二进制数据会拖慢数据库整体读写性能;
- 备份变慢:包含二进制数据的 SQLite 备份更大、耗时更长;
- 缺乏 CDN 能力:SQLite 无法为图片分发提供任何边缘加速手段。
此外 018 号文档还补充了一个量级参考:SQLite + LiteFS 的组合经过测试可支撑到 10GB 规模,这对多数应用足够,但"足够"不是 Epic Stack 的追求。同时,旧的方案没有任何图片优化/压缩能力,客户端请求什么原图就返回什么原图(该问题随后由 041-image-optimization.md 引入的 openimg 按需优化解决)。
二、决策:切换到 Tigris 对象存储
2025-02-20,Epic Stack 正式通过 040-tigris-image-storage.md 决策(Status: accepted),核心结论如下:
- 将图片二进制数据从 SQLite 中移出,放入专用的对象存储;
- SQLite 只保留图片的元数据(引用关系、所有权等);
- 借助 Tigris 的 S3 兼容 API 完成图片的高效存取;
- 为大量图片上传的应用提供更好的扩展性。
决策中有一个非常关键的实现取舍:不引入任何 S3 SDK,而是自己用经过认证的 fetch 请求管理上传与下载。这让依赖面保持极轻,也正因为没有 SDK 抽象,底层签名逻辑完全透明可控。
积极影响
- SQLite 体积显著缩小,备份效率提升;
- 关注点分离更清晰(二进制数据 vs 关系数据);
- 借助 Tigris 基础设施获得潜在更优的图片服务性能;
- 对重图片应用更具扩展性;
- 未来接入 CDN 更容易;
- 数据库维护与备份流程简化;
- Tigris 存储成本远低于 Fly volume 存储。
需要接受的代价
- 新增外部服务依赖(但 Fly.io 原生集成,无需额外注册账号);
- 需要管理 Tigris 配置;
- 部署配置略微复杂;
- 图片上传与检索逻辑复杂度上升。
三、配置 Tigris:环境变量与本地 Mock
3.1 必需的环境变量
根据 app/utils/env.server.ts 中的 Zod schema 校验,以下五个变量缺一不可(均为必填字符串,AWS_ENDPOINT_URL_S3还需通过z.string().url()的 URL 格式校验):
AWS_ACCESS_KEY_ID="mock-access-key" AWS_SECRET_ACCESS_KEY="mock-secret-key" AWS_REGION="auto" AWS_ENDPOINT_URL_S3="https://fly.storage.tigris.dev" BUCKET_NAME="mock-bucket"AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY:Tigris 的访问凭据;AWS_REGION:区域,Fly + Tigris 集成场景固定为auto;AWS_ENDPOINT_URL_S3:Tigris 的 S3 兼容端点,Fly 上默认为https://fly.storage.tigris.dev;BUCKET_NAME:对象存储桶名称。
在 Fly.io 上,创建 Epic Stack 项目时会自动为应用创建 storage 并注入这些变量;在本地开发时,这些值写入.env文件,配合 MSW(Mock Service Worker)Mock,整个开发过程可以完全离线运行。
3.2 本地 Mock 的实现
Mock 逻辑位于 tests/mocks/tigris.ts,它拦截指向${AWS_ENDPOINT_URL_S3}/${BUCKET_NAME}/:key*的 PUT 与 GET 请求:
- PUT:校验请求头必须包含
Authorization(以AWS4-HMAC-SHA256开头)、X-Amz-Date、X-Amz-Content-SHA256: UNSIGNED-PAYLOAD,且 Credential 中携带正确的 Access Key,校验通过后将请求体写落到tests/fixtures/uploaded目录; - GET:优先从
tests/fixtures/images读取测试夹具图片,否则回落到tests/fixtures/uploaded,并返回带Cache-Control: public, max-age=31536000, immutable的响应。
这意味着即使没有真实 Tigris 账号,本地开发与端到端测试也能完整体验完整的上传 → 存储 → 读取链路。
四、混合存储模型:元数据在 SQLite,二进制在 Tigris
4.1 数据库 Schema
prisma/schema.prisma 中的UserImage与NoteImage模型与决策文档描述一致,实际实现还比决策文档多了一个altText可选字段(用于图片的替代文本):
model UserImage { id String @id @default(cuid()) altText String? objectKey String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String @@index([userId]) } model NoteImage { id String @id @default(cuid()) altText String? objectKey String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt note Note @relation(fields: [noteId], references: [id], onDelete: Cascade, onUpdate: Cascade) noteId String @@index([noteId]) }两个模型的关键设计:
objectKey是核心桥梁字段:它指向对象在 Tigris 桶中的唯一路径,是 SQLite 与对象存储之间的唯一关联;- 级联删除:
onDelete: Cascade保证用户/笔记被删除时,对应的图片元数据记录一并清除(二进制对象需另行清理); - 外键索引:
@@index([userId])与@@index([noteId])保证按所有者查询图片元数据的性能。
从源码结构看,图片本身不再以字节形式落库,数据库只扮演"引用登记表"的角色,这正是决策文档强调的"混合存储(hybrid approach)"。
五、核心实现:无 SDK 的 AWS SigV4 签名请求
决策文档明确"不使用 S3 SDK",全部实现集中在 app/utils/storage.server.ts,对 Node 内置crypto模块完成 AWS Signature Version 4(SigV4)手工签名。
5.1 对象 Key 的组织策略
上传函数使用cuid2生成唯一 ID,并结合时间戳与原始扩展名构造可读、可归类的 Key:
- 用户头像:
users/${userId}/profile-images/${timestamp}-${fileId}.${fileExtension} - 笔记图片:
users/${userId}/notes/${noteId}/images/${timestamp}-${fileId}.${fileExtension}
这种用户/模块/时间戳-随机ID.扩展名的层级结构天然支持按前缀列出/清理对象。
5.2 签名流程:AWS SigV4 一步步拆解
getBaseSignedRequestInfo是签名核心,它完全手工实现了 SigV4 四步流程:
- 构造规范化请求(Canonical Request):按
HTTP方法 / 资源路径 / 规范化查询串 / 规范化头 / 签名头列表 / 载荷哈希拼接。本项目使用UNSIGNED-PAYLOAD(对内容不做流式哈希,适用于流式上传); - 构造待签名字符串(String to Sign):
AWS4-HMAC-SHA256 + amzDate + credentialScope(dateStamp/region/s3/aws4_request) + sha256(canonicalRequest); - 派生签名密钥(Signing Key):通过
getSignatureKey对AWS4${secretKey}依次做 HMAC(date → region → service → aws4_request); - 生成签名:对待签名字符串做最终 HMAC-SHA256,组装进
Authorization头。
5.3 上传与下载两个方向的封装
上传(PUT):uploadToStorage接受File或@mjackson/form-data-parser的FileUpload,对FileUpload直接使用.stream()流式发送(决策与代码都在追求轻量),并在头部附带Content-Type与X-Amz-Meta-Upload-Date元数据;响应非 2xx 时记录状态码并抛出错误。
下载(GET):getSignedGetRequestInfo(key)复用同一签名基座生成带签名的 GET URL 与头信息,供代理层拉取对象。
5.4 上传在业务层的调用链
签名上传被两处业务逻辑复用:
- 头像上传:app/utils/auth.server.ts 在用户认证/同步流程中调用
uploadProfileImage(user.id, imageFile),并把返回的objectKey写入UserImage记录; - 笔记图片上传:app/routes/users/$username/notes/+shared/note-editor.server.tsx 在笔记表单处理时对每个带文件的图片字段调用
uploadNoteImage(userId, noteId, file),将objectKey存入NoteImage; - 头像设置页面 app/routes/settings/profile/photo.tsx 同样通过
uploadProfileImage完成上传。
六、图片服务出口:本地代理 + 按需优化
决策文档提到"图片 URL 指向本地服务器,由本地服务器代理到 Tigris",这一层实现在 app/routes/resources/images.tsx:
- loader 接收
objectKey查询参数,调用getSignedGetRequestInfo生成带签名 URL,通过 openimg 的getImgResponse以fetch方式回源拉取,并设置Cache-Control: public, max-age=31536000, immutable的强缓存; - 端点在服务图片的同时,还基于 openimg + sharp 提供
w、h、format、fit等按需变换参数,优化结果落盘到/data/images(生产环境)或tests/fixtures/openimg(测试环境)做文件缓存,见 docs/image-optimization.md; - 出于安全考虑,回源域名通过
allowlistedOrigins白名单限制(仅允许当前应用域名与AWS_ENDPOINT_URL_S3)。
这套"签名直连 + 本地代理 + 按需优化"的组合,既避免了将私有存储桶直接暴露给浏览器,又为后续接入 CDN 留好了位置。
七、迁移与向后兼容
决策文档的实施清单明确包含"数据库迁移 + 既有图片手工迁移 + 提供迁移工具"三项工作:
- 设置 Tigris 配置;
- 修改图片上传处理器,将文件存入 Tigris;
- 更新图片检索路由,从 Tigris 提供服务;
- 迁移期间保持向后兼容(需要数据库迁移,以及既有图片的手工迁移);
- 为已有应用提供迁移工具。
对正在升级的存量应用,建议的迁移路径是:先把既有 SQLite 中的 BLOB 图片逐个导出,按新 Key 规则上传到 Tigris 桶,再执行 Prisma 迁移将 BLOB 列替换为objectKey引用,最后在验证图片可正常代理访问后释放数据库中的二进制数据。
八、小结:这套架构带来的工程启示
从 018-images.md 的"先存 SQLite",到 040-tigris-image-storage.md 的"迁往 Tigris",Epic Stack 的图片存储演进完整呈现了一次典型的架构升级路径:当数据形态(二进制 vs 关系型)与业务诉求(扩展性、CDN、成本)不匹配时,果断把关注点拆开。
最终形态值得直接借鉴:
- 关系数据库只存引用:SQLite 表里只有一个
objectKey字符串,体积小、备份快、查询快; - 对象存储只管二进制:Tigris 负责海量字节的存取与分发;
- 无 SDK 的签名实现:约 180 行代码完成 SigV4 签名(app/utils/storage.server.ts),零第三方依赖;
- 本地全离线可测:MSW Mock 让开发者不配真实账号也能跑通全链路(tests/mocks/tigris.ts)。
如果你的应用同样面临"图片越来越多、数据库越来越重"的问题,可以直接复用这套模式:选一个 S3 兼容存储,用 SigV4 签名手写上传下载,数据库只留objectKey,再配一层按需优化代理,即可获得一份轻量、可扩展、可离线开发验证的图片存储方案。
相关文档:docs/decisions/040-tigris-image-storage.md · docs/decisions/018-images.md · docs/decisions/041-image-optimization.md · docs/image-storage.md · docs/image-optimization.md
【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考