对象存储就是你需要的一切

Hacker News Top 产品

摘要

Tigris 展示了如何使用其对象存储作为数据库替代品,通过在基于FoundationDB构建的分布式键值存储上直接实现唯一约束和事务等基本原语。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/09/10 14:22

# 对象存储就是你需要的一切 | Tigris对象存储 来源:https://www.tigrisdata.com/blog/object-storage-all-need/ 背景 Tigris基于我们构建在FoundationDB(一种分布式键值存储)之上的数据库引擎来提供对象存储服务。Ampbase创始人JP使用了一个完全不依赖底层数据库的控制平面,今天他将详细介绍他不得不自行实现哪些数据库行为以及为此付出的代价。 感谢JP! 在上一篇Ampbase博客文章(https://ampbase.io/blog/posts/why-we-built-ampbase/)中,我介绍了我们未使用的所有数据库引擎,并承诺会跟进解释我们实际采用的方案。我们不使用关系型数据库,而是直接将Tigris作为存储层,并在其提供的两个基本操作之上实现我们实际需要的少数数据库行为。是的,是的,我知道;"我们不需要数据库"这个标题很吸引人,但这种情况通常在不可避免的下一篇"我们如何夹着尾巴逃向Postgres"文章发表前大约八个月出现。 实际上,当你选择数据库引擎时,你真正需要的是四个基本功能:唯一约束、事务、索引和历史表。为了将Tigris的全局对象存储用作数据库,我们不得不自行实现所有这些原语。今天我将揭开帷幕,向你展示这些原语的工作原理,让你理解构建你选择的数据库引擎背后实际发生了什么。 ## 实际存储内容 (https://www.tigrisdata.com/blog/object-storage-all-need/#whats-actually-stored) 所有内容存在于两层存储桶中。 一个全局的`directory`桶存储组织列表,每个组织拥有自己的桶。其中四个键承担了数据库通常为你完成的任务,因此在这里标记并在下一节详细分析: 图01:两层结构及每个键实现的原语 ``` ┌──────────────────────────────────────────────────────┐ │ directory桶 单一,全局 │ │ │ │ orgs/{org_id}/ │ │ metadata.json │ │ members/{sha256(email)}.json │ 索引 │ billing.json │ 比较并交换目标 │ channels/{channel_id}/metadata.json │ │ api-tokens/{token_id}.json │ │ events/audit/{event_ulid}.pb │ 审计日志 │ org-ops/queue.pb │ └──────────────────────────────────────────────────────┘ 提供商凭证访问此层,且仅此一层 ┌──────────────────────────────────────────────────────┐ │ 组织桶 每个客户一个 │ │ │ │ channel-slugs/{slug}.json │ 唯一约束 │ channel-{channel_id}/ │ │ config-meta/{config_id}.json │ │ config-versions/{version_ulid}.json │ 历史表 │ bundle-meta/{bundle_id}.pb │ │ bundle-versions/{bundle_id}/{version_ulid}.pb │ │ active-config.pb │ 指针,就地覆写 │ events/{event_ulid}.json │ └──────────────────────────────────────────────────────┘ 该客户的作用域键访问此层,且仅此层 ``` 起初我们用JSON对象将所有内容写入每个客户的桶中。后来随着API中越来越多功能采用protobuf选项(https://protobuf.dev/programming-guides/proto3/#options),我们得以在模式定义旁(https://protovalidate.com/)定义验证规则等。所有JSON的序列化和反序列化成本比我们预期的更高,因此我们切换为直接使用Protocol Buffers。我们的数据库同时处理这两种格式,因此如果记录早于protobuf迁移,一切都能按预期加载。 通过使用Protobuf,我们消除了管理数据库层的整个问题:迁移、连接、模式。唯一的缺点是Protobuf字段名一旦确定就永久不变,但公平地说,在PostgreSQL、MySQL或SQLite中更改列名同样痛苦。 天真地为每个客户创建桶的方式是在同一$bigcloud账户下为每个客户创建桶,每次达到配额限制时就创建新账户。或者在一个桶中使用前缀来规避每个账户的桶限制,并依赖IAM策略中的复杂性来强制隔离。这一切听起来相当乏味,而Tigris恰好为此类场景提供了合作伙伴集成计划(https://www.tigrisdata.com/docs/partner-integrations/)。一次调用即可为该客户创建Tigris组织、其桶以及一组作用域限定的访问密钥。我们持有提供商身份;每个客户都是其下的一个组织,具有强隔离性。 隔离性内置于基础设施层:无需在应用代码中编写`WHERE org_id = ?`这类可能被遗忘的语句,因为访问一个客户数据的凭证无法访问任何其他人的数据。作为一个过去构建过几个管理数据库平台的人,我知道这是大多数人搞砸的地方。 除了隔离,如果对象存储要真正替代我们的数据库,我们还需要类似数据库的行为。但如何用对象存储的简洁性获得类似数据库的行为?你可以利用强读后写一致性、条件写入和其他原语作为一切的基础。 ## 对象存储的简洁性与数据库类似行为 (https://www.tigrisdata.com/blog/object-storage-all-need/#database-like-behavior-with-the-simplicity-of-object-storage) 你可以从对象存储中获得数据库的所有重要保证。不相信我,说我将从你们冰冷的手中夺走PostgreSQL?请继续阅读。 你数据库所需的一切(以及对象存储所需的)包括: - 强读后写一致性 - 条件写入 - 唯一性约束 - 事务 - 索引 - 历史表 我们依赖Tigris提供现成的强读后写一致性和条件写入,但自行实现了其他四项。 ### 强读后写一致性 (https://www.tigrisdata.com/blog/object-storage-all-need/#strong-read-after-write-consistency) 每个人都期望数据库具有强、读后写一致性。对象存储直到2020年12月左右(https://aws.amazon.com/about-aws/whats-new/2020/12/amazon-s3-now-delivers-strong-read-after-write-consistency-automatically-for-all-applications/)才拥有强一致性模型。如果你想了解更多关于他们如何为对象存储添加强一致性的内容,Werner Vogels有一篇精彩的详细说明(https://www.allthingsdistributed.com/2021/04/s3-strong-consistency.html)深入探讨了技术细节。 Tigris给我们带来的主要好处是桶*数据*是全局的,而不仅仅是桶*名称*是全局的。这意味着当两个客户端在同一区域时数据是强一致的,但一旦跨区域(https://www.tigrisdata.com/blog/conflict-resolution-is-fun/)就会变得复杂。全局复制意味着最终一致性,即系统同步变更时有时可能会暂时不一致。 我们的一个核心问题是确定我们到底需要强一致性的场景,以及最终一致性足够好的场景。 ### 条件写入 (https://www.tigrisdata.com/blog/object-storage-all-need/#conditional-writes) 条件写入本质上是比较并交换。 Tigris支持写入时的HTTP预条件(https://www.tigrisdata.com/docs/objects/conditionals/):`If-None-Match: *`仅在键不存在时写入,而`If-Match: {etag}`仅在对象自读取后未更改时写入。两者都根据对象的最新状态进行评估,且在你的桶位置类型(https://www.tigrisdata.com/docs/buckets/locations/)所提供的任何一致性模型下进行。一致性模型的选择稍后会变得更加重要。 但这里重要的是,一旦你拥有了比较并交换,你基本上就拥有了每个数据库底层的核心原语。 ### 无需UNIQUE的唯一性 (https://www.tigrisdata.com/blog/object-storage-all-need/#uniqueness-without-unique) 考虑数据库索引的两个属性使其查找数据更高效:预计算查找和确保相同数据不能存储两次。这就是`CREATE INDEX`和`CREATE UNIQUE INDEX`之间的区别。大多数时候,你不会在主键或UUID上创建索引来使查找更高效,你创建它们是为了确保不能存储两次相同的用户电子邮件地址或唯一标识符。 为了在我们的数据库中获得索引的唯一性属性,我们利用了内容感知存储与条件写入的结合。这确保了相同的内容不会被存储两次。 我们尝试通过在PutObject调用中传递`If-None-Match: *`头来创建渠道或用户。这告诉Tigris如果该键中已存储了任何内容,则拒绝数据。当两个应用实例尝试向同一位置写入不同数据时,Tigris会决定哪个胜出,并向失败者返回错误,我们处理此错误并报告给用户: ```go switch { case err == nil: return nil case isPreconditionFailed(err): // 另一个写入者并发创建了slug。重新读取以确定 // 这是幂等的(相同的channelID)还是冲突的映射。 return s.handlePutConflict(ctx, slug, channelID, err) } ``` 恼人的是,这不会告诉客户端*为什么*他们输掉了竞争。在实践中,这可能意味着另一个应用实例首先写入了那里,多区域桶(https://www.tigrisdata.com/blog/conflict-resolution-is-fun/#multi-region-buckets)写入的部分失败可能有点问题,或者重试失去了控制且未以其他方式暴露出来。唯一弄清楚发生了什么的方法是从数据库中读取数据。不这样做会给用户带来一个非常混乱的情况,他们因为自己刚刚创建了而无法再次创建某些内容。这是你只有在分布式系统中才会遇到的那种问题。当你大声说出来时完全讲不通,以至于难以处理,因为你缺乏表达它的时间相对性结构。计算机不是很棒吗? ### 无事务的变更 (https://www.tigrisdata.com/blog/object-storage-all-need/#mutation-without-transactions) 账单状态是每个组织一个JSON对象,有几个东西会写入它:Stripe webhook、保留清扫器和生命周期电子邮件工作器。其中两个同时触发并不常见但完全可能,丢失更新意味着客户的订阅状态静默地错误,这种错误你会从客户那里发现。 没有事务,你就得到乐观并发:读取对象及其ETag,计算新状态,仅在ETag仍匹配时写回,如果不匹配则从新读取重试。 ```go // 什么都没读取到,所以仅在键仍不存在时创建;读取到版本, // 因此仅替换该版本。 func precondition(etag string) (ifMatch, ifNoneMatch *string) { switch etag { case "": return nil, aws.String("*") default: return aws.String(etag), nil } } switch _, err := s.client.PutObject(ctx, in); { case isPreconditionFailed(err): return nil, err // 从新读取重试 } ``` 退避从10ms增加到100ms,上限五次尝试,因为这里的争用很少,冲突应该在第一次重试时解决。如果五次内没有解决,那就是有问题,第六次尝试也无法解决。 不太明显的约束在函数签名中。调用者传递一个`mutate`函数,因为该函数在每次尝试时都会针对新读取的状态重新运行,所以它必须是其输入的纯函数。其中的任何副作用(一封电子邮件、一个计数器、一个Stripe调用)每次尝试发生一次,而不是每次更新发生一次。这个要求不是由编译器强制执行的。它由注释和下一位审查者强制执行。 ### 无索引的索引 (https://www.tigrisdata.com/blog/object-storage-all-need/#indices-without-an-index) `members/{sha256(email)}.json`看起来像是出于隐私原因的哈希。其实不是。它是一个索引。 “这个用户是这个组织的成员吗?”是每个经过身份验证的请求都会问的问题。将电子邮件哈希到键中,答案就是本地计算路径上的一次`GetObject`。无需列表、扫描,也无需保持同步的二级索引。键*就是*查找。 这是整个设计模式,也是设计的中心限制,我稍后会回到这一点:你可以获得O(1)访问,但仅限于你提前想到的问题。 ### 无历史表的历史 (https://www.tigrisdata.com/blog/object-storage-all-need/#history-without-a-history-table) 配置版本在ULID键下是仅追加的,这是存储模型不再仅仅是变通方案,而是开始优于其替代品的地方。 ULID按键创建时间的词法顺序排序。对象存储按键的词法顺序列出键。因此“此渠道中的所有配置更改,按顺序”是一个前缀列表,而“周二到周四之间发生的一切”是在一个已经为你排序的键空间上进行的范围读取。没有索引,没有`ORDER BY`,也没有人忘记索引的`created_at`列。 版本对象和事件从不重写,因此审计跟踪不是任何人实现的功能。它是因为没有代码路径向这些键写入两次的结果。历史记录不会被粗心的`UPDATE`丢失,因为根本没有`UPDATE`。 *确实*被覆写的是指针:配置当前服务的版本,以及每个渠道代理读取的已部署配置对象。这些是设计上可变的,精确地说,不可变性保证涵盖的是发生事情的记录,而不是当前状态的声明。部署是指针移动;回滚是向另一个方向进行的相同移动。旧版本从未消失,因为从未被要求删除它。 ## 实际问题所在 (https://www.tigrisdata.com/blog/object-storage-all-need/#where-it-actually-breaks) 如我之前提到的,这种架构在白板上效果很好,但在现实世界中会有一些问题。以下是我们遇到的一些最大痛点,大致按造成伤害的程度排序。 ### 读放大是真正的代价 (https://www.tigrisdata.com/blog/object-storage-all-need/#read-amplification-is-the-real-cost) 控制平面,在我们代码中是主应用,每个区域运行N ≥ 2个实例,并且每个实例在每个请求上都读取相同的目录桶:令牌验证、组织元数据、成员RBAC、刷新令牌轮换。N个实例意味着你为每次读取支付N次。更糟的是扇出。列出成员、邀请、API令牌或Webhooks是一次`ListObjectsV2`调用,后面跟着每个结果一次`GetObject`。这意味着一次页面加载需要数十次跨越网络服务的往返,而PostgreSQL只需要一次查询和一个你不会多想的连接。 答案与你使用数据库时采用的方法并无不同。你在它前面放置一个读穿透缓存。这就是工作流程,技术选择是偏好问题:Valkey、DragonflyDB、Memcached、Redis,缓存选择很多。我们仍在为放大付出代价,这可能……

相似文章

SQLite:持久化工作流的全部所需

Hacker News Top

这篇博文认为,SQLite 结合 Litestream 进行异步备份,为许多工作流系统(尤其是 AI 智能体)提供了一种简单而有效的持久化执行方法,无需单独编排层或网络数据库。

我们如何为SQLite构建零磁盘、S3分层存储引擎

Lobsters Hottest

Rivet工程师详细介绍了他们如何为SQLite构建零磁盘、S3分层的存储引擎,实现了隔离数据库,具备即时启动、低延迟写入、时间点恢复和无限存储能力,可支持数百万Actors。

SQLite:万能数据库解决方案

Lobsters Hottest

本文倡导将SQLite视为一种多功能且稳定的数据库解决方案,强调其在不同技术栈中替代多种其他工具的能力。