# 数据与状态 · 一条共享连接的纪律

> 技术参考 · 深入原理(架构师向)。承接《[扩展模型](46-extending.md)》。
> 平台的数据层有一条几乎所有"动钱/动状态"提案都绕不开的纪律:**共享连接 + 读写池 + 显式事务**。不懂它,你的提案很可能引入静默的数据腐蚀,或拖垮整个进程。这一篇讲清楚这三条路、原子性怎么保证、以及那个最容易踩的坑。

---

## 三条路:共享连接 / 读池 / 写池

数据库访问统一经一个入口,它按上下文给你三条路之一:

| 路 | 是什么 | 什么时候用 |
|---|---|---|
| **共享连接** | **单条**连接,autocommit,一把锁**串行一切**(不是池)——慢一下就堵全进程 | 默认:单条语句自提交的读写 |
| **读池** | autocommit 连接池,读并行 | 把读从共享连接的串行里挪开(读多/重读) |
| **写池** | autocommit 连接池,写并行 | **独立、单语句、自提交**、不属于其它事务单元的写;常见于日志/用量/计数,也可用于本身已原子的条件 `UPDATE` / `UPSERT` |

**默认落共享连接** —— agent/worker 里随手读写都在它上面。

---

## 原子性:多语句必须显式包事务

这是最关键的一条。**共享连接是 autocommit 的——每条语句各自提交,没有隐式的"一次 commit"。** 所以:

> 凡是"要么全成、要么全回滚"的多语句单元(尤其**动钱**),**必须**显式包进一个事务块;否则会被静默拆单,中途失败就留下半成品(静默腐蚀)。

事务块内的纪律:

- 整段持锁 → **块内不能有长 await**(LLM / 网络调用挪到块外);
- 块内读**看得到**本单元未提交的写;
- 块内**任一语句失败 abort 整块** —— 所以"吞异常的 best-effort 写"要留在块外。

> 这就是为什么本项目里每一次动钱的 review 都盯着"扣款+入账+台账是不是在同一个事务里"——不在,就是一个能凭空造钱或丢钱的 bug。

---

## 头号坑:别用共享连接跑大查询

**共享连接被一把锁串行。** 如果你在它上面跑一个大 `fetchall` 或慢扫描,它会**持锁数秒,堵死整个进程**的所有读写。

- 读多写少的整段(如上下文组装)→ 用"读上下文"模式(首次写自动回落共享连接,后续读能看到该写;它**不会额外提供多语句原子性**,要原子仍须显式事务);
- 确知与未提交写无关的重读(如知识库/embedding 扫描)→ 用"强制读池"逃生舱。

> 反向更糟:把依赖未提交写的读写套进池 → 读不到/写不进同一单元。选错路和漏包事务,是数据层两类最隐蔽的 bug。

---

## 其它约定(提案时会碰到)

- 数据库是 **PostgreSQL**;占位符用 `%s`(不是 `?`);
- 时间戳存为 **TEXT**(`'YYYY-MM-DD HH:MM:SS'` UTC);
- 原始 SQL 只放在数据层(`infra/dbpg/*`),Module/Infra 调它,不散写 SQL;
- 没有自动迁移 runner:schema 变更是**带日期的迁移文件 + 手动应用**,hot 表 `ALTER` 要带 `lock_timeout`。

---

## 对架构提案意味着什么

任何涉及**经济、状态、计数**的提案,都要在设计里回答:

1. 这组写**是不是一个原子单元**?是 → 明确"包进一个事务";
2. 有没有在共享连接上**跑大查询/慢扫描**的风险?有 → 指明走读池;
3. 要不要走写池?只有在写是**独立、单语句、自提交**,且不依赖另一个语句的成功/未提交结果时才可以;
4. 迁移怎么做?hot 表改动有没有 `lock_timeout`、是否幂等?

答不清楚这四条,一个动数据的提案就还不成熟。

---

*这一篇属于「深入原理(架构师向)」。想理解这些数据层被谁调用,回到《[组件全图](43-components.md)》;想知道怎么把一个能力(含它的数据)slot 进平台,看《[扩展模型](46-extending.md)》。*
