“Preparing… 0/0”——屏幕上的这行提示看起来像是一个正在推进的进度,但当你等了很久,它依然纹丝不动。
这就是 TextStack 的用户在点击“Ask this book”时遇到的真实场景。对于某些上传的书籍,界面就这样永远卡在了准备阶段,既不会报错,也不会完成,重试按钮也毫无作用,唯一的恢复手段竟然是开发者手动去数据库里改动记录。
这个开源技术书籍阅读器的 RAG(检索增强生成)索引流程里,藏着一个状态机设计上的经典反例:一个一旦进入就无法靠自身逻辑离开的状态,一个看起来像“进程”的“黑洞”。修复它的过程,恰好是一堂关于异步任务、原子操作和韧性设计的实操课。
要理解这个 bug 的成因,得先回到旧版索引的请求流程。当用户点击索引按钮,前端发出一个 POST /me/books/{id}/index 请求,事情就开始了。但在旧代码里,这个端点承担了太多职责:
POST /me/books/{id}/index -> 将 rag_status 设置为 Indexing (在端点内立即执行) -> 接着直接执行整个文档分块和视觉解析 (仍在同一个 HTTP 请求内) -> 所有工作完成后才返回 202问题就出在“工作直接跑在 HTTP 请求里”和“状态切换先于工作完成”这两个设计上。TextStack 为了支持 PDF 上传,会调用付费的视觉解析服务把每一页转换成可检索的文本,这个过程耗时以分钟计。Cloudflare 等中间代理会在超时后就切断连接,API 容器在每次部署时也会重启,无论哪种情况,正在执行解析的进程都会被悄无声息地杀死。而此时数据库的那一行记录已经变成了:
rag_status = Indexing, rag_chunk_count = 0接着,更致命的限制来了。因为重试路径只会认领状态为 NotIndexed 或 Failed 的行,所以在代码逻辑里,一条正在“Indexing”的记录永远不可能被重试:
// 旧的 claim 逻辑:Indexing 状态的行无法被认领if (book.RagStatus is RagIndexStatus.NotIndexed or RagIndexStatus.Failed) { ... }结果就是,当某本书因为请求中断、容器重启等意外停在中途时,“chunk 数为 0 的 Indexing”成了一个没有出口的状态:它不是“未索引”,所以不会被端点再次受理;它不是“失败”,所以重试机制跳过它;它也不是“已完成”,所以用户永远看不到答案。一个看起来像“正在准备”的假象,成了前端永远刷不走的死胡同。
修复的思路非常清晰:让端点只负责转换状态并快速返回,将实际工作交给后台工作进程,同时让每一个状态最终都能到达一个“终局”(完成或失败)。具体的改造分三步实现。
第一步,解耦 HTTP 路径和工作负载。端点接到请求后,只做一件事:把对应书籍的 rag_status 置为 Indexing,然后立刻返回 202。任何付费的视觉解析都不再占用请求线程,Cloudflare 的超时杀死、容器重启丢进程等风险被完全隔离在管道之外。
第二步,引入后台 Worker 并用原子更新实现无锁抢单。为了避免引入消息队列等额外基础设施,直接利用 PostgreSQL 的单条 UPDATE 做工作认领。Worker 的 SQL 像这样:
var claimed = await db.Database.ExecuteSqlInterpolatedAsync($""" UPDATE user_books SET rag_indexing_started_at = now() WHERE id = {bookId} AND rag_status = 1 -- Indexing AND rag_chunk_count = 0 AND rag_indexing_started_at IS NULL; """, ct);if (claimed == 0) return; // 已经被别人领走了,直接退出这里的并发控制完全交给了数据库的 WHERE 条件。只有那些状态确为 Indexing、chunk 数为 0 且尚未被任何 Worker 打上时间戳的行,才会被更新。如果多个 Worker 同时尝试认领同一行,PostgreSQL 会立刻将 UPDATE 序列化,最终只有一条语句能够实际影响一行,其余的 claimed 返回 0 并自动退出。没有锁表、没有复杂的分布式协调,数据库本身就是公正的裁判。
第三步,增加一个周期性清理任务来回收“死行”。即使有了 Worker 机制,仍然可能发生 Worker 进程意外被杀的情况,从而产生与之前类似的“卡死”行。清理逻辑会定期扫描那些 Indexing 状态且 rag_indexing_started_at 超时仍未完成的行,将其状态重置为 NotIndexed,让下一次端点请求或 Worker 周期调度能重新捡起。
三个动作组合在一起,就形成了一个首尾相接的闭环:端点只负责标记“你要被处理了”,Worker 通过原子更新抢走任务并务必终结为完成或失败,清扫进程则包揽所有中途夭折的“僵尸”记录,把它们推回起点。这时候再回顾当初那个“没有出口”的设计,会发现根源其实是状态机设计中常见的陷阱:有了中间态,却没有覆盖所有可能的路径将其转换为终态。看上去多了一个状态,实际上多了一条可以一直流连进去的死胡同。
对开发者而言,这个案例也提供了一个低成本的实践参照:异步处理不一定需要引入 Redis、Kafka 等独立组件,一次数据库的原子更新配合后台服务,就能解决很多因长任务超时和中断带来的状态不一致问题。当然,这意味着每一列的含义必须足够精确,WHERE 条件必须能确切地挑出尚未被认领的任务,并且终态必须有兜底机制。TextStack 的这次修复就展示了:即使是最简单的“准备中”状态,也需要一条明明白白的出口路径——要么完成,要么失败,绝不能永久地停留在 0/0 的幻影里。
全文对应的具体代码改动可以在 TextStack 仓库 中的 RagIndexingService.cs 和 RagIndexingWorker.cs 两个文件找到。如果你也在构建类似的异步任务流水线,不妨看看那个 SQL WHERE 条件——一个简单的判断,也许就能帮你省掉一整块中间件的复杂度。
热门跟贴