
多路径写入一致性从一次 Debug 到系统性防御写在前面这不是一篇通用的技术教程而是一次真实 Debug 的完整复盘。问题本身很简单——“博客分类筛选不工作”——但往下挖了四层才发现表象各异的四个 Bug 其实指向同一个设计缺陷。这个缺陷从项目的第一行代码就埋下了后续每次加功能都在上面打补丁直到补丁本身变成了问题的一部分。我想通过这个案例说清楚三件事数据一致性不是靠修 Bug 修出来的是设计阶段决定的多路径写入不一致有一个很简单的判断方法可以在代码生成之前就发现AI 编程中这个问题尤其容易被放大但也有对应的解决手段一、先理解架构数据是怎么流转的1.1 文章和分类的关系在动手之前先看清项目的三个核心数据实体和它们的存储方式Tag标签 Category分类 ───────────────────────────── ───────────────────────────── Tag 表独立实体从第一天就在 Article.category字符串字段 ↑ ↑ _get_or_create_tags() 后来需要侧边栏展示 → ↑ SiteSetting.blog_categories新开一个存储 article_tags关联表 再后来需要多入口编辑 → 没有重构继续打补丁关键对比标签一张表、一个写入函数、一个关联表 →从不出问题分类一个字符串字段 一个平行列表 两个写入入口 →到处出问题这不是巧合。1.2 什么是多路径写入不一致同一个语义数据存在两条以上写入路径各路径指向不同存储位置最终数据漂移。正常情况单路径 编辑器 → _get_or_create_tags() → Tag 表 → 所有读取方 ↑ 唯一来源永不漂移 问题情况多路径 编辑器 → Article.category字符串字段 管理台 → SiteSetting.blog_categories平行列表 ↑ 两个写入目标天然不一致二、Debug 过程四层下钻问题从表面到底层一共四层。每一层发现一个不同的 Bug但最终都指向同一个根因。第一层为什么标签链接点了没反应现象点击博客页的标签链接URL 不变页面刷新。排查打开浏览器开发者工具 → Elements → 查看标签a的href。a href#AI/a ↑ 空的原因Jinja2 模板中有一行代码{% set tag_url /blog?tag tag_info.name (category current_category if current_category else ) %}问题出在运算符优先级。Jinja2 的条件表达式优先级低于实际解析为(/blog?tag tag_info.name category current_category) if current_category else 当current_category为空默认情况tag_url 。空href等于刷新当前页。修复拆成两行不用行内条件表达式。{% set tag_url /blog?tag tag_info.name %} {% if current_category %} {% set tag_url tag_url category current_category %} {% endif %}这一层的本质模板表达式求值 Bug。与数据一致性无关但为后续排查开了个头。第二层为什么新建的分类不出现现象创建文章时选了一个新分类名比如深度学习保存后博客侧边栏找不到这个分类。排查分别追踪写和读两条链路。写入链路 文章编辑器 → POST /api/articles → Article.category 深度学习 读取链路 博客侧边栏 → _get_category_list(db) → SiteSetting.blog_categories → [AI, Python后端, ...] ↑ 没有深度学习问题是两端的存储位置不同。文章写了Article.category但侧边栏从SiteSetting.blog_categories读。新分类名从未被注册到列表中。文章保存 Article.category字段 A 博客展示 SiteSetting.blog_categories列表 B ↑ 链路在此断裂对应到架构图标签的工作方式正确 输入 Python → _get_or_create_tags() → Tag 表 → 所有读取方可见 ✅ 分类的工作方式错误 输入 深度学习 → Article.category 深度学习 → SiteSetting.blog_categories 不知道这件事 ❌修复添加_ensure_category_in_list()每次保存文章时将新分类名注册到列表中。def_ensure_category_in_list(db,category):ifnotcategory:returncats_get_category_list(db)ifcategorynotincats:cats.append(category)_save_category_list(db,cats)手动埋了个事后同步函数。这一层的本质这是设计阶段留下的坑。分类从一开始就是字符串字段后来为了展示侧边栏加了平行列表但文章编辑器不知道这个列表的存在。两处存储之间没有同步机制。第三层为什么编辑已有文章的分类不生效现象进入文章编辑页修改分类保存。回到博客页文章还在旧分类下。排查抓 API 请求看数据流。前端发出的请求PUT/api/articles/5{category:AI,title:...,content:...}后端接收的模型classArticleUpdate(BaseModel):title:Optional[str]Nonecontent:Optional[str]Nonesummary:Optional[str]Nonecover_image:Optional[str]Noneis_published:Optional[bool]None# ❌ category 在哪里ArticleUpdate没有定义category字段。FastAPI/Pydantic 收到{category: AI}后直接丢弃update_article中的if category in update_data永远为 False。这个字段从项目一开始就缺失——因为创建文章用的是ArticleCreate继承自ArticleBase有category但更新文章用的是独立的ArticleUpdate只是当时没人记得加上去。修复加一行category: Optional[str] None。这一层的本质代码的写端也有断点。文章编辑器发了数据但 API 层根本没接收。第四层为什么首页文章数不对现象首页侧边栏显示文章6实际有十几篇。排查追踪模板变量来源。{{ articles|length }} ← 这个值只有 6追踪到路由articlesquery.order_by(...).limit(PAGE_SIZE).all()# 默认 PAGE_SIZE 9totalquery.count()# 真正的总数被计算了return_ctx(request,articlesarticles[:6],# 传到模板的只有 6 篇# totaltotal ← 被计算但没传递)total被计算了但没传到模板。模板只能退而求其次用articles|length显示第一页的篇数。修复把total传过去。这一层的本质服务端与模板之间的参数传递断链。不是 Bug是遗漏。四个 Bug 的共同点第一层模板表达式 Bug ← 代码写错了 第二层新建分类不注册到列表 ← 两处存储没同步 第三层ArticleUpdate 缺字段 ← 代码少写了 第四层total 没传模板 ← 参数漏传了前两个影响功能后两个表面不影响——但它们指向同一个方向这个项目的分类功能从设计到实现都缺乏对数据完整链路的把控。每一步都是独立加上去的没有人完整地追踪过一篇文章的分类从输入到展示走过了哪些环节。三、追根溯源问题是怎么一步步积累的从 Git 历史看这个功能的时间线第 1 天Article.category Column(String) 需求文章需要一个分类字段。 选择字符串字段因为简单。 问题判断❌ 没有预判分类未来会被复用 第 N 天SiteSetting.blog_categories 需求博客页需要侧边栏展示分类列表。 选择在 SiteSetting 新建一个 JSON 列表。 问题判断⚠️ 应该重构 Article.category 为独立表但选择了再加一个存储 第 NM 天文章编辑器自由输入分类 需求用户可以在写文章时新建分类。 选择input 加 datalist不限制输入。 问题判断❌ 没有建立任何分类注册机制 第 NMK 天管理台分类管理 需求需要独立管理分类的地方。 选择新建分类 CRUD API操作 SiteSetting.blog_categories。 问题判断❌ 第三个入口第三个存储操作依然没有归一化每一个决策在当下都是合理的。字符串字段简单平行列表快速自由输入灵活管理 CRUD 必要。但累积起来就形成了一个每个入口各写各的的架构。这就是典型的补丁式演进——每一次都在上一层的补丁上再打补丁直到补丁本身变成问题。四、修复方案从打补丁到重构4.1 短期修复修 Bug四个 Bug 各自修复耗时约 15 分钟。4.2 中期方案写后同步添加_ensure_category_in_list()在文章保存时将新分类注册到列表中。解决了数据不出现的问题但未解决两处存储的结构问题。4.3 长期方案归一化真正的修复是让分类和标签走完全相同的模式# 新增 Category 表与 Tag 表完全对应classCategory(Base):__tablename__categoriesidColumn(Integer,primary_keyTrue)nameColumn(String(50),uniqueTrue,nullableFalse)# Article 新增 FKclassArticle(Base):category_idColumn(Integer,ForeignKey(categories.id))category_relrelationship(Category,back_populatesarticles)# 与 _get_or_create_tags 完全一致的写入函数def_get_or_create_category(db,name):ifnotname:returnNonecatdb.query(Category).filter(Category.namename.strip()).first()ifnotcat:catCategory(namename.strip())db.add(cat)db.flush()returncat.id然后删除所有旧代码删除_get_category_list()、_save_category_list()、CATEGORIES_KEY删除_ensure_category_in_list()博客侧边栏改为直接从Category表查询管理台分类 CRUD 改为操作Category表启动时自动迁移旧数据修复后分类的数据流变成了写入 文章编辑器 → _get_or_create_category() → Category 表唯一存储 管理台 → CRUD API → Category 表唯一存储 读取 博客侧边栏 → Category 表查询 文章详情 → Article.category_rel 管理台列表 → Category 表查询 所有路径指向同一个位置。不再需要同步函数不再有平行列表。五、从这次经历中提炼的设计原则原则一SSOT 判定——在写第一行代码前决定存储方式一个字段应该用字符串还是独立表判断标准不是现在需要多少功能而是未来可能被怎么用。这个问题只要有一个是就应该用独立表 □ 会被多条记录复用吗 □ 会被作为筛选条件吗 □ 需要在侧边栏/导航中展示吗 □ 用户需要统一重命名或删除吗案例对比字段判断选择结果Article.tags复用 筛选 管理Tag独立表 ✅从不出问题Article.category复用 筛选 管理字符串字段 ❌四处打补丁Article.cover_image只展示字符串字段 ✅从不出问题原则二数据链路审计——列出所有入口和出口每新增一个数据实体强制做一次链路审计写入口 ①A 功能 → 写到哪里 写入口 ②B 功能 → 写到哪里 读出口 ①C 页面 → 从哪里读 读出口 ②D 页面 → 从哪里读 → 所有写的指向同一个存储 → 所有读的指向同一个存储 → 读写指向同一个存储如果任一答案是否链路在这里断裂。原则三警惕事后同步函数代码中出现以下函数名时是强烈的设计警告_ensure_xxx_in_list()# 确保 xxx 在列表中——为什么不在写的时候就放进去sync_xxx_from_articles()# 从文章同步 xxx——为什么要事后同步migrate_xxx_data()# 迁移 xxx 数据——为什么要等数据先错再修如果一个字段需要事后同步函数来保持一致性说明设计已经出了问题。正确的设计不需要同步函数——因为所有路径在写入时就放在了正确的位置。原则四补丁式演进是数据一致性的最大敌人错误的决策 不重构 必然的技术债 正确的决策 不重构 正常的代码演化 错误的决策 及时重构 可以被接受的弯路问题不是第一次选错了存储方式而是发现错了之后没有重构而是继续在错误的基础上加补丁。六、AI 编程中如何系统性避免这类问题6.1 问题本质AI 倾向于走最小路径当人类说给文章加个分类功能时AI 最自然的输出是classArticle(Base):categoryColumn(String(50),default)# 最小改动最快完成这不是 AI 的错——它被训练成优先理解并满足当前请求而不是预测六个月后的需求。AI 缺少两个能力预判不知道分类未来会被怎样复用全局审计不知道项目中有没有类似 Tag 的模式可以参考6.2 解决方法一在需求描述中注入设计约束与其说加分类功能不如说给文章加分类功能。 设计要求 1. 分类必须用独立表不要字符串字段参考已有的 Tag 表模式 2. 提供 _get_or_create_category() 函数参考 _get_or_create_tags() 3. 列出所有写入和读取分类的路径为什么有效AI 其实知道应该怎么做——你给了明确的约束它就能输出正确的结果。问题在于你不说它就不做。6.3 解决方法二让 AI 做负向推演在 AI 输出方案后追问这个设计有什么数据一致性风险 列出所有可能写入这个字段的代码路径。 这些路径是否都指向同一个存储为什么有效AI 能识别风险但它不会主动说出来。你需要一个提问来触发它做安全检查。就像代码审查中问这个函数有什么边界情况一样。6.4 解决方法三在项目记忆中固化设计规则在项目的CLAUDE.md或记忆系统中加入## 数据一致性规则 - 共享实体分类、标签、状态等必须使用独立表 FK 关联 - 禁止使用字符串字段 平行列表的模式 - 添加新数据实体时必须列出所有写入/读取路径 - 参考 Tag 表的 _get_or_create_tags 模式为什么有效AI 在每次交互时会加载这些规则。它不需要你每次都说一遍。6.5 解决方法四对 AI 的输出做模式识别审查人工审查时快速扫描 AI 生成的代码✅ 好信号安全 category_id Column(Integer, ForeignKey(categories.id)) _get_or_create_category() # 原子创建 db.query(Category).all() # 从唯一表读取 ❌ 危险信号需要追问 category Column(String(50)) # 字符串字段 → 将来可能被复用 settings.xxx # 平行配置 → 有没有主存储 _ensure_xxx() / _sync_xxx() # 事后同步 → 为什么不在写入时解决 xxx_list / xxx_cache / xxx_backup # 冗余存储 → 有没有一致性保障为什么有效这些模式在代码中非常明显一眼就能看出来。你不需要深入理解业务逻辑。6.6 一个完整的交互示例差的交互我给文章加个分类功能。 AI好的在 Article 表加一个 category 字段。 我...... # 六个月后数据开始漂移开始打补丁好的交互我给文章加分类功能。 设计要求独立表 FK 关联参考 Tag 的实现。 AI创建 Category 表Article 加 category_id FK提供 _get_or_create_category()。 写入路径① 文章编辑器 → POST/PUT /api/articles → _get_or_create_category() ② 管理台 → POST /api/categories → 直接写 Category 表 读取路径① 博客侧边栏 → 查询 Category 表 ② 文章详情 → Article.category_rel 所有路径指向同一存储。没有平行列表不需要事后同步。区别不在于 AI 的能力——同一套 AI 模型在前者输出错误方案在后者输出正确方案。区别在于你给了多少约束。七、检查清单7.1 新功能开发自检SSOT 判定这个数据需要独立表吗数据链路已列出所有写入口指向同一存储所有读出口从同一存储读取级联操作已定义删除/重命名时关联数据如何处理多表操作在同一事务中7.2 代码审查中识别危险信号有没有Column(String)字段未来可能变成独立实体有没有独立于主存储的列表或缓存有没有同步、“修复”、确保类函数GET 请求有没有写操作副作用定时任务和用户操作是否写同一资源7.3 AI 编程约束需求描述中注明了设计约束独立表 / 链路审计 / 参考模式对 AI 的输出做了模式识别审查好信号 ✅ / 危险信号 ❌追问了有哪些数据一致性风险项目中固化了数据一致性规则CLAUDE.md 或记忆系统