<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>SQLite on</title><link>/tags/sqlite/</link><description>Recent content in SQLite on</description><generator>Hugo -- gohugo.io</generator><lastBuildDate>Sun, 06 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="/tags/sqlite/index.xml" rel="self" type="application/rss+xml"/><item><title>1866 万行的谜：一次 Cloudflare D1 免费额度被打穿的排查全过程</title><link>/post/d1-quota-postmortem/</link><pubDate>Sun, 06 Sep 2026 00:00:00 +0000</pubDate><guid>/post/d1-quota-postmortem/</guid><description>&lt;p>从&amp;quot;打不开题库&amp;quot;到&amp;quot;账户一天读了 1866 万行&amp;quot;，中间踩了三个代码坑、三个诊断脚本自己的坑，两次亲手推翻自己深信不疑的结论 —— 最后在第三次复发时，真的挖到了那条元凶查询。&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>项目&lt;/th>
&lt;th>内容&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>事故日期&lt;/td>
&lt;td>2026-09-03 ～ 09-05（UTC）&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>影响&lt;/td>
&lt;td>全站接口不可用&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>直接原因&lt;/td>
&lt;td>Cloudflare D1 免费额度超限（错误码 7500）&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>根本原因&lt;/td>
&lt;td>学习模式接口的相关子查询没有合适的索引&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>这篇文章记录的是一个自用的小型刷题网站（Cloudflare Pages + Functions + D1）在上线新题库功能后遇到的一次生产事故。之所以值得写下来，不是因为最后完美解决了，而是因为中间每一步&amp;quot;以为找到了&amp;quot;后来都被更真实的数据推翻，包括排查工具本身也接连翻车。下面的报错和用量数据都来自真实日志（项目名、账户与数据库标识已隐去）。&lt;/p>
&lt;p>&lt;strong>时间线（北京时间）：&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>时间&lt;/th>
&lt;th>事件&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>9/1&lt;/td>
&lt;td>新题库功能上线，部署成功&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9/3 08:00&lt;/td>
&lt;td>读取暴涨约 495 万行&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9/3 10:00&lt;/td>
&lt;td>读取暴涨约 912 万行&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9/4 07:00&lt;/td>
&lt;td>读取暴涨约 458 万行，随后用户撞见故障&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9/4 07:45 / 07:50&lt;/td>
&lt;td>两次部署秒失败&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9/4 08:00&lt;/td>
&lt;td>配额重置（UTC 零点）&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9/4 上午&lt;/td>
&lt;td>推翻&amp;quot;迁移导致&amp;quot;的猜测&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9/5&lt;/td>
&lt;td>第三次复发（约 865 万行），锁定真正根因&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="01-症状题库突然打不开了">&lt;a href="#01-%e7%97%87%e7%8a%b6%e9%a2%98%e5%ba%93%e7%aa%81%e7%84%b6%e6%89%93%e4%b8%8d%e5%bc%80%e4%ba%86" class="header-anchor">&lt;/a>01 症状：题库突然打不开了
&lt;/h2>&lt;p>反馈很简单：&lt;strong>&amp;ldquo;怎么进去以后又加载不出题库了&amp;rdquo;&lt;/strong>，紧接着是&amp;quot;首页能打开，但学习模式一直显示加载中&amp;quot;。这类反馈信息量很少 —— 手机上看不了控制台，唯一能确认的就是&amp;quot;坏了&amp;quot;。真正的排查是从代码和日志里一步步挖出来的。&lt;/p>
&lt;h2 id="02-坑一一个中文请求头崩了全站接口">&lt;a href="#02-%e5%9d%91%e4%b8%80%e4%b8%80%e4%b8%aa%e4%b8%ad%e6%96%87%e8%af%b7%e6%b1%82%e5%a4%b4%e5%b4%a9%e4%ba%86%e5%85%a8%e7%ab%99%e6%8e%a5%e5%8f%a3" class="header-anchor">&lt;/a>02 坑一：一个中文请求头，崩了全站接口
&lt;/h2>&lt;p>刚上线&amp;quot;题库切换&amp;quot;功能时，前端用一个自定义请求头 &lt;code>X-Question-Set&lt;/code> 把当前选中的题库名透传给后端，图省事直接塞了中文：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-js" data-lang="js">&lt;span style="color:#66d9ef">new&lt;/span> &lt;span style="color:#a6e22e">Headers&lt;/span>().&lt;span style="color:#a6e22e">set&lt;/span>(&lt;span style="color:#e6db74">&amp;#39;X-Question-Set&amp;#39;&lt;/span>, &lt;span style="color:#e6db74">&amp;#39;1号题库&amp;#39;&lt;/span>)
&lt;span style="color:#75715e">// Uncaught TypeError: Cannot convert argument to a ByteString
&lt;/span>&lt;span style="color:#75715e">// because the character at index 2 has a value of 39064
&lt;/span>&lt;span style="color:#75715e">// which is greater than 255.
&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>HTTP 请求头的值按规范只能是 &lt;strong>ByteString&lt;/strong>（Latin-1 范围），中文字符直接塞进去必炸。更麻烦的是，前端几乎所有请求都走同一个 &lt;code>apiFetch&lt;/code> 封装函数，很多调用方又写了 &lt;code>.catch(() =&amp;gt; {})&lt;/code> 静默吞掉错误 —— 于是&amp;quot;一个请求头炸了&amp;quot;变成了&amp;quot;全站看不到任何数据&amp;quot;，且没有任何报错提示。&lt;/p>
&lt;p>修复：前端编码、后端容错解码。&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-ts" data-lang="ts">&lt;span style="color:#75715e">// src/lib/api.ts
&lt;/span>&lt;span style="color:#75715e">&lt;/span>&lt;span style="color:#a6e22e">headers&lt;/span>.&lt;span style="color:#66d9ef">set&lt;/span>(&lt;span style="color:#e6db74">&amp;#34;X-Question-Set&amp;#34;&lt;/span>, encodeURIComponent(&lt;span style="color:#a6e22e">getQuestionSet&lt;/span>()));
&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-ts" data-lang="ts">&lt;span style="color:#75715e">// functions/lib/questionSet.ts
&lt;/span>&lt;span style="color:#75715e">&lt;/span>&lt;span style="color:#66d9ef">export&lt;/span> &lt;span style="color:#66d9ef">function&lt;/span> &lt;span style="color:#a6e22e">getRequestQuestionSet&lt;/span>(&lt;span style="color:#a6e22e">request&lt;/span>: &lt;span style="color:#66d9ef">Request&lt;/span>)&lt;span style="color:#f92672">:&lt;/span> &lt;span style="color:#66d9ef">string&lt;/span> {
&lt;span style="color:#66d9ef">const&lt;/span> &lt;span style="color:#a6e22e">raw&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#a6e22e">request&lt;/span>.&lt;span style="color:#a6e22e">headers&lt;/span>.&lt;span style="color:#66d9ef">get&lt;/span>(&lt;span style="color:#e6db74">&amp;#34;X-Question-Set&amp;#34;&lt;/span>);
&lt;span style="color:#66d9ef">if&lt;/span> (&lt;span style="color:#f92672">!&lt;/span>&lt;span style="color:#a6e22e">raw&lt;/span>) &lt;span style="color:#66d9ef">return&lt;/span> &lt;span style="color:#a6e22e">DEFAULT_QUESTION_SET&lt;/span>;
&lt;span style="color:#66d9ef">try&lt;/span> {
&lt;span style="color:#66d9ef">return&lt;/span> decodeURIComponent(&lt;span style="color:#a6e22e">raw&lt;/span>);
} &lt;span style="color:#66d9ef">catch&lt;/span> {
&lt;span style="color:#66d9ef">return&lt;/span> &lt;span style="color:#a6e22e">DEFAULT_QUESTION_SET&lt;/span>;
}
}
&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="03-坑二加载中可以转到天荒地老">&lt;a href="#03-%e5%9d%91%e4%ba%8c%e5%8a%a0%e8%bd%bd%e4%b8%ad%e5%8f%af%e4%bb%a5%e8%bd%ac%e5%88%b0%e5%a4%a9%e8%8d%92%e5%9c%b0%e8%80%81" class="header-anchor">&lt;/a>03 坑二：&amp;ldquo;加载中&amp;quot;可以转到天荒地老
&lt;/h2>&lt;p>修完编码问题后，又暴露出第二层问题：只要任何一次 &lt;code>apiFetch&lt;/code> 请求失败，页面的 &lt;code>loading&lt;/code> 状态就再也没有机会被置回 &lt;code>false&lt;/code> —— 看到的就是一个永远转圈的&amp;quot;加载中…&amp;quot;，没有任何错误信息。&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-diff" data-lang="diff"> async function load(restart = false) {
setLoading(true);
&lt;span style="color:#a6e22e">+ setLoadError(null);
&lt;/span>&lt;span style="color:#a6e22e">+ try {
&lt;/span>&lt;span style="color:#a6e22e">&lt;/span> const res = await apiFetch&amp;lt;...&amp;gt;(`/study?bank=${bank}`);
...
&lt;span style="color:#a6e22e">+ } catch (e) {
&lt;/span>&lt;span style="color:#a6e22e">+ setLoadError(e instanceof Error ? e.message : String(e));
&lt;/span>&lt;span style="color:#a6e22e">+ } finally {
&lt;/span>&lt;span style="color:#a6e22e">&lt;/span> setLoading(false);
&lt;span style="color:#a6e22e">+ }
&lt;/span>&lt;span style="color:#a6e22e">&lt;/span> }
&lt;/code>&lt;/pre>&lt;/div>&lt;p>把 &lt;code>loadError&lt;/code> 直接渲染在页面上，配一个&amp;quot;重试&amp;quot;按钮 —— 这个改动本身是对的，但后面会看到：它&lt;strong>不是&lt;/strong>这次事故的根因，只是让下一次同类问题更容易被发现。&lt;/p>
&lt;h2 id="04-真正的元凶d1-免费额度被打穿">&lt;a href="#04-%e7%9c%9f%e6%ad%a3%e7%9a%84%e5%85%83%e5%87%b6d1-%e5%85%8d%e8%b4%b9%e9%a2%9d%e5%ba%a6%e8%a2%ab%e6%89%93%e7%a9%bf" class="header-anchor">&lt;/a>04 真正的元凶：D1 免费额度被打穿
&lt;/h2>&lt;p>顺着部署流水线往回查，发现&amp;quot;应用数据库迁移&amp;quot;这一步直接报错退出：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-text" data-lang="text">✘ [ERROR] A request to the Cloudflare API
(/accounts/***/d1/database/***/query) failed.
Your account has exceeded D1&amp;#39;s free tier daily row
read limit. Upgrade to a paid plan or wait until
tomorrow (midnight UTC) to continue. [code: 7500]
&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;strong>关键事实：&lt;/strong> Cloudflare D1 免费套餐每天 500 万行读取上限。这一天账户实际读取了 &lt;strong>18,659,736&lt;/strong> 行 —— 超了 3.7 倍。迁移这一步失败会导致工作流里后面&amp;quot;部署到 Pages&amp;quot;这一步被整体跳过，也就是说：那天代码其实&lt;strong>从来没有真正部署成功过&lt;/strong>，加载失败是数据库层面全面拒绝服务导致的，不是新代码本身有 bug。&lt;/p>
&lt;/blockquote>
&lt;h2 id="05-配额到底怎么判的一个反直觉的发现">&lt;a href="#05-%e9%85%8d%e9%a2%9d%e5%88%b0%e5%ba%95%e6%80%8e%e4%b9%88%e5%88%a4%e7%9a%84%e4%b8%80%e4%b8%aa%e5%8f%8d%e7%9b%b4%e8%a7%89%e7%9a%84%e5%8f%91%e7%8e%b0" class="header-anchor">&lt;/a>05 配额到底怎么&amp;quot;判&amp;quot;的：一个反直觉的发现
&lt;/h2>&lt;p>当时被问了一个很尖锐的问题：&lt;strong>&amp;ldquo;前两次大查询发生后配额不就用完了吗，怎么后面又可以发生大查询？&amp;quot;&lt;/strong> 把 GraphQL Analytics 接口的数据拆到小时级别之后，答案浮出水面：&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>UTC 时间&lt;/th>
&lt;th style="text-align:right">读查询数&lt;/th>
&lt;th style="text-align:right">读行数&lt;/th>
&lt;th style="text-align:right">写行数&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>00:00–01:00&lt;/td>
&lt;td style="text-align:right">182&lt;/td>
&lt;td style="text-align:right">&lt;strong>4,945,555&lt;/strong>&lt;/td>
&lt;td style="text-align:right">443&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>01:00–02:00&lt;/td>
&lt;td style="text-align:right">621&lt;/td>
&lt;td style="text-align:right">258&lt;/td>
&lt;td style="text-align:right">1,806&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>02:00–03:00&lt;/td>
&lt;td style="text-align:right">100&lt;/td>
&lt;td style="text-align:right">&lt;strong>9,121,777&lt;/strong>&lt;/td>
&lt;td style="text-align:right">196&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>08:00 / 09:00（各 1 小时）&lt;/td>
&lt;td style="text-align:right">各 3&lt;/td>
&lt;td style="text-align:right">各 8,314&lt;/td>
&lt;td style="text-align:right">0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>23:00–24:00&lt;/td>
&lt;td style="text-align:right">51&lt;/td>
&lt;td style="text-align:right">&lt;strong>4,575,518&lt;/strong>&lt;/td>
&lt;td style="text-align:right">49&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>三段加起来正好是 18,659,736，跟 Cloudflare 控制台的数字完全对上。但更有意思的是：&lt;strong>03:00 那次暴涨之后账户早就该被判定超额了，为什么 23:00 那段还能继续读几百万行？&lt;/strong> 直到部署尝试在 23:47 才第一次真正被拒绝。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>反直觉的结论：&lt;/strong> 500 万行不是实时生效的硬闸门。查询该跑就跑，账户是否&amp;quot;超额&amp;quot;是靠后台异步汇总当天用量后才判定的 —— 从数据看，这个判定延迟长达近 20 个小时。所以会出现&amp;quot;早就超额了，但表面上一切正常，还能继续超支&amp;quot;的诡异窗口期，直到判定终于追上来，才会突然把所有新查询一并拦下。&lt;/p>
&lt;/blockquote>
&lt;h2 id="06-走错的方向一开始以为是迁移干的">&lt;a href="#06-%e8%b5%b0%e9%94%99%e7%9a%84%e6%96%b9%e5%90%91%e4%b8%80%e5%bc%80%e5%a7%8b%e4%bb%a5%e4%b8%ba%e6%98%af%e8%bf%81%e7%a7%bb%e5%b9%b2%e7%9a%84" class="header-anchor">&lt;/a>06 走错的方向：一开始以为是迁移干的
&lt;/h2>&lt;p>最初的直觉判断：当天刚好上线了新题库功能，两个迁移文件一共 2000 多条 SQL 语句（去重导入 + 题干加序号），&amp;ldquo;批量导入烧掉配额&amp;quot;这个解释听起来很合理。&lt;/p>
&lt;p>但被追问&amp;quot;这个时间点你确定吗&amp;quot;之后，去翻了 git 提交记录：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-text" data-lang="text">2026-09-01T02:08:55Z 新增题库切换功能：两个题库相互独立
2026-09-01T02:59:49Z 紧急修复：X-Question-Set 请求头塞中文导致全站失败
2026-09-01T03:15:23Z 新题库：题干前追加原始 Excel 序号
&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;strong>推翻：&lt;/strong> 这两个迁移是 &lt;strong>9 月 1 日&lt;/strong> 提交并成功部署的，比事故发生的 9 月 3 日早了整整两天。事故那天的三次读取暴涨，跟这两个迁移&lt;strong>完全没有关系&lt;/strong>。这是一次被当场纠正的判断失误。&lt;/p>
&lt;/blockquote>
&lt;h2 id="07-顺手挖出的一个真问题一条会越用越慢的查询">&lt;a href="#07-%e9%a1%ba%e6%89%8b%e6%8c%96%e5%87%ba%e7%9a%84%e4%b8%80%e4%b8%aa%e7%9c%9f%e9%97%ae%e9%a2%98%e4%b8%80%e6%9d%a1%e4%bc%9a%e8%b6%8a%e7%94%a8%e8%b6%8a%e6%85%a2%e7%9a%84%e6%9f%a5%e8%af%a2" class="header-anchor">&lt;/a>07 顺手挖出的一个真问题：一条会越用越慢的查询
&lt;/h2>&lt;p>代码审查过程中确实挖出一个设计上有缺陷的查询 —— 首页&amp;quot;曾经掌握后遗忘&amp;quot;这个统计：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-sql" data-lang="sql">&lt;span style="color:#75715e">-- functions/api/review/stats.ts（修复前）
&lt;/span>&lt;span style="color:#75715e">&lt;/span>&lt;span style="color:#66d9ef">SELECT&lt;/span> &lt;span style="color:#66d9ef">COUNT&lt;/span>(&lt;span style="color:#f92672">*&lt;/span>) &lt;span style="color:#66d9ef">AS&lt;/span> n
&lt;span style="color:#66d9ef">FROM&lt;/span> review_records rr
&lt;span style="color:#66d9ef">JOIN&lt;/span> questions q &lt;span style="color:#66d9ef">ON&lt;/span> q.id &lt;span style="color:#f92672">=&lt;/span> rr.question_id
&lt;span style="color:#66d9ef">WHERE&lt;/span> rr.user_id &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#f92672">?&lt;/span> &lt;span style="color:#66d9ef">AND&lt;/span> rr.prev_status &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#e6db74">&amp;#39;mastered&amp;#39;&lt;/span>
&lt;span style="color:#66d9ef">AND&lt;/span> rr.is_correct &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#ae81ff">0&lt;/span> &lt;span style="color:#66d9ef">AND&lt;/span> q.question_set &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#f92672">?&lt;/span>
&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>review_records&lt;/code> 是一张只增不减的答题日志表，这条查询&lt;strong>没有任何时间范围限制&lt;/strong>，每次打开首页都要重新数一遍。用 &lt;code>EXPLAIN QUERY PLAN&lt;/code> 实测：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-text" data-lang="text">SEARCH rr USING INDEX idx_review_records_user_time (user_id=?)
SEARCH q USING INDEX sqlite_autoindex_questions_1 (id=?)
&lt;/code>&lt;/pre>&lt;/div>&lt;p>索引确实用上了 —— 但只能定位到&amp;quot;这个用户的全部历史记录&amp;rdquo;，&lt;code>prev_status&lt;/code> / &lt;code>is_correct&lt;/code> 这两个真正的筛选条件完全没有索引覆盖。而这个应用用户很少，&lt;code>user_id=?&lt;/code> 这个条件在实践中筛不掉什么，本质上跟全表扫描没有太大区别。&lt;/p>
&lt;p>最初的修复思路是&amp;quot;加个索引&amp;rdquo;，但被追问&amp;quot;这个索引列区分度够吗&amp;quot;当场问住 —— 一个几乎没有区分度的 &lt;code>user_id&lt;/code> 打头，索引救不了这条查询。真正的修复是把&amp;quot;读时统计&amp;quot;改成&amp;quot;写时计数&amp;rdquo;：只在真正发生&amp;quot;曾掌握又答错&amp;quot;这一刻才 +1（本来就是低频事件），读的时候变成主键点查一行：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-text" data-lang="text">SEARCH user_relapse_stats USING INDEX
sqlite_autoindex_user_relapse_stats_1 (user_id=? AND question_set=?)
&lt;/code>&lt;/pre>&lt;/div>&lt;p>用模拟数据验证：旧查询要检查 &lt;strong>5,000&lt;/strong> 行候选，实际符合条件的是 50 行；新查询只读 &lt;strong>1&lt;/strong> 行，结果一样是 50。&lt;/p>
&lt;h2 id="08-诊断脚本本身也翻了三次车">&lt;a href="#08-%e8%af%8a%e6%96%ad%e8%84%9a%e6%9c%ac%e6%9c%ac%e8%ba%ab%e4%b9%9f%e7%bf%bb%e4%ba%86%e4%b8%89%e6%ac%a1%e8%bd%a6" class="header-anchor">&lt;/a>08 诊断脚本本身也翻了三次车
&lt;/h2>&lt;p>&lt;strong>坑 A · GraphQL 字段名写错。&lt;/strong> 查 Cloudflare 用量的诊断脚本，一开始查了个不存在的字段 &lt;code>pagesFunctionsInvocationsAdaptiveGroups { count }&lt;/code>，GraphQL 的行为是&lt;strong>整个查询直接报错、什么数据都不返回&lt;/strong>（不是部分成功）：&lt;code>&amp;quot;unknown field \&amp;quot;count\&amp;quot;&amp;quot;, &amp;quot;data&amp;quot;: null&lt;/code>。&lt;/p>
&lt;p>&lt;strong>坑 B · API Token 权限不够。&lt;/strong> 字段名修好之后，又撞上 &lt;code>&amp;quot;not authorized for that account&amp;quot;&lt;/code> —— 部署用的 Token 压根没开 Account Analytics 的读权限，得去 Cloudflare 后台手动加。&lt;/p>
&lt;p>&lt;strong>坑 C · 一个方括号引发的 YAML 解析失败。&lt;/strong> 给诊断步骤起名字时手滑写了 &lt;code>name: [临时诊断] 用真实数据对比...&lt;/code> —— YAML 里以 &lt;code>[&lt;/code> 开头的值会被解析成&amp;quot;流式数组&amp;quot;，导致&lt;strong>整个 workflow 文件解析失败&lt;/strong>。GitHub 的报错完全不指向真正原因，而是一句令人摸不着头脑的 &lt;code>&amp;quot;Workflow does not have 'workflow_dispatch' trigger&amp;quot;&lt;/code>，靠肉眼重新审查 YAML 才发现问题。&lt;/p>
&lt;h2 id="09-用真实数据验证--以及又一次自我推翻">&lt;a href="#09-%e7%94%a8%e7%9c%9f%e5%ae%9e%e6%95%b0%e6%8d%ae%e9%aa%8c%e8%af%81--%e4%bb%a5%e5%8f%8a%e5%8f%88%e4%b8%80%e6%ac%a1%e8%87%aa%e6%88%91%e6%8e%a8%e7%bf%bb" class="header-anchor">&lt;/a>09 用真实数据验证 —— 以及又一次自我推翻
&lt;/h2>&lt;p>三个坑修完，诊断脚本终于能跑了。用一条只读 SQL 直接查生产库：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-text" data-lang="text">wrangler d1 execute &amp;lt;db-name&amp;gt; --remote
{
&amp;#34;total_review_records&amp;#34;: &amp;#34;约 1.7k&amp;#34;,
&amp;#34;actual_relapse_rows&amp;#34;: 0
}
// meta.rows_read 约为总行数 × 3 —— 三个 COUNT 子查询各扫了一遍全表
&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;strong>再一次推翻：&lt;/strong> &lt;code>review_records&lt;/code> 全表只有一两千行，即便旧查询真的扫了全表，也只是千行的量级，跟单小时读取几百万甚至近千万行的规模&lt;strong>差了三四个数量级&lt;/strong>。&amp;ldquo;relapse-count 查询就是元凶&amp;quot;这个判断，同样站不住脚 —— 它是一个真实存在、值得修的问题，但不是这次事故的真正原因。&lt;/p>
&lt;/blockquote>
&lt;h2 id="10-阶段性现状">&lt;a href="#10-%e9%98%b6%e6%ae%b5%e6%80%a7%e7%8e%b0%e7%8a%b6" class="header-anchor">&lt;/a>10 阶段性现状
&lt;/h2>&lt;p>&lt;strong>已经确认并修复的：&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>请求头中文编码崩溃 → 前端 &lt;code>encodeURIComponent&lt;/code>，后端容错解码&lt;/li>
&lt;li>接口失败导致页面卡死在&amp;quot;加载中&amp;rdquo; → 全部关键页面补上 &lt;code>try/catch&lt;/code> 和错误提示&lt;/li>
&lt;li>无边界的 relapse-count 全表扫描 → 改成写时维护的计数器&lt;/li>
&lt;li>诊断脚本自身的三个 bug（字段名、权限、YAML 语法）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>当时还没查清楚的：&lt;/strong> 那三次单小时读取几百万到近千万行的真正来源。已排除的嫌疑：两个迁移文件（时间对不上）、relapse-count 查询（表太小）。为了不让下一次复发时继续两眼一抹黑，先给 &lt;code>env.DB&lt;/code> 包了一层轻量埋点 —— 每次查询读取的行数只要超过阈值，就记一条日志，带上接口路径和 SQL 文本。&lt;strong>没想到这条悬案很快就有了后续。&lt;/strong>&lt;/p>
&lt;h2 id="11-第三次复发这次真的抓到了">&lt;a href="#11-%e7%ac%ac%e4%b8%89%e6%ac%a1%e5%a4%8d%e5%8f%91%e8%bf%99%e6%ac%a1%e7%9c%9f%e7%9a%84%e6%8a%93%e5%88%b0%e4%ba%86" class="header-anchor">&lt;/a>11 第三次复发：这次真的抓到了
&lt;/h2>&lt;p>埋点刚推上去、还没来得及部署，配额当天就在没有任何部署、没有任何迁移的情况下第三次复发了 —— 只是正常点了几次网页。查了一下当天的数据，比之前任何一次都更极端：&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>日期&lt;/th>
&lt;th style="text-align:right">读查询数&lt;/th>
&lt;th style="text-align:right">读行数&lt;/th>
&lt;th style="text-align:right">平均每次&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>9 月 2 日（健康基线）&lt;/td>
&lt;td style="text-align:right">451&lt;/td>
&lt;td style="text-align:right">89,873&lt;/td>
&lt;td style="text-align:right">≈199&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9 月 4 日（全天）&lt;/td>
&lt;td style="text-align:right">305&lt;/td>
&lt;td style="text-align:right">&lt;strong>21,705,253&lt;/strong>&lt;/td>
&lt;td style="text-align:right">&lt;strong>≈71,165&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9 月 5 日（仅&amp;quot;点了几次&amp;quot;）&lt;/td>
&lt;td style="text-align:right">43&lt;/td>
&lt;td style="text-align:right">&lt;strong>8,648,954&lt;/strong>&lt;/td>
&lt;td style="text-align:right">&lt;strong>≈201,150&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>只有 43 次读查询，平均每次读了 20 万行 —— 说明问题不在&amp;quot;查询发生了多少次&amp;quot;，而在&lt;strong>某一次查询内部本身&lt;/strong>。这个线索第一次指向了具体某个接口，而不是&amp;quot;某种模式&amp;quot;。&lt;/p>
&lt;p>把之前没细看过的接口挨个翻了一遍，翻到 &lt;code>functions/api/study.ts&lt;/code>（学习模式接口）时找到了：它为了在题目看板上显示&amp;quot;这道题上次做的结果&amp;quot;，&lt;strong>每一道题都要跑 4 个相关子查询&lt;/strong>：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-sql" data-lang="sql">&lt;span style="color:#75715e">-- functions/api/study.ts（修复前）
&lt;/span>&lt;span style="color:#75715e">&lt;/span>&lt;span style="color:#66d9ef">SELECT&lt;/span> q.id, ...,
(&lt;span style="color:#66d9ef">SELECT&lt;/span> rr.is_correct &lt;span style="color:#66d9ef">FROM&lt;/span> review_records rr
&lt;span style="color:#66d9ef">WHERE&lt;/span> rr.question_id &lt;span style="color:#f92672">=&lt;/span> q.id &lt;span style="color:#66d9ef">AND&lt;/span> rr.user_id &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#f92672">?&lt;/span>&lt;span style="color:#ae81ff">1&lt;/span>
&lt;span style="color:#66d9ef">ORDER&lt;/span> &lt;span style="color:#66d9ef">BY&lt;/span> rr.answered_at &lt;span style="color:#66d9ef">DESC&lt;/span> &lt;span style="color:#66d9ef">LIMIT&lt;/span> &lt;span style="color:#ae81ff">1&lt;/span>) &lt;span style="color:#66d9ef">AS&lt;/span> last_is_correct,
&lt;span style="color:#75715e">-- 另外 3 个几乎一样的子查询：last_uncertain / last_selected / last_answered_at
&lt;/span>&lt;span style="color:#75715e">&lt;/span>&lt;span style="color:#66d9ef">FROM&lt;/span> questions q
&lt;span style="color:#66d9ef">WHERE&lt;/span> q.source_file &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#f92672">?&lt;/span>&lt;span style="color:#ae81ff">2&lt;/span> &lt;span style="color:#66d9ef">AND&lt;/span> q.question_set &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#f92672">?&lt;/span>&lt;span style="color:#ae81ff">3&lt;/span>
&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>EXPLAIN QUERY PLAN&lt;/code> 一测，发现了跟 relapse-count 一模一样的病根：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-text" data-lang="text">CORRELATED SCALAR SUBQUERY 1
SEARCH rr USING INDEX idx_review_records_user_time (user_id=?)
CORRELATED SCALAR SUBQUERY 2
SEARCH rr USING INDEX idx_review_records_user_time (user_id=?)
-- 子查询 3、4 同上
&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;strong>找到了：&lt;/strong> 4 个子查询的筛选条件其实是 &lt;code>question_id&lt;/code>，但 &lt;code>review_records&lt;/code> 上能用的索引全部是 &lt;code>user_id&lt;/code> 打头的 —— 又是同一个&amp;quot;几乎没有区分度的列打头&amp;quot;的问题，这次的爆炸半径却大得多：&lt;strong>relapse-count 只在打开首页时触发一次，这条查询是&amp;quot;题库里有多少道题就触发多少次，还要 ×4&amp;quot;&lt;/strong>。一道题只要这个用户没做过，子查询就得把这个用户全部历史记录（按时间倒序）扫穿才能确认&amp;quot;没有&amp;quot;。题库越大、历史记录越多，一次页面加载能扫的行数就越夸张。&lt;/p>
&lt;/blockquote>
&lt;p>按接近真实的规模模拟（题库约 1100 道题、用户历史约 1700 条、这个题库刚做过 100 道）：&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">旧版单次页面加载扫描行数&lt;/th>
&lt;th style="text-align:right">加对索引之后&lt;/th>
&lt;th style="text-align:right">差距&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">7,153,400&lt;/td>
&lt;td style="text-align:right">4,596&lt;/td>
&lt;td style="text-align:right">1556×&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>715 万，跟真实观测到的 495 万 / 912 万 / 865 万完全是同一个数量级 —— 这不是巧合，是同一个机制在不同的历史记录量、不同的题库大小下反复复现。修复只加了一行索引，没动任何查询逻辑：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-sql" data-lang="sql">&lt;span style="color:#75715e">-- migrations/0010_review_records_question_index.sql
&lt;/span>&lt;span style="color:#75715e">&lt;/span>&lt;span style="color:#66d9ef">CREATE&lt;/span> &lt;span style="color:#66d9ef">INDEX&lt;/span> &lt;span style="color:#66d9ef">IF&lt;/span> &lt;span style="color:#66d9ef">NOT&lt;/span> &lt;span style="color:#66d9ef">EXISTS&lt;/span> idx_review_records_question_time
&lt;span style="color:#66d9ef">ON&lt;/span> review_records(question_id, user_id, answered_at &lt;span style="color:#66d9ef">DESC&lt;/span>);
&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>question_id&lt;/code> 本身有几千个不同取值，区分度极高 —— 这次终于是&amp;quot;选对了打头的列&amp;quot;。加上索引后，4 个子查询全部变成精确的索引点查：&lt;/p>
&lt;div class="highlight">&lt;pre style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;code class="language-text" data-lang="text">SEARCH rr USING INDEX idx_review_records_question_time
(question_id=? AND user_id=?)
-- 子查询 4：SEARCH rr USING COVERING INDEX ...（连表都不用回查了）
&lt;/code>&lt;/pre>&lt;/div>&lt;p>顺带一提，&amp;ldquo;返回上一题标记蒙的&amp;quot;那个功能用的是一模一样的查询写法 —— 不用改一行代码，这次也跟着沾光修好了。&lt;/p>
&lt;hr>
&lt;p>这篇复盘想说明的其实是一件很朴素的事：排查过程里&amp;quot;听起来合理&amp;quot;的解释，跟&amp;quot;有真实数据支撑&amp;quot;的结论，是两件不同的事 —— 这次一共有过两次被质疑后自我推翻，直到第三次真正复发，靠着回去挨个重新审查代码、而不是继续在&amp;quot;这次又是为什么&amp;quot;上做数据分析，才真正找到了根因。埋点已经加上了，如果这次的修复不是全部答案，下一次至少能直接看日志，不用再靠猜。&lt;/p></description></item></channel></rss>