保持相关工单更新的一致性

CloudflareBeginner
立即练习

介绍

只有保存了解决说明后,工单才能关闭。你将使用 D1 prepared batch 将这两次写入保持在同一个事务中,然后在多个请求之间传递 Sessions API 书签,使后续读取能够看到之前已提交的更改。

本实验区分原子回滚和顺序会话一致性。实验只使用一个独立的数据库和 Worker,不要求启用读副本,也不要求重现复制竞态。

使用你自己的学习账号和一台全新的 VM。初始化过程会先准备 Node.js 22.22.0,然后在 /home/labex/project/ticket-database 下运行 npm install,安装项目本地的 Wrangler 4.131.1 及评测所需的依赖。直接依赖的版本已固定;安装过程会创建自己的 lockfile。初始化阶段不会登录云端,也不会执行评测数据库操作。在个人计算机上操作时,请在项目中使用 npm install --save-dev wrangler@4.131.1 安装相同版本的 Wrangler。

本实验使用少量合成记录,处于 D1 Free allowances 范围内。账号已有的用量也会计入这些额度。实验不需要购买域名。请保留这台 VM,直到确认资源删除和注销都已完成。

授权这台 VM 并选择账号

在本步骤中,将这个全新的终端连接到你自己的学习账号。仅登录 Dashboard 不会授权这台 VM。D1 权限用于创建、修改和删除数据库;Workers 权限用于部署;KV 权限用于支持 Wrangler 的清理资源清单。授权前请查看实际的授权页面,包括 Background Access,然后再确认授权。

打开准备好的项目并检查已固定版本的 CLI:

cd /home/labex/project/ticket-database
npx wrangler --version

预期输出为 4.131.1。开始设备授权;--device 会显示浏览器验证码,--browser=false 会让你自行选择是否打开浏览器:

npx wrangler login --device --browser=false --scopes account:read user:read d1:write workers_scripts:write workers_kv:write

在浏览器中打开显示的 URL,输入当前验证码,确认你的学习账号和权限,然后完成授权。等待终端确认成功。不要将密码或令牌粘贴到项目文件中。

npx wrangler whoami --json

检查 loggedIn: true,然后读取账号的 nameid,即使列表中只有一个账号也要读取。将目标账号的 ID 复制到下面的配置中。下面的 Shell 变量使用 6 个随机字节(12 个十六进制字符),避免与其他学习者的资源名称冲突。here-document 会将 JSON 标记之间的内容写入文件;其中的 $RUN 会展开。

$schema 前的反斜杠会保留这个 JSON 键的原样;$RUN 仍会展开为本次运行的唯一名称。

RUN=labex-c04-d05-$(openssl rand -hex 6)
cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "YOUR_ACCOUNT_ID",
  "main": "src/index.js",
  "compatibility_date": "2026-09-15",
  "workers_dev": true,
  "preview_urls": false
}
JSON

运行这段命令前,先将 YOUR_ACCOUNT_ID 替换为实际的账号 ID。保持这个终端打开,以便 RUN 变量继续可用。name 用于标识本次运行;account_id 用于选择执行云端操作的账号。该文件是普通 JSON,同时也是有效的 JSONC。写入这个文件不会部署 Worker。

准备工单和解决说明

在本步骤中,准备两个相关表。关闭工单时,也应保存对应的解决说明。如果只有一次写入成功,工作人员可能会看到已关闭但没有说明的工单。初始化过程会提供数据库结构和 HTTP 路由;你需要实现相关的 SQL 操作。

创建一个临时云数据库。--binding DB 为应用代码提供简短的绑定名称,--update-config 会将数据库的实际名称和 UUID 写入 wrangler.jsonc--use-remote=false 会让开发过程使用本地数据库:

npx wrangler d1 create "$RUN-db" --binding DB --update-config --use-remote=false

读取创建结果中的名称和 ID,然后检查已保存的绑定:

cat wrangler.jsonc

DB 条目必须指向本次运行创建的数据库。绑定是代码与资源之间配置好的连接。绑定中的 UUID 用于标识云端数据库,而 --local 使用的是这台 VM 上独立的 SQLite 数据库。执行 SQL 命令时,必须始终指定 --local--remote

cat schema.sql
npx wrangler d1 execute DB --local --file schema.sql
npx wrangler d1 execute DB --remote --file schema.sql

工单 1 处于打开状态,还没有解决记录。工单 2 已关闭,并拥有解决事件 1。已占用的事件 ID 提供了一个可控的失败场景:再次插入事件 1 时,必须违反主键约束。

保持写入原子性并按顺序读取

在本步骤中,解决两个独立的一致性问题。原子性表示两个相关写入要么全部成功,要么全部失败。D1 的 batch() 会将 prepared statements 作为一个事务执行:如果其中一条语句失败,整个批处理都会回滚。分别等待两个写入完成,无法提供这种保证。

会话会跟踪一系列查询所观察到的数据库状态。提供的路由器会调用 env.DB.withSession(...);当客户端没有书签时,会从 first-primary 开始。路由器会将 getBookmark() 的结果放入 x-d1-bookmark 响应头。后续请求可以发送这个书签,从至少该数据库状态继续读取。这属于顺序一致性,不是跨多个 HTTP 请求的全有或全无事务。

使用路由器传入的会话实现这两个函数:

cat > src/store.js <<'JS'
export async function closeTicket(session, id, eventId, note) {
  await session.batch([
    session.prepare("UPDATE tickets SET status = 'closed' WHERE id = ?").bind(id),
    session.prepare('INSERT INTO resolutions(event_id, ticket_id, note) VALUES (?, ?, ?)').bind(eventId, id, note)
  ]);
}
export async function readTicket(session, id) {
  const ticket = await session.prepare('SELECT id, subject, status FROM tickets WHERE id = ?').bind(id).first();
  if (!ticket) return null;
  const { results } = await session.prepare('SELECT event_id, note FROM resolutions WHERE ticket_id = ? ORDER BY event_id').bind(id).all();
  return { ...ticket, resolutions: results };
}
JS

阅读 src/index.js,找到 withSession、传入的书签请求头以及返回的书签。该请求的所有数据库操作都必须使用这个会话。书签是不透明的位置标记:将它原样传回,不要尝试解析或自行生成。

cat src/index.js
npx wrangler dev --ip 0.0.0.0 > dev.log 2>&1 &
cat dev.log

等待出现本地监听提示。本地模拟可以测试批处理回滚,但不能展示真实的远程复制过程或云端书签。

在成功关闭前观察回滚

在本步骤中,故意提交已经被占用的事件 ID。批处理的第一条语句会尝试关闭工单 1,但第二条语句会失败。失败后读取结果:

curl -i http://localhost:8787/tickets/1/close -H 'Content-Type: application/json' -d '{"event_id":1,"note":"Must roll back"}'
curl -i http://localhost:8787/tickets/1

预期先返回 409 event_conflict,随后显示工单 1 仍为 open,且 resolutions 数组为空。仅看到 409 还不够:后续读取可以证明没有保留部分更新。

现在使用尚未占用的事件 ID 2:

curl -i http://localhost:8787/tickets/1/close -H 'Content-Type: application/json' -d '{"event_id":2,"note":"Access restored"}'
curl -i http://localhost:8787/tickets/1

预期返回 HTTP 200,工单 1 变为 closed,并包含事件 2 的解决记录,记录中的说明为 Access restored。现在两条记录保持一致。不要重置本地数据,也不要修改远程数据库来人为制造复制延迟。

使用书签继续远程会话

在本步骤中,对 D1 执行相同的批处理,并在多个请求之间传递真实书签。远程测试数据仍处于初始状态。

npx wrangler deploy

复制实际的部署 URL。先重复失败的批处理并确认回滚:

URL='YOUR_DEPLOYED_HTTPS_URL'
curl -i "$URL/tickets/1/close" -H 'Content-Type: application/json' -d '{"event_id":1,"note":"Must roll back"}'
curl -i "$URL/tickets/1"

预期先返回 409,然后返回一个处于打开状态且没有解决记录的工单。如果部署仍在传播,请在最多一分钟内重试读取;不要将平台错误页面误认为应用程序的 JSON 契约。

提交成功的关闭操作:

curl -i "$URL/tickets/1/close" -H 'Content-Type: application/json' -d '{"event_id":2,"note":"Access restored"}'

预期看到已关闭的工单及其说明。复制响应头中非空的 x-d1-bookmark 值,不要包含额外空格,然后将其填入下面的变量:

BOOKMARK='YOUR_RESPONSE_BOOKMARK'
curl -i "$URL/tickets/1" -H "x-d1-bookmark: $BOOKMARK"

后续读取必须能看到已关闭的工单和事件 2 的解决记录。书签会限制读取结果不能早于哪个数据库状态;它不是身份验证令牌。此流程不要求出现过时读取,也不要求启用读复制。我们验证的是会话契约,而不是声称发生了副本竞态。

在 Dashboard 中打开对应的 Worker,确认其 DB 绑定指向本次运行创建的数据库。完成功能验证后再清理资源。

Worker 与 D1 的绑定

此示例显示 Worker 的 DB 绑定指向其 D1 数据库。生成的资源前缀标识本次示例运行,你的名称会不同。截图仅确认绑定关系。上方 HTTP 检查验证了原子回滚、成功更新和书签连续读取。

删除临时资源

在本步骤中,仅删除本实验创建的资源,并且在 VM 仍处于授权状态时完成。先完成所有功能检查。保留配置文件,直到确认删除成功。

npx wrangler delete

确认提示中只有本次运行配置里的 Worker 名称。

npx wrangler d1 delete DB

查看提示,并确认其中只有本次运行创建的数据库。然后列出数据库:

npx wrangler d1 list --json

成功响应中不应再出现你记录的数据库名称和 UUID。其他资源可以保留。身份验证或网络错误不能证明删除成功:先恢复访问,然后重新读取并确认。仍处于登录状态时,完成本步骤的验证。

同时停止本地开发任务。列出任务,并且只终止你启动的 wrangler dev 任务(如果任务编号不同,请替换 %1):

jobs
kill %1

结束这台 VM 的授权

在确认独立的删除检查通过后,再执行本步骤结束授权。注销会删除这台 VM 上保存的 Wrangler 授权;仅关闭 VM 并不能完成云端清理。

npx wrangler logout
npx wrangler whoami --json

预期显示 loggedIn: false。未通过身份验证的查询可能以非零状态退出;只有当结构化响应明确表示你已注销时,这才是预期行为。完成验证后,关闭实验环境。

总结

你练习了如何保持相关工单更新的一致性。你检查了可观察的数据库结果,明确区分了所选账号和本地状态,并在注销前删除了临时资源。