目录 (10 节)↓
D1 读配额耗尽:一次数据库故障复盘
一次首页 500,最终追到账号级读配额耗尽。从排查过程出发,讨论如何找到高成本查询、验证索引,并为缓存降级和数据库容量建立可测量的依据。
2026 年 9 月末,我的个人站点出现了 HTTP 500。错误堆栈指向文章标签查询,看起来像是关联查询写错、数据库迁移遗漏,或者刚部署的代码出了问题。
继续检查发现,首页与依赖健康检查同时返回 500,R2 对象存储的检查却通过了。D1 返回的原始错误指出,账号已经耗尽免费计划的每日读行配额。此时需要调查的是账号用量、查询总成本,以及缓存失效后页面会发生什么。
这次排查确认了配额故障,但尚未定位到主要消耗查询,也没有实施数据库优化或套餐调整。文章前半部分还原排查过程,后半部分讨论后续可以怎样测量和改进。SQL 实验与容量数字都是教学示例,不代表站点的生产测量结果。
1. 页面失败要沿着依赖往下查
站点运行在 Cloudflare Workers 上,D1 保存文章及关联数据,R2 保存图片等对象,内容查询结果缓存在 KV 中。生成文章列表时,除了正文摘要,还需要读取标签、封面和照片关系。
| 请求路径 | 数据来源 | 失败时的影响 |
|---|---|---|
| 页面请求 → 内容缓存命中 | KV 中已保存的查询结果 | 可以减少对 D1 的依赖 |
| 页面请求 → 缓存未命中 → 查询数据库 | D1 中的文章与关联数据 | 查询被拒绝时,依赖它的页面无法生成 |
| 图片请求 → 对象存储 | R2 中的图片文件 | 对象存储有独立的可用状态 |
| 依赖健康检查 → 分别探测各存储 | D1 与 R2 | 可以判断是哪项依赖失败 |
缓存未命中后的读取路径直接依赖 D1。缓存可以减少访问数据库的次数,但数据库不可用时,如果缓存里也没有可读结果,页面仍然会失败。图片能够正常读取,无法使依赖数据库的文章列表恢复。
健康检查如果只返回一个布尔值,这些差异就会被隐藏。分别记录 D1、R2 和其他关键依赖的结果,才能判断页面生成失败影响到了哪条链路。
2. 报错的查询未必是配额消耗源
当时的检查结果可以支持以下判断:
| 观察 | 能支持的判断 | 不能据此判断的事 |
|---|---|---|
| 文章列表在查询关联标签时失败 | 这一请求读 D1 时遇到了错误 | 此查询消耗了账号全部读配额 |
| 首页与健康检查同时返回 500,R2 检查通过 | D1 是共同失败依赖,R2 并未同时故障 | 所有站点路由和所有账号数据库都已逐一验证失败 |
| 底层错误明确指出账号每日读行配额耗尽 | 本次失败属于平台配额拒绝 | 缺索引、恶意流量或某个数据库是主要消耗源 |
排查这类错误时,保留 cause、依赖名称和错误原文很有用。ORM 包装后的异常通常会突出失败的 SQL,而嵌套的原始错误能进一步说明是语法、绑定参数、权限、超时,还是容量问题。
当时读取到的关键错误片段是:
Your account has exceeded D1's free tier daily row read limit.
账号预算耗尽后,轻量查询同样会被拒绝。标签关联查询恰好在这次请求里触发了错误,它不一定是此前消耗配额最多的查询。
3. 这次排查停在了哪里
从首次看到页面错误,到确认配额问题,排查按以下顺序展开:
- 从页面错误追到公开文章列表及关联标签查询。
- 检查首页和依赖健康状态,比较 D1 与 R2 的结果。
- 读取 D1 原始错误,确认账号级每日读行配额耗尽。
- 检查内容缓存实现,确认它的默认有效期为五分钟。
- 把后续调查集中到配额消耗源、缓存与降级行为。修改 SQL 或重新部署都不能补回当天已经用掉的额度。
这次诊断没有修改数据库、缓存策略或计费方案,因此还没有优化前后的用量对照。
当时没有连续探测记录,因此无法给出准确的恢复用时。平台有固定的额度重置时间,站点何时重新可用仍需要实际请求来确认。
4. 数据库成本要看扫描行数
截至 2026 年 10 月 8 日核查,D1 计费说明列出的免费计划读额度是每个账号每日 500 万行,按 UTC 自然日计量;额度在 UTC 00:00 重置,对应北京时间 08:00。计量单位是查询读取、扫描的行,不能拿返回结果数量或页面访问量直接代替。
一条查询返回 20 篇文章,读取的行数可能远多于 20。过滤、关联和排序需要处理多少候选数据,与索引、数据分布和执行计划有关,要结合查询元数据测量。
D1 查询结果中的 meta.rows_read、meta.rows_written 可以帮助记录单次成本;字段含义见返回对象说明。示意日志可以只保留操作标识和计量值:
// 观测示例:按业务操作记录单次查询成本。
const result = await db.prepare(sql).bind(...parameters).all();
console.info({
operation: "published-post-list",
rowsRead: result.meta.rows_read,
rowsWritten: result.meta.rows_written,
});
这里的 operation 应由应用维护,便于把 SQL 与业务入口关联起来。日志不需要记录用户正文、凭据或全部绑定参数。分页长度可以保持不变,后台扫描量却会随着表增长而变化,所以需要持续观察。
5. 从账号用量追到业务入口
下一步调查应从账号向内缩小范围:先看账号总量,再按数据库和时间段归因,最后按查询与业务操作归因。Cloudflare 提供账号计费用量及数据库指标与查询分析。后者能帮助比较查询次数、读取行数和延迟,查询次数与计费读取行数需要分别看。
时间窗口也要对齐:数据库指标默认展示最近 24 小时,免费额度按 UTC 自然日重置。判断当天还剩多少额度时,应使用当天的账号级读取行数,不能直接拿单个数据库最近 24 小时的数字相减。
具体调查可以从这些问题开始:
| 维度 | 要回答的问题 |
|---|---|
| 数据库 | 主要消耗来自当前站点,还是同账号的其他应用? |
| 时间段 | 是否出现突增,是否与部署、导入、任务或爬虫访问相邻? |
| 查询 | 哪些 SQL 的累计读取行数高?是单次昂贵,还是运行频繁? |
| 业务入口 | 首页、标签页、全文搜索、管理任务、健康探测分别贡献多少? |
| 缓存 | 未命中是因为过期、主动失效、新地区冷读,还是缓存键设计? |
在拿到用量数据前,爬虫访问、缺少索引、后台任务和流量增长都只是候选原因。这次故障中还没有查询分析结果能够证明其中哪一项是主因。
累计成本可以先按 执行次数 × 平均读取行数 排序。偶尔运行的复杂查询,消耗可能少于每次页面访问都会触发的普通查询。再检查平均值是否掩盖了少数大扫描,按需要补充最大值或分位数,就能逐步缩小调查范围。
6. 用一个离线实验说明索引如何影响执行计划
为了观察过滤和排序如何影响执行计划,我用本地 SQLite 做了一个对照实验:生成 20,000 条合成文章,其中 1,000 条为公开状态,按发布时间和 ID 倒序取 20 条。下文的表结构是独立示例,实验没有访问生产数据库。
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
published_at INTEGER NOT NULL
);
SELECT id, title, published_at
FROM posts
WHERE status = 'published'
ORDER BY published_at DESC, id DESC
LIMIT 20;
加索引前,SQLite 3.53.4 的计划为:
SCAN posts
USE TEMP B-TREE FOR ORDER BY
添加与过滤、排序匹配的索引:
CREATE INDEX posts_status_time_id
ON posts(status, published_at DESC, id DESC);
同一查询的计划变为:
SEARCH posts USING INDEX posts_status_time_id (status=?)
前后返回的 20 条结果完全一致。这个实验验证了本地查询计划的变化,没有测量 D1 的 rows_read,也不能据此判断故障站点缺少同样的索引。实际优化需要用生产表结构、数据分布和查询元数据复验。
SQLite 的执行计划说明可以帮助读懂 SCAN、SEARCH 和临时排序结构;D1 索引指南说明了如何检查 D1 使用的索引。计划出现扫描也不自动代表错误,小表、低选择性条件和需要返回大部分数据的查询,都可能合理地扫描。索引也有存储、构建和写入维护成本。
7. 缓存过期后,故障仍会暴露给页面
站点的内容缓存默认保留五分钟。缓存未过期时可以减少 D1 查询,但存储有效期结束后,原值不再可读,请求就会回源到已经耗尽配额的数据库。
这里需要区分三个时间概念:
- 内容业务上多久可以不更新。
- 应用保存的缓存值多久还存在。
- 分布式 KV 的边缘读取多久看到新的写入或删除。
调整 TTL 前,需要明确它控制的是哪一个时间。要在数据库故障时展示旧内容,还需要保存最后一次成功快照,规定允许展示多久,以及哪些内容必须及时撤回。
Workers KV 的一致性说明明确了跨地区读取具有最终一致性。删除一个缓存键,不保证所有地区立即停止返回旧值。因此,也不能依靠 KV 提供原子会话撤销或一次性挑战消费这样的强一致语义。
个人 CMS 可以按内容性质设计降级响应:
| 内容 | 故障期间可以考虑的响应 |
|---|---|
| 可长期公开的技术文章 | 在明确的最大陈旧时间内展示最后一次审核通过的公开快照 |
| 首页列表与标签集合 | 已知公开快照,或说明暂时不可用;不能把读取失败包装为空列表 |
| 草稿、私密文章、管理页面 | 继续执行权限检查,不复用公开内容的旧快照策略 |
| 已撤回或需要立即删除的内容 | 需要可靠撤回机制;无法确认有效性时不继续展示旧内容 |
| 写入接口 | 明确返回失败,保留客户端草稿,不能假装保存成功 |
采用旧快照降级时,物理保留时间必须覆盖最大陈旧时间,数据里还需要保存 generatedAt、版本和公开状态。如果缓存已经被存储层删除,异常处理分支也取不到旧值。上述方案仍待实施,需要在数据库拒绝查询的情况下验证页面响应、权限检查和撤回行为。
8. 给应用建立读预算
页面访问量可以结合回源比例和单次查询成本,粗略换算成读预算:
每日读取行数 ≈ Σ[某入口调用次数 × 回源比例 × 单次回源读取行数]
+ 后台任务 + 管理操作 + 探测 + 同账号其他数据库
假设某入口每天调用 10,000 次,80% 命中缓存,每次回源的全部查询合计读取 1,200 行,那么这一入口的估算值是 10,000 × 20% × 1,200 = 2,400,000 行。数字全部是假设,用来说明为什么要同时测命中率和单次成本。
没有缓存时,回源比例是 100%;只有部分查询使用缓存时,需要分别计算。全站平均命中率不能直接套到每项数据库操作上,后台任务、管理操作和数据导入也需要纳入用量统计。
预算要为业务增长和突发访问留出余量。升级计划可以解除部分硬额度约束,但不合理的扫描、重复回源和故障时的页面响应仍需处理。保留调整前后的测量,才能判断查询优化、缓存改动或套餐调整分别起到了什么作用。
9. 后续改动如何验收
| 顺序 | 工作 | 完成证据 |
|---|---|---|
| 1 | 保存账号及数据库用量、错误原文和时间段 | 能归属故障与主要消耗数据库 |
| 2 | 按操作记录查询次数、行数和缓存结果 | 能解释主要消耗入口 |
| 3 | 对最高成本查询验证索引或访问方式 | 结果一致,计划与远端计量前后对照 |
| 4 | 为公开只读内容设计有界降级 | 数据库拒绝时页面行为符合内容权限与陈旧限制 |
| 5 | 检查发布、撤回和缓存失效 | 旧内容不会越过权限或撤回要求 |
| 6 | 建立容量阈值与告警 | 剩余预算与异常变化可被观察,恢复另有探测记录 |
如果再遇到相似故障,我会保留原始错误和故障时间,先确认拒绝请求的依赖,再查账号内各数据库的消耗。找到主要查询后,用同一组数据比较结果、执行计划和实际读取行数;缓存与降级方案则单独验证数据库不可用时的页面行为。
资料与实验说明
事故经过来自 2026 年 9 月末的一次个人站点排查,应用名称、内部接口和数据标识已通用化。SQL 对照在本地 SQLite 3.53.4 上运行,使用合成数据,不涉及生产数据库。文中的用量模型采用假设数字,后续治理方案尚待实现和验证。
Cloudflare 规则于 2026 年 10 月 8 日核查。额度、计费方式和平台功能可能变化,实际配置前可通过正文中的官方文档链接确认。