构建 Worker 应用的开发人员专注于他们正在创建的内容,无需关注所需的基础设施,并从 Cloudflare 的全球 网络中获益 。从个人项目到关键业务工作负载,许多应用都需要持久数据。Workers 提供各种根据开发人员的需求量身定制的数据库和存储选项,例如键值和对象存储。
关系数据库是当今许多应用程序的支柱。 D1——Cloudflare 的关系数据库——现已正式推出。从 2022 年底 alpha 版到 2024 年 4 月正式发布,我们一直专注于让开发人员能够通过熟悉的关系数据和 SQL 来构建生产工作负载。
什么是 D1?
D1 是 Cloudflare 内置的无服务器关系数据库。对于Worker 应用程序,D1 利用 SQLite 的 SQL 方言提供 SQL 的表达能力,并提供开发人员工具集成,包括对象关系映射器(ORM),例如 Drizzle ORM。可通过 Workers 或 HTTP API 访问 D1。
无服务器意味着无需配置,基于 Time Travel 的默认灾难恢复, 以及 基于使用量的定价。D1 包括一个慷慨的免费层,允许开发人员试用 D1,然后将这些试用升级到生产。
如何使数据全球化?
D1 正式版注重可靠性和开发人员体验。 现在我们计划扩展 D1,以更好地支持全球分布式应用程序。
在 Workers 模式中,入站请求会在最接近的数据中心调用无服务器执行。 Worker 应用程序可根据用户请求进行扩展到全球。 然而,应用数据依然存储在集中式数据库中,全球用户流量必须考虑到数据位置的访问往返。 例如,现在的 D1 数据库位于单一位置。
Workers 支持Smart Placement ,以考虑频繁访问的数据位置。Smart Placement 会调用更靠近中央后端服务(如数据库)的 Worker,以降低延迟并提高应用程序性能。我们已经解决了全球应用程序中 Workers 的放置问题,但还需要解决数据放置问题。
那么问题来了,作为 Cloudflare 的内置数据库解决方案,D1 如何才能更好地支持全球应用程序的数据放置? 答案就是异步读复制。
什么是异步读复制?
在基于服务器的数据库管理系统(如 Postgres、MySQL、SQL Server 或 Oracle)中, 读副本 是一个单独的数据库服务器,作为主数据库服务器的一个只读、几乎最新的副本。管理员创建读副本的方法是,从主服务器的快照启动一个新服务器,并配置主服务器以异步方式向副本服务器发送更新。由于更新是异步的,因此读取副本可能会滞后于主服务器的当前状态。主服务器和副本之间的差异称为副本延迟。读副本可以不止一个。
异步读复制是一种久经考验的数据库性能提高解决方案:
通过在多个副本之间分配负载可以提高吞吐量。
当副本靠近进行查询的用户时,就有可能降低查询延迟。
请注意,有些数据库系统也提供同步复制功能。 在同步复制系统中,写入必须等到所有副本都确认写入后才能进行。 同步复制系统的运行速度只能达到最慢复制的速度,当一个复制失败时,系统就会停止运行。 如果我们要在全球规模上提高性能,就必须尽可能避免同步复制!
一致性模型 & 读副本
大多数数据库系统都提供 读已提交、 快照隔离或 可序列化的一致性模型,具体取决于配置。例如,Postgres 默认为读已提交模型, 但可以配置为使用更强的模型。SQLite 在 WAL 模型下提供快照隔离。快照隔离或可序列化等较强模型更易于编程,因为它们限制了允许的系统并发情景和程序员需要担心的并发竞态条件。
读副本是独立更新的,因此每个副本的内容在任何时刻都可能不同。如果所有查询都在同一台服务器上进行,无论是主服务器还是读取副本,那么查询结果都会根据底层数据库提供的任何一致性模型保持一致 。如果您使用的是读副本,结果可能有点过时。
如果一个基于服务器的数据库使用读副本,一个会话中的所有查询都使用同一个服务器是很重要的。如果在同一会话中切换不同的读副本,就会破坏应用程序提供的一致性模型,这可能会违反您对数据库运行方式的假设,并导致应用程序返回不正确的结果!
示例例如,有两个副本 A 和 B。副本 A 比主数据库滞后 100 毫秒,副本 B 比主数据库滞后 2 秒。 假设用户希望
执行查询 11a. 根据查询 1 的结果进行计算
根据 (1a) 中的计算结果执行查询 2
在 t=10 秒时,查询 1 进入副本 A 并返回。 查询 1 可以看到主数据库在 t=9.9 秒时的样子。 假设计算需要 500 毫秒,那么在 t=10.5 秒时,查询 2 访问副本 B。请记住,副本 B 比主数据库滞后 2 秒,因此在 t=10.5 秒时,查询 2 看到的是数据库在 t=8.5 秒时的样子。 就应用程序而言,查询 2 的结果看起来就像数据库在时间上倒退了!
从形式上看,这就是 读已提交一致性,因为你的查询只能看到已提交的数据,但没有其他保证,甚至不能保证你能读取自己写入的数据。虽然读已提交是一种有效的一致性模型,但很难推理读已提交模型所允许的所有可能竞态条件,因此很难正确的编写应用程序。
D1 的一致性模型 & 读副本
默认情况下,D1 使用 SQLite 所提供的快照隔离。
快照隔离是一种熟悉的一致性模型,大多数开发人员都认为这种模型易于使用。我们在 D1 中实现这种一致性模型的方法是,确保 D1 数据库只有一个活动副本,并将所有 HTTP 请求路由到该单一数据库。虽然确保 D1 数据库最多只有一个活动副本是一个棘手的分布式系统问题,但我们通过使用 Durable Objects 构建 D1 解决了这个问题。Durable Objects 保证了全局唯一性,因此一旦我们依赖 Durable Objects,路由 HTTP 请求就变得非常简单了:只需将它们发送到 D1 Durable Object 即可。
如果数据库有多个活动副本,这种方法就不管用了,因为没有 100% 可靠的方法来查看传入的 HTTP 请求,并将其 100% 地路由到相同的副本。 不幸的是,正如我们在上一节的示例中看到的,如果我们不能在 100% 的时间内将相关请求路由到同一个副本,那么我们所能提供的最佳一致性模型就是读已提交。
鉴于不可能一致地路由到特定副本,另一种方法是将请求路由到任何副本,并确保所选副本根据对程序员 "有意义 "的一致性模型响应请求。如果我们愿意 在请求中包含 Lamport 时间戳 ,就可以使用任何副本实现 顺序一致性。顺序一致性模型具有“读取我自己的写入”、“写入跟随读取”以及写入全排序等重要属性。写入全排序意味着每个副本都会看到事务以相同的顺序提交,这正是我们在事务系统中希望看到的行为。顺序一致性有一个限制条件,那就是系统中的任何单个实体都可能任意过时,但这一限制条件对我们来说是一个特征,因为它允许我们在设计API 时考虑副本滞后的问题。
我们的想法是,如果 D1 为应用程序的每次数据库查询提供一个 Lamport 时间戳,而这些应用程序告诉 D1 它们看到的最后一个 Lamport 时间戳,我们就可以让每个副本根据顺序一致性模型来决定如何让查询工作。
对副本实现顺序一致性,一种稳健但简单的方法是:
将 Lamport 时间戳与对数据库的每一个请求相关联。 单调递增的提交令牌就很好地解决了这个问题。
将所有写入查询发送到主数据库,以确保写入全排序。
向任意副本发送读取查询,但让副本滞后服务查询,直到副本从主数据库接收到晚于查询中 Lamport 时间戳的更新。
这种实现方式的优点是,在读取量大的工作负载总是访问同一个副本的常见用例中,速度很快,而且即使请求被路由到不同的副本,它也能正常工作。
抢先预览
为了将读复制引入 D1,我们将使用一个新概念来扩展 D1 API : 会话。会话封装了代表应用的一个逻辑会话的所有查询。例如,一个会话可能代表来自特定 Web 浏览器的所有请求或来自移动应用程序的所有请求。如果使用会话,您的查询将使用对您的请求最有意义的 D1 数据库副本,无论是主数据库还是附近的副本。D1 的会话实现将确保会话中所有查询的顺序一致性。
由于会话 API 改变了 D1 的一致性模型,开发人员必须选择使用新的 API。 现有的 D1 API 方法不变,仍将采用与以前相同的快照隔离一致性模型。 不过,只有使用新的会话 API 进行的查询才会使用副本。
下面是 D1 会话 API 的示例:
D1 的会话实现使用提交令牌。 提交令牌标识了已提交到数据库的特定查询。 在会话中,D1 使用提交令牌确保查询按顺序排列。 在上面的示例中,D1 会话确保 “SELECT COUNT(*)” 查询发生在新顺序的 “INSERT” 之后 ,即使我们在等待之间切换副本也是如此。
export default {
async fetch(request: Request, env: Env) {
// When we create a D1 Session, we can continue where we left off
// from a previous Session if we have that Session's last commit
// token. This Worker will return the commit token back to the
// browser, so that it can send it back on the next request to
// continue the Session.
//
// If we don't have a commit token, make the first query in this
// session an "unconditional" query that will use the state of the
// database at whatever replica we land on.
const token = request.headers.get('x-d1-token') ?? 'first-unconditional'
const session = env.DB.withSession(token)
// Use this Session for all our Workers' routes.
const response = await handleRequest(request, session)
if (response.status === 200) {
// Set the token so we can continue the Session in another request.
response.headers.set('x-d1-token', session.latestCommitToken)
}
return response
}
}
async function handleRequest(request: Request, session: D1DatabaseSession) {
const { pathname } = new URL(request.url)
if (pathname === '/api/orders/list') {
// This statement is a read query, so it will execute on any
// replica that has a commit equal or later than `token` we used
// to create the Session.
const { results } = await session.prepare('SELECT * FROM Orders').all()
return Response.json(results)
} else if (pathname === '/api/orders/add') {
const order = await request.json<Order>()
// This statement is a write query, so D1 will send the query to
// the primary, which always has the latest commit token.
await session
.prepare('INSERT INTO Orders VALUES (?, ?, ?)')
.bind(order.orderName, order.customer, order.value)
.run()
// In order for the application to be correct, this SELECT
// statement must see the results of the INSERT statement above.
// The Session API keeps track of commit tokens for queries
// within the session and will ensure that we won't execute this
// query until whatever replica we're using has seen the results
// of the INSERT.
const { results } = await session
.prepare('SELECT COUNT(*) FROM Orders')
.all()
return Response.json(results)
}
return new Response('Not found', { status: 404 })
}
关于如何在 Workers fetch handler 中启动会话,有几个选项。 db.withSession(<condition>)
接受这些参数:
condition
参数
行为
<commit_token>
(1) 从给定的提交令牌启动会话
(2) 后续查询具有顺序一致性
first-unconditional
(1) 如果第一个查询是读取,则读取当前副本的任何内容,并将读取的提交令牌作为后续查询的基础。 如果第一个查询是写,则将查询转发给主数据库,并将写的提交令牌作为后续查询的基础。
(2) 后续查询具有顺序一致性
first-primary
(1) 针对主数据库执行第一次查询、读取或写入
(2) 后续查询具有顺序一致性
npx wrangler d1 create northwind-traders
# omit --remote to run on a local database for development
npx wrangler d1 execute northwind-traders --remote --file=./schema.sql
npx wrangler d1 execute northwind-traders --remote --file=./data.sql
null
或缺少参数
# database schema & data
npx wrangler d1 export northwind-traders --remote --output=./database.sql
# single table schema & data
npx wrangler d1 export northwind-traders --remote --table='Employee' --output=./table.sql
# database schema only
npx wrangler d1 export <database_name> --remote --output=./database-schema.sql --no-data=true
与 first-unconditional
一样处理
# To find top 10 queries by average execution time:
npx wrangler d1 insights <database_name> --sort-type=avg --sort-by=time --count=10
通过“往返”会话的最后一个查询的提交令牌,并使用它来启动一个新会话,可以让一个会话跨多个请求。这使得每个用户代理,例如 Web 应用或移动应用,都能够确保用户看到的所有查询都是顺序一致的。
D1 的读复制将是内置功能,不会产生额外的使用或存储成本,并且不需要配置副本。Cloudflare 将监控应用程序的 D1 流量,并自动创建数据库副本,将用户流量分散到离用户更近的多个服务器上。 与我们的无服务器模式相一致,D1 开发人员无需担心副本的配置和管理。相反,开发人员应该专注于设计应用,以实现复制和数据一致性的权衡。
我们正积极开发全局读复制和实现上述方案(欢迎在我们的开发人员 Discord 服务器 #d1频道中分享反馈)。在此之前,D1 正式版包含几个令人兴奋的新功能。
了解 D1 正式版
自 2023 年 10 月 D1 开放测试版发布以来,我们一直专注于 D1 的可靠性、可扩展性以及开发人员对关键服务的体验要求。 我们投资开发了多项新功能,使开发人员能够更快地使用 D1 构建和调试应用程序。
利用更大的数据库构建更大型的系统我们听取了开发人员对更大数据库的需求。现在,D1 支持高达 10 GB 的数据库,Workers Paid 计划中支持 5 万个数据库。通过 D1 的横向扩展,应用程序可以建立每个业务实体独立数据库的模型。自测试版发布以来,新的D1 数据库在给定时间内处理的请求量是 D1 alpha 数据库的 40 倍。
导入& 导出批量数据开发人员导入和导出数据有多种原因:
不同数据库系统之间的数据库迁移测试
用于本地开发或测试的数据副本
针对合规性等定制要求进行手动备份
虽然以前可以针对 D1 执行 SQL 文件,但我们正在改进 wrangler d1 execute –file=<filename>
以确保大型导入是原子操作,不会让数据库处于中途状态。 wrangler d1 execute
现在默认为优先本地执行,以保护您的远程生产数据库。
要导入我们的 Northwind Traders演示数据库,您可以下载 模式 & 数据 并执行 SQL 文件。
D1 数据库数据& 模式、纯模式或纯数据可通过以下方式导出为 SQL 文件:
调试查询性能了解 SQL 查询性能和调试缓慢的查询是生产工作负载的关键步骤。我们添加了实验性的wrangler d1 insights
来帮助开发人员分析查询性能指标,这些指标也可通过 GraphQLAPI 获取。
开发人员工具各种社区开发者项目都支持 D1。新增项目包括 Prisma ORM,版本为 5.12.0,现在支持Workers 和 D1。
后续步骤
正式版现在提供的功能和我们的全局读复制设计只是满足开发人员的应用程序 SQL 数据库需求的开始。 如果您还没有使用过 D1,可以 立即开始 ,访问 D1 的 开发人员文档来激发一些想法,或者在我们的开发人员 Discord 服务器上加入 #d1 频道 ,与其他 D1 开发人员和我们的产品工程团队进行交流。