在引擎之下,离开热路径:D1 如何扩展到数百万个数据库
传统数据库架构假设每个应用有一到两个大型数据库集群,通常还配有连接池解决方案。AI辅助开发和氛围编码平台颠覆了这一模式,推动应用向许多小型数据库发展:每个用户、应用、预览环境或租户一个。
这个模式特别适合D1。D1是Cloudflare基于SQLite的托管无服务器数据库。它允许你创建数百万个数据库,并且只为你使用的部分付费。
问题变成了:如何在不通过集中瓶颈让每个查询变慢的控制平面的情况下,扩展数百万数据库的控制平面?
我们做了大量架构调整,以扩展D1,支持拥有数百万数据库的平台,其中数十万数据库随时活跃。
这篇文章深入探讨了D1控制平面的工作原理。我们将描述的许多架构模式,任何在Cloudflare开发者平台上构建的人都可以使用。文章还展示了我们如何构建自己的控制平面。
人口增长
在我们开始规划这些变化时,D1的使用已经稳步增长,但我们知道一旦大型平台开始搭建D1,我们需要支持更大规模的应用。
事情到目前为止是这样发展的。
D1 已经看到了过去一年中,未删除的存活数据库数量增长了15倍,以及一个过去两年增长了65倍(截至2026年10月8日)。
我们也看到了过去一年每日活跃数据库数量增长了18倍(截至2026年10月8日)。如果数据库当天至少执行了一次查询,则被视为活跃。
成熟且成功的平台已经在使用 D1 构建,使用所有 Cloudflare 用户可用的相同工具。
“ChatGPT Sites让任何人都能将想法转化为可分享的体验。D1为用户提供了一种简单且可扩展的方式,可以在仪表盘、游戏和工具中添加自定义指标、排行榜和评论等功能。”
- Jon Abrams,ChatGPT Sites @ OpenAI
在进行本文所述变更的同时,我们定期对远超预期增长目标进行了负载测试。这指导了优化的重点。
指导工程原则
基于Cloudflare开发者平台的应用涵盖了从全栈应用到服务API、内部单用户门户到大规模多租户氛围编码平台,以及介于两者之间的各种应用。
在构建D1控制平面以支持数百万数据库的过程中,以下原则指导了我们的架构决策,对任何在我们平台上构建控制平面和数据平面的人都很有帮助。
- 所有流程设计时都要考虑失败和幂等性。失败是必然发生的,因此系统需要尽可能自我纠正并持续进步。
- 避免在任何热路径中集中放置单区域组件用于数据平面操作(我们这里是所有查询请求)。
- 用途工人KV,一个全球分布式的KV存储,用于存储关键的路由元数据,使缓存未命中时很少需要查询中央数据库。
- 用途超光速引擎访问 Postgres 中央数据库高效的连接池以及优化路由和缓存,尽可能使用读取副本。
- 定期在数据库中预计算聚合数据,以避免用户请求处理时的扫描查询。
- 利用不需要强一致性的组件之间的最终一致性和排队机制,以提高可靠性和可用性。
- 用真实的工作负载测试应用,发现瓶颈和低效,优先优化这些。
我们内部还有更多工程原则来构建系统,但以上内容足够通用,适用于所有在Cloudflare上构建的玩家。
以下章节将深入探讨D1控制平面关于D1数据库生命周期的主要系统组件和流程,并展示如何在实际系统中应用这些原则。
高级架构
让我们先介绍一下D1的主要组成部分。
图中还有一些支持D1的次级进程的箭头和框缺失,但这些是提供D1公开功能集的部分。我们将在博客文章的专门章节中详细探讨其余部分。
首先,每个D1数据库是由一个SQLite持久对象支持。这使得生成数百万个 S 变得非常容易。
“HTTP API”形状代表我们所有的 API 表面,包括 Cloudflare 仪表盘、Wrangler 和 Cloudflare CLI。所有 API 请求都经过 Cloudflare 的边缘网关,由 D1 Edge API 工作者提供。
图示展示了允许用户对其数据库执行SQL查询的HTTP端点,称为“查询端点”。其余端点包括常规的数据库操作,如创建、删除和列出数据库,以及导入/导出和时间旅行功能。
这些端点被称为“CRUD端点”。
CRUD端点使用Hyperdrive查询我们中央数据中心(称为“核心”)中的Postgres集群。有两种配置。一种配置是否启用了缓存并针对 Postgres 读取副本。
另一种配置禁用缓存,主要针对 Postgres 主节点。
Edge API Worker 可能会调用 D1 Eyeball Worker(取决于请求类型)。Eyeball Worker 是我们所有需要由底层 D1 持久对象(DO)处理请求的代理。
这使我们能够将所有查询处理都放在 Eyeball Worker 中,包括可观察性、自动重试、错误处理及其他查询特定的行为。
Workers KV作为我们边缘的缓存层,用于大量读取的元数据,所有D1 Worker都能读取。
“工人绑定”形状代表所有用户的工人,带有D1 工人装订.来自 D1 绑定的请求直接发送给 D1 眼球工作者,后者再将请求转发给支持数据库的持久对象,并将响应代理回给用户工作者。
我们有两种用户在数据库中执行查询的方法。一种是通过HTTP API查询端点,另一种是通过他们在账户中部署的Worker,并带有D1绑定。
在运算方面,我们将每个运算折叠为以下类别之一:
- 数据平面:任何与数据库相关的查询请求;应尽可能快且始终处于边缘。
- 控制平面:任何响应非查询用户请求的HTTP API操作或内部流;它们可以依赖于中心的单一区域组件。
- 背景异步平面:任何分析和计费相关的工作流程;最终都可以保持一致,并且可以运行于任何地方。
背后还有更多内容,让我们深入解析密钥流。
用户附近快速按需数据库创建
多租户平台和人工智能代理驱动开发需要以快速、经济且可靠的方式创建大量数据库实例。
以下是一个例子Wrangler命令创建D1数据库:
% time wrangler d1 create "demo-01"
✅ Successfully created DB 'demo-01' in region WEUR
...
0.56s user 0.13s system 42% cpu 1.614 total当用户创建D1数据库时,在数据库准备好接收查询之前,需要完成几项工作。
- 检查账户是否能在免费套餐限制或账户限制下创建更多数据库。
- 生成一个唯一的D1数据库UUID,一个独特的耐用对象ID(
newUniqueId()),并用账户和数据库信息初始化底层的持久对象(DO)。 - 在Postgres中持久化新创建数据库的信息(每个数据库一行,包括元数据和初始化的持久对象ID映射)。
- 将数据库ID的映射写入Workers KV中的Durable Object ID(后来数据平面和查询端点使用)。
让我们来看看实际流程,以及我们如何在每一步从故障中恢复。
一旦数据库创建请求进来,我们的Edge API Worker处理器会被调用到该边缘最近的Cloudflare位置。Edge API通过启用缓存(缓存TTL几秒钟)的Hyperdrive读取Postgres副本,以获取该账户未删除数据库的数量。
Edge API Worker 会将控制平面请求转发到我们 Postgres 集群附近的数据中心,针对那些多次往返到 Postgres 的端点(步骤 2),其中“创建数据库”端点就是其中之一。
原因是远距离往返可能需要数百毫秒,而靠近 Postgres 则可能只有几毫秒。对于最多进行一次 Postgres 查询的终端,我们不需要做这种优化,因为 Hyperdrive 已经能通过 Cloudflare 网络高效路由。
创建D1数据库且没有任何选项,默认情况下数据库会靠近请求的位置。你可以通过提供明确的地点提示或管辖权.地点提示和管辖权选项与耐用物品支持,因此当这些数据被提供时,它们会直接传递到底层的持久对象初始化。
默认创建时,我们希望D1数据库创建在请求的原始位置附近,靠近用户。为此,在图的第4步,我们内部将请求执行转发到靠近原始请求位置的数据中心,并从那里调用底层的持久对象。
这确保底层的持久对象位于用户附近。
你可以通过以下方式实现类似的路由技术明确的工人安置提示运行时通过服务绑定调用正确的工作者。
创建端点在放置上有点特殊,因为即使我们至少查询两次 Postgres,我们也会对底层的 Durable Objects 进行远程调用,而这些对象每个请求都不同,所以将请求跑到世界两端可能效率不高。
这就是为什么我们倾向于将初始请求转发到 Postgres 附近。
下一步是生成数据库UUID以及新的独特耐用对象ID使用newUniqueId()API 并调用init(options)RPC 功能在新的耐用物体的残头.持久对象将信息存储在其持久存储中,确保从此点起的每个入请求都包含链接数据库的所有元数据。
一旦Edge API工作者收到初始化响应,它会通过Hyperdrive将数据库元数据持久化到我们的中央Postgres主数据库中,每个数据库只存在一行元数据。
上述步骤被包裹在重试循环中(图中的第5-7步)。当DO初始化或写入Postgres失败时,我们用不同的ID重试(这一点很重要,正如下一节我们将看到的)。
写入Postgres是“创建检查点”,即使最终响应未能送达用户,数据库也被视为已创建。从此,数据库包含在所有控制平面操作中,并可绑定到Worker以执行查询。
此时D1数据库已准备好接收查询。
最终对耐用对象的创建确认RPC发生在waitUntil()并且可以失败。该调用向DO提示数据库现已激活,DO会将数据库元数据更新Workers KV,使后续查询可以立即读取Workers KV缓存中的信息,无需查询Postgres。
一个常见的问题是,为什么创建流程不能完全运行在 Postgres 事务内部,以简化流程?答案很简单,因为它无法扩展,因为这会导致连接在 Durable Object 初始化时被保留,而初始化可能在地球的另一端。
自纠正数据库的心跳
每个D1 Durable Object会定期心跳控制平面,获取最新的数据库状态(活跃或已删除)和账户限制(后面在强制执行部分会详细说明)。
间隔为30秒,顶部会显示每个数据库的抖动。我们还有按数据库或账户更改间隔的旋钮,或完全禁用心跳。
心跳可以容忍略微过时的数据,这意味着它们可以由Hyperdrive的多缓存层或Postgres读副本提供。这种心跳流使持久对象能够自我更正,反映数据库创建和删除的真实来源(Postgres)。
在继续之前,有一个澄清细节。我们在上一节(步骤5)创建流程中,每次重试使用不同的数据库UUID和Durable Object ID的原因,是因为如果我们用新的Durable Object ID重用数据库UUID,多个Durable Object会认为它们拥有同一个数据库,导致难以调试的情况。
周期性的心跳也起到对孤儿对象的清理作用。
此外,在 Postgres 中持久化数据库映射之前,首先要初始化 Durable Object,否则查询请求可能会流向 Durable Object,但由于未初始化状态而被拒绝。
例如,假设以下失效场景及其自我纠正原因:
- 我们执行了上一节的所有操作,直到初始化了持久对象A,然后写入Postgres失败了。
- 工作者重新尝试循环,并初始化一个带有不同 ID B 的新 DO,这次它持续存在于 Postgres。
- 我们现在有两个Durable Object初始化并作为请求的一部分运行,但只有其中一个有效。
- 用户只能查询Durable Object B,因为这是Postgres中唯一可用的ID,而Postgres是我们的真实来源。
- 30秒内,DO A会将下一次心跳发送到控制平面,并收到数据库不存在的回复。这将触发DO A标记自己为已删除。
删除端点也发生类似流程。
心跳的另一种自我纠正行为是确保Workers KV数据最终反映现实:数据库是活跃还是被删除。
创建流程的最后一步是“尽力而为”,即使失败也不会造成问题。最终它是一致的,下一次 Durable Object 心跳时,会确保 Workers KV 项目的值是正确的。
由于 Workers KV 最终是一致的,且无法进行一致的读取,Durable Object 作为写入 Workers KV 项目的序列化点。
删除的原理反过来:Durable Object拥有将“已删除”状态写入Workers KV的权利,在实际删除操作中仍是尽力而为,如果失败,heartbeat最终会修正。
Durable Object也会将激活或已删除状态存储在存储中,因此即使Workers KV已过时允许请求通过,也能立即拒绝或接受查询请求。
总体而言,数据库心跳是操作需要写入外部数据存储并更新持久对象时,达到一致状态的备用机制。
热路径解耦:避免查询进入中央数据库
查询端点的关键原则是避免与 Postgres 或其他集中的单区域组件通信。每次通过 HTTP 的查询请求都会在接近原始用户请求位置的地方执行我们的 Edge API Worker,然后通过 Eyeball Worker 直接发送到 D1 持久对象。
Edge API 工作者负责在实际执行业务逻辑之前,执行标准的 HTTP API 认证、基于资源的授权、账户和数据库层级的速率限制、请求验证、检查账户权限以及其他与 API 相关的功能。
而Eyeball Worker则跳过这些额外检查(除了它自己的专用速率限制),因为D1绑定本身是服务绑定发送给 D1 眼球工作者。每个用户工作者的 D1 绑定都携带编码信息,授权访问一个特定的 D1 数据库,该数据库由 D1 眼球工作者验证并允许请求通过。
下图展示了通过HTTP API执行查询的过程。
- 用户通过HTTP API发送查询请求。
- Edge API Worker 首先尝试从 D1 UUID 加载到 Workers KV 数据库元数据的映射。
- 极少数情况下,该项在 Workers KV 中找不到,可能是因为写入失败,或可能因可用性问题。随后我们回归于 Hyperdrive 读取针对 Postgres 副本的映射。在查询实际副本之前,Hyperdrive 先检查调用者位置的缓存,然后检查靠近 Postgres 集群位置的缓存,只有当两者都缓存未命中时,才会查询 Postgres 副本进行映射。
- 一旦我们获得映射,Edge API Worker 会调用 Eyeball Worker over Worker Service Bindings,传递相关 ID。
- 眼球工作者进行验证,并将查询请求转发给D1持久对象。
- D1 Durable Object 执行 SQL 查询,并通过 Eyeball Worker 返回结果给 Edge API Worker,Edge API Worker 再返回给用户。
// Edge API Worker
const parsed = parseQueries(request);
const cachedDB = await withRetries(getCachedDatabase(parsed.dbUuid));
const response = await env.EYEBALL_WORKER.query({
db: cachedDB,
queries: parsed.queries,
});
return response;如果你想知道为什么我们会使用newUniqueId()在数据库创建过程中,不再用“名称”来称呼持久对象以消除上述步骤2和3,答案是避免往返中央映射的查询在查询请求中,将“name”变为“Durable Object ID”。
仅靠Durable Object ID是不够的,因为我们还需要其他元数据来帮助我们选择合适的Durable Object Namespace用于眼球工作器。因此,我们将一次查找替换为两个。
在大多数情况下,即使是我们许多自家应用,用名称称呼耐用对象效果很好,这样就不用从自己的ID查找到持久的对象ID,所以以这个为起点,只有必要时才切换到唯一的DO ID。
你可能还会好奇,既然我们已经有超驱动缓存,为什么还用Workers KV。
Workers KV是一个持久的数据存储,而不仅仅是缓存:如果某个项目从KV的边缘缓存中掉落,读取将从KV自身的存储中提供,而非Postgres,从而避免Postgres进入数据平面。
在灾难性的缓存清除场景中,百万个数据库试图获取其路由信息将导致亚稳态失败。
提醒一下,来自带有 D1 绑定的用户工作者的查询请求无需解析 D1 UUID 映射元数据,因为绑定本身内部存储了该信息,因此请求会直接发送给眼球工作者和持久对象。
关于查询执行本身以及持久对象在底层的实现,阅读我们关于D1的博客文章 阅读复制它深入探讨了所有细节。
接下来,我们来看支持 D1 的异步进程。
执行存储使用限制
所有达到一定规模的平台最终都需要强制执行计费和使用限制。撰写本文时,D1 拥有每个账户5GB存储限制免费套餐和1TB工人付费计划(可按要求提高)。
总体而言,使用限额执行和计费系统是实现最终一致性和异步处理的良好候选方案,接下来我们将探讨D1如何实现这些限制的执行。
带队列的异步更新处理
旅程的第一步是每个持久对象将存储使用更新发布到类似 Kafka 的队列,用于批处理和异步处理。控制平面不需要同步处理每一次更新:即使加载峰值没有立即反映,使用信息也未被及时反映,也没关系。
重要的是,它不能让 Postgres 过载,影响 D1 用户。我们通过限制队列消费者数量,从而限制 Postgres 的写入负载来确保这一点。
队列消费者系统运行在 Cloudflare 的中央数据中心(“核心”)内部,而持久对象本身则分布在全球各地的边缘。使用数据会在每个边缘服务器本地汇总,然后发布到内部队列系统中。
这里的指导原则是,我们使用队列来平滑并批量写入我们中央区域的Postgres数据库。
批处理使用差异与预聚合
队列消费者会将每个数据库最新报告的大小写入 Postgres 表。但这并不是我们执行决策所需的。执行决策需要整个账户所有存储使用量的总和。
当执行时读取使用情况时,我们负担不起运行SELECT sum(storage) FROM database_stats WHERE account_id = ?.这种查询的成本会随着每个账户数据库数量的增加线性递增。
对于拥有数百万个D1数据库的账户,比如一些用D1构建的代理编码平台,这种成本是不适用的。
理想情况下,我们希望有一个查找表,能让我们直接从账户ID到数据库总存储。解决方案是使用增量视图维护来预先计算总存储,并在数据库存储变更时保持更新。
我们把这个表格叫做account_database_stats.
理论上,我们可以让队列的消费者更新database_stats以及account_database_stats每一次更新都会有自己的写入。实际上,对于大型账户来说,这会导致单行大量写锁定争夺。
相反,我们增加了第三张表,称为account_database_stats_changes它就像一个变更队列。这是所有变更的日志,每个账户可以有多个条目。
然后我们会定期从该表汇聚更新到account_database_stats.流向如下:
- 队列消费者将使用信息写入
database_stats为特定数据库提供表。 - 触发器计算该数据库存储的差值,并将该差值放入
account_database_stats_changes表格(每个 delta 一行)。 - 一个定时作业每秒运行一次,消耗所有内容
account_database_stats_changes以及更新account_database_stats乘以这些差值之和。
具体来说,调度作业执行以下查询:
WITH updates AS MATERIALIZED (
DELETE FROM account_database_stats_changes
RETURNING *
),
changes AS MATERIALIZED (
SELECT
account_id,
SUM(storage_change) AS storage
FROM updates
GROUP BY account_id
)
INSERT INTO account_database_stats (account_id, storage)
SELECT
account_id,
storage
FROM changes
ON CONFLICT (account_id) DO UPDATE
SET storage = account_database_stats.storage + EXCLUDED.storage;它会收到来自以下的更新列表account_database_stats_changes按账户汇总,然后将 delta 写入每个账户的表。这样可以避免对特定行的写入争用,因为我们在写入前会汇总这些变更。
该公共表表达式(CTE) DELETE ... RETURNING原子处理待处理的更改,并将删除的行传递给下一个 CTE。Postgres 只执行一次数据修改 CTE,并执行完成。
查询随后按账户分组这些变更,并为每个账户应用一次汇总更新,减少写入争用。
这种模式设计是许多其他Cloudflare团队采用的模式,它让我们能够扩展到每个账户数百万资源,而不会让每增加一个资源都会使普通操作变得更昂贵。
值得注意的是,总数最终是一致的。这不像其他数据库中那种一流的增量视图维护。我们已经用队列将持久对象的更新导入Postgres,所以我们已经接受最终一致性。
通过超光驱缓存读取
我们现在有了每个账户总存储的查找表。接下来呢?
每个D1 Durable Object都会定期通过我们之前提到的周期心跳读取账户的使用情况。我们查询PostgresSELECT storage FROM account_database_stats WHERE account_id = ?并与账户的权限和等级限制进行比较。
如果总额超过限制,我们禁止所有会增加数据库大小的写入。
查询通过 Hyperdrive 访问 Postgres 副本,并使用 Hyperdrive 内置缓存。Hyperdrive 的缓存键由查询和所有参数值组成,存活时间为几秒钟。
通过使用账户 ID 作为唯一参数,该账户上的每个数据库共享相同的缓存值,这正是我们想要的。
是时候来个巧妙的缓存技巧了。通过超光驱缓存读取默认有60秒的有效期,而且超光驱缓存配置适用于所有查询。对于特定查询,我们希望缓存值且有效期更短。
通过添加一个dynamic_ttl查询参数,我们每X秒生成一个不同的缓存键,因此能更快读取新值。
-- $2 = Math.floor(Date.now() / (X * 1000))
SELECT storage, $2 as dynamic_ttl
FROM account_database_stats WHERE account_id = $1可能会很诱人地用账户ID和数据库ID作为参数查询。这样你就能找到账户总数,并减去当前数据库的总数。这样可以准确回答“这个数据库允许使用多少存储空间才开始强制执行?”这个问题,从而让数据库更早开始执行限制。
这样做意味着数据库ID会被包含在Hyperdrive的缓存密钥中,这会使缓存命中率非常低。对于拥有数百万活跃数据库的账户,基于账户的缓存密钥意味着大多数心跳使用Hyperdrive的缓存账户信息,且永远无法到达Postgres。
限制执行在对使用变化的反应上略慢,但这是可以接受的。
除了上述流程,我们还有一个过程定期将 Postgres 的存储使用信息快照到 ClickHouse 集群中,以便我们获得存储时间的历史视图。这些 ClickHouse 事件也是存储使用计费的真实来源。
在边缘执行行使用限制
D1 还对免费套餐客户强制执行每日执行使用限制。我们这里使用的模式不同,也值得一提。
每当在持久对象中执行SQL查询时,我们会跟踪它读写了多少行(如有需要还包括索引更新)以执行查询。查询执行完成后,我们发布一个使用事件,最终会进入另一个ClickHouse集群。
这些事件驱动了基于行的使用计费。
一个核心组件,称为“限制器”,运行在我们的核心数据中心,自当天开始定期查询ClickHouse的使用事件:
SELECT account_id, sum(rows_read) as reads, sum(rows_written) as writes
FROM requests
WHERE timestamp > toStartOfDay(now())
GROUP BY account_id HAVING reads > ? or writes > ?ClickHouse比Postgres更擅长处理这类查询,而且我们也受益于不会让Postgres集群因分析查询而过载。
对于每个超额账户,我们都会检查他们的权限和等级(该限制仅适用于免费用户)。如果超过限制,我们会通过邮件(全天进行去重处理)发送,并设置密钥水银,我们内部的全球分布密钥值存储,具有快速的亚秒复制,到期日在一天结束。
如果账户升级到工人付费层,我们会收到队列中的消息并清除Quicksilver密钥。
每个D1耐用对象都会定期在边缘检查Quicksilver,以确定其账户是否被限制,如果有限制,它们会拒绝任何未来的查询执行,直到限制被解除。
开发者平台的开发者可以使用工人KV即时,该项目建立在水银之上。
这种模式避免了整个流程中对区域 Postgres 的负载,并且使用合适的工具完成正确的任务。用 ClickHouse 做分析查询比用 Postgres 更高效。
这种模式在公司内很常见,但与我们用于存储使用限制的强制方法不同。未来,我们可能会迁移其他限制执行方式,采用这种模式。
结论
D1架构的设计旨在将控制平面的关注点与热路径查询执行分离。
每项决策都遵循我们的指导原则:减少中心区域瓶颈,使用缓存和读取副本,并尽可能实现一致性以扩展读取吞吐量,并为每个作业选择合适的数据存储:Postgres 用于交易元数据,ClickHouse 用于分析,Workers KV 用于全球复制路由数据。
如果你正在构建一个氛围编码平台,并希望以快速、经济且可靠的方式为用户提供托管的SQLite数据库,可以考虑使用D1。
如果你是在Workers和Developer Platform上构建应用,这里涵盖的模式和权衡反映了D1的真实决策。把它们作为你自己应用的基础。
看看D1文献,并告诉我们你在D1 Discord频道!
致谢
非常感谢Cloudflare内部数据库和超光速团队,他们帮助我们发现了问题使用模式并提出了改进建议。