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

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


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

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

是什么什么时候用
共享连接单条连接,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、是否幂等?

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


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