如何设计数据库查询视图、读模型与报表宽表
不要先背 CQRS 和宽表;先解释“明细是对的,首页和月报为什么是错的”
这一页只解决一个问题: 当账目事实已经写对,用户在列表、首页、搜索、月报和导出里为什么还会看到错数。读模型不是概念装饰,它就是“用户读到的那一层数据库设计”。
用户新增一笔 88 元晚餐。明细页能看到,说明事实表写入成功;但首页“本月支出”没变,分类列表没有,搜索搜不到,月报导出也少 88 元。此时不要再问“查哪张表”,先问“这些页面各自读的是不是同一个世界”。
| 错在哪里 | 说明什么 | 设计动作 |
| 明细对,首页错 | 首页不是事实表,是聚合读模型 | 定义来源、刷新时机、重算范围 |
| 明细对,搜索错 | 搜索索引不是事实源 | 索引可延迟,但必须可补建和对账 |
| 页面对,导出错 | 两个读路径口径分叉 | 报表宽表要有口径版本 |
| 昨天对,今天错 | 旧批次或旧事件覆盖了新结果 | 读模型要有构建批次和水位 |
| 问题 | 例子 |
| 这个读模型服务哪个页面或报表 | 账目列表、首页摘要、月报导出不能混成一张万能表 |
| 它的事实来源是什么 | 账目、状态事件、分类快照、导入来源、汇率版本 |
| 它允许延迟多久 | 列表要接近实时,月报可以批量生成,搜索可以标注延迟 |
| 它错了按什么范围重算 | 按用户、账本、月份、分类、entry_id 或 batch_id |
| 它怎样证明和事实源一致 | 条数、金额、hash、抽样样本、差异明细 |
| 旧结果能不能覆盖新结果 | 用 version、watermark、build_batch_id 拦住旧批次回写 |
不要一上来做“万能宽表”。先为一个读问题建一个最小模型。下面是月报读模型,不是事实表。
monthly_report_rows
- id
- ledger_id
- report_month
- metric_version
- build_batch_id
- source_entry_id
- amount_minor
- currency
- category_snapshot_id
- included_status_rule
- source_updated_at
- row_hash
- published_at
吸收点: 事实表回答“发生了什么”;读模型回答“某个场景要怎样读”。读模型可以冗余,但必须能从事实源重建。
| 读场景 | 不要怎么做 | 应该怎么做 |
| 列表 | 用 SELECT * 加 offset 深分页 | 用稳定游标:occurred_at + id,并统一状态、权限、来源字段 |
| 首页摘要 | 把聚合结果当随手缓存 | 写清来源事实、刷新触发、失效范围、重算办法 |
| 月报宽表 | 只存一个汇总金额 | 保存口径版本、构建批次、来源 entry、分类快照和发布时间 |
| 搜索索引 | 把搜索结果当事实裁决 | 搜索只负责找,打开详情仍回事实表和权限校验 |
关键边界: 读模型错了,不要直接手改宽表金额。优先回到事实源,重放生成逻辑,或按明确范围重建。
| 样本 | 通过标准 |
| 新增一笔账 | 明细、列表、首页、搜索、月报最终一致,能解释延迟 |
| 作废一笔账 | 月报和首页撤掉金额,搜索不再展示有效结果 |
| 发生时间跨月修改 | 旧月和新月都被重算,不只更新新月 |
| 分类改名 | 列表可显示新名,历史月报不静默漂移 |
| 旧批次晚到 | 旧 build_batch_id 不能覆盖已发布的新版本 |
| 搜索漏文档 | 对账能发现 entry_id 差异,并按 id 补建 |
总结: 读模型设计不是“多建几张方便查的表”。它的核心是:为一个读场景固定来源、口径、刷新、版本、重算和对账。只要这 6 件事说不清,宽表越多,用户看到的世界越容易和事实源分叉。