模块二-架构基础功 | 第11讲:事件驱动架构 - 用 Event Sourcing + CQRS 构建 CodeSentinel 的审核事件流
开场:审核系统最值钱的不是「当前状态」,而是「当时为什么是这个状态」
传统 CRUD 很容易把 CodeSentinel 做成「表里只有最新结论」:PR 审核通过或未通过,一行记录结束。但一旦进入企业审计、合规复盘、模型误报追责,你会被问到更难的问题:谁在什么时间依据什么证据做了判断?如果重来一次,同样输入会不会得到同样结论? 这些问题指向同一类能力:不可篡改的事实序列 与 可重建的读模型。
很多团队第一次接触事件溯源会担心「复杂度爆炸」。我的经验是:复杂度不会凭空消失,只会转移——你不把它放在事件与投影上,就会把它散落在 无数 update 语句、临时补丁表、人工对账 Excel 上。CodeSentinel 作为 AI 时代的治理平台,越早把「事实层」立住,越能在后面接入更复杂的模型与规则而不失控。换句话说,事件溯源不是目的,可解释、可复盘、可演进 才是目的;事件只是实现这些目的的一条成熟路径。
Event Sourcing(事件溯源) 的核心是:不把业务真相简化成可变行,而是把真相存成 追加-only 的事件日志。CQRS(命令查询职责分离) 则把「写模型(命令侧)」与「读模型(查询侧)」拆开:写侧专注一致性、读侧专注查询性能与展示形态。对 CodeSentinel,这套组合几乎是「天然匹配」:审核本身就是一连串可被命名的业务事实(提交、启动、发现违例、完成、批准),而看板与报表往往需要 投影(projection) 成宽表或物化视图。
本讲会给你一套 完整可运行的 Python 教学实现:事件基类与具体事件、EventStore(SQLite,可替换为 PostgreSQL)、命令处理器、读侧投影。你会理解为什么事件驱动能同时服务 审计追溯、重放、时间查询,以及生产里必须补上的 并发控制、版本、幂等。
把「为什么要事件溯源」讲得更功利一点:当业务方质疑「为什么当时审核没过」时,如果你只有一张最终状态表,你只能回答结果;如果你有事件流,你可以回答 证据链——什么时候开始审、发现了哪些违例、何时完成、谁批准。对引入 LLM 的 CodeSentinel,这一点更关键:模型输出不稳定时,你必须能把一次结论绑定到 输入上下文版本、规则包版本、提示模板版本 等元数据上,而这些元数据最适合作为事件 payload 的字段逐步演进。
当然,事件溯源不是免费午餐:存储成本、投影复杂度、团队心智负担都会上升。因此本讲同时强调 CQRS 的分工:写侧追求真相序列,读侧追求体验与性能。你要学会判断:哪些查询可以最终一致,哪些必须同步读主存储或携带版本号。
全局视角:CQRS + 事件溯源在 CodeSentinel 中的分层
第一张图给出经典 CQRS:命令进入聚合,产生事件入库存储;异步投影更新读库供 API 查询。读侧失败不应反过来污染写侧:投影器可以重试、可以重建,但不要轻易「回滚」已经 append 的事实,除非你有明确的补偿事件与业务流程支撑。
flowchart LR
subgraph Write["命令侧 Write Model"]
CMD[Command Handler]
AR[Review Aggregate]
ES[(Event Store append-only)]
end
subgraph Read["查询侧 Read Model"]
PJ[Projector]
RV[(Read DB / Materialized View)]
Q[Query API]
end
CMD --> AR
AR --> ES
ES --> PJ
PJ --> RV
Q --> RV
第二张图是 审核生命周期状态机(简化教学版),事件驱动迁移。真实系统还会有「暂停、等待人工、等待外部扫描回调」等状态;不要把状态机画得过于理想化,否则实现阶段会被现实流程击穿。建议你把「教学版状态机」与「企业版状态机」分成两张图维护,避免新人误把简图当规范。
stateDiagram-v2
[*] --> Draft
Draft --> InReview: ReviewStarted
InReview --> InReview: ViolationFound
InReview --> Completed: ReviewCompleted
Completed --> Approved: ReviewApproved
Completed --> Rejected: ReviewRejected
第三张图描述 跨服务事件流(与上一讲消息层衔接):命令侧落库后 outbox 投递,通知与报表消费。你可以把它理解为:事件存储是系统内部的「会计账簿」,消息队列是「广播喇叭」——账簿必须先记对,喇叭才能大声;反过来,喇叭坏了不应把账簿撕掉重记。
sequenceDiagram
participant U as User/CI
participant API as Command API
participant H as CommandHandler
participant ES as EventStore
participant P as Projector
participant N as NotificationWorker
U->>API: SubmitReviewCommand
API->>H: handle
H->>ES: append PRSubmitted, ReviewStarted...
ES-->>H: ok(stream_version)
H-->>API: ack
ES->>P: poll new events
P->>P: update dashboard view
ES->>N: ReviewCompleted (via outbox in prod)
N-->>U: webhook
核心原理:事件、存储、投影与 CQRS 的边界
Event Sourcing:存事件,不是只存最终状态
事件是不可变事实:PRSubmitted 发生了就不会「变成没发生」,你只能再追加纠正类事件(例如 ReviewCorrected)。这带来强大审计能力,也带来建模约束:你要谨慎设计 事件粒度——太细爆炸,太粗丢失信息。
把「不可变」说清楚:不可变不是不能纠错,而是 纠错也要通过追加事实 来完成。对审核系统,这对应真实世界的流程:你不会「把历史记录橡皮擦掉」,你会新增一条「更正说明」。当产品经理说「能不能直接改数据库」时,你可以用这套类比解释为什么事件溯源更贴近合规与审计需求。
CQRS:读写分离不是银弹,是权衡
读模型可以为了大屏查询做反规范化;写模型保持聚合一致性。代价是 最终一致:投影延迟会让用户短暂看到旧数据。审核系统通常可接受秒级延迟,但合入门禁若要强一致,需要明确哪些读路径走写库或版本校验。
读写分离的另一个隐性成本是 心智模型:团队成员要习惯「写入成功 ≠ 立刻读到」。这要求产品文案、接口设计与监控三者一致。否则客服会收到大量「我明明提交了为什么看不到」的工单,而根因只是投影延迟三十秒。
为什么特别适合代码审核
- 审计:每个违例发现可对应
ViolationFound; - 重放:规则包升级后可重放事件验证新规则误报率;
- 时间查询:「上周某策略启用后发生了什么」可用事件流回答。
CodeSentinel 事件字典(本讲子集)
PRSubmitted:PR 进入审核;ReviewStarted:审核启动;ViolationFound:记录违例;ReviewCompleted:审核完成(含结论摘要);ReviewApproved:人工/策略批准。
真实系统还会继续扩展,例如 ReviewFailed、RulePackUpgraded、LLMInferenceRecorded 等。事件字典应作为 受控文档 维护,而不是让工程师随手发明新字符串事件名——否则投影器与报表会出现「暗事件」,AI 生成代码时更容易拼写错误。
乐观并发与 stream version
同一 review_id 的事件流必须有 版本号,防止两个命令并发写入导致丢失更新。教学实现用 SQLite 的 stream_version 校验。
你也可以把 expected_version 理解成「我对当前真相的认知版本」。客户端携带版本号提交命令,服务端发现版本不一致就说明真相在你离线时已经前进——这不是异常,而是分布式协作常态。把这一点写进 API 文档,能显著减少前后端互相指责。
幂等与命令去重
网络重试会让同一命令到达两次。应用层需要 idempotency key 或命令唯一 ID,确保重复追加不会产生重复事实(或产生可识别的重复事件并投影去重)。
对 CodeSentinel,SubmitPRCommand 很适合携带 CI 侧生成的 delivery_id 作为幂等键:同一个 webhook 重放不会创建两条审核流。没有这一层,你会在高峰期看到重复审核、重复通知,甚至重复计费。
从教学到生产:PostgreSQL、JSONB、分区与归档
SQLite 适合课堂与单测;生产常用 PostgreSQL:events 表按 occurred_at 分区,冷热分层。投影失败要 可重试、可死信、可告警。
命令查询分离后的「读自己的写」
用户提交命令后立刻刷新页面,如果投影延迟,他可能看不到最新状态。处理方式包括:命令响应体返回 预测读模型(基于刚追加的事件在内存合并)、或返回 version 让前端轮询直到读模型追上。CodeSentinel 的 PR 详情页非常适合携带 expected_read_version,让 UI 在短暂不一致时显示友好提示,而不是错误结论。
重放(Replay)在规则迭代中的实战用法
当你升级规则包或更换静态扫描引擎版本时,可以对历史事件流重放,比较「新规则下会多报还是少报」。注意:重放环境必须隔离,不能对真实外部系统重复触发副作用——因此事件里要把 副作用 与 事实 分层,或通过 outbox 标记「仅重放模式」。AI 团队特别喜欢这种能力,因为它让「模型/规则变更」可以被量化评估,而不是凭感觉上线。
与 AI 生成代码的关系
AI 爱写「直接 update 表字段」。你要用 事件优先 的脚手架约束:改状态必须 append,读状态优先走投影。把这条写进 AGENTS.md,比口头强调更有效。
事件建模:粒度太细与太粗的典型症状
太细的症状是:一次用户操作裂变出上百条事件,投影与排查都痛苦;太粗的症状是:一条事件塞满巨型 JSON,失去组合与重放价值。对 CodeSentinel,推荐以「业务上可命名、可审计」为粒度:ViolationFound 可以一条对应一个规则命中,但不要一条对应 diff 的每一个 token。对 LLM 轨迹,可以用单独 LLMInferenceRecorded 事件,引用外部对象存储上的 trace 文件,而不是把整段对话塞进 SQL。
读模型不是缓存的简单别名
很多人把读模型等同于 Redis 缓存。实际上读模型往往是 面向查询优化的结构:宽表、倒排索引、预聚合指标。审核大屏可能要按团队、按仓库、按策略版本统计通过率——这些统计如果每次从事件流现算,会拖垮数据库。投影器的职责就是把昂贵计算搬到 写入路径的异步侧(或准实时侧),让读取路径保持廉价。
并发冲突:乐观锁失败时用户体验怎么做?
教学实现用 ValueError 表示冲突。真实 API 应映射为 409 Conflict,并返回当前版本建议客户端重试或合并。对审核系统,冲突往往来自「两个人同时点批准」或「自动化流水线重复回调」。你要在产品层定义:冲突解决策略 是自动重试、人工介入,还是拒绝第二次提交。
事件版本与「模式演进」
事件 payload 会改字段。建议从第一天引入 schema_version 或在事件名上版本化(ViolationFoundV2)。投影器要能同时消费多版本,或在上游做迁移任务把旧事件转换为新形态。没有这条线,六个月后你会害怕改任何字段。
与上一讲消息总线的关系:EventStore 不是 MQ
事件存储回答「真相是什么」;消息队列回答「怎么通知别人」。生产里常见模式是:append 事件到数据库 同事务写 outbox,再由 worker 投递 MQ。订阅者失败不应回滚事件存储,否则你会把分布式系统写成「同步大事务」。CodeSentinel 的报告与通知应消费 outbox,而不是直接在命令处理器里 requests.post。
代码实战:SQLite Event Store + 命令处理 + 投影读模型
将以下内容保存为 codesentinel_event_lab.py,运行 python codesentinel_event_lab.py(仅标准库 + sqlite3)。示例实现:追加事件、按 stream 读取、投影到内存读模型。
# codesentinel_event_lab.py
# Python 3.10+,标准库即可运行
from __future__ import annotations
import json
import sqlite3
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
# ---------- 事件模型 ----------
@dataclass(frozen=True)
class EventEnvelope:
event_id: str
name: str
occurred_at: str
payload: Dict[str, Any]
class DomainEvent:
name: str
def to_envelope(self) -> EventEnvelope:
raise NotImplementedError
@dataclass(frozen=True)
class PRSubmitted(DomainEvent):
name: str = "PRSubmitted"
review_id: str = ""
repo: str = ""
pr_number: int = 0
actor: str = ""
def to_envelope(self) -> EventEnvelope:
return EventEnvelope(
event_id=str(uuid.uuid4()),
name=self.name,
occurred_at=utc_now_iso(),
payload={"review_id": self.review_id, "repo": self.repo, "pr_number": self.pr_number, "actor": self.actor},
)
@dataclass(frozen=True)
class ReviewStarted(DomainEvent):
name: str = "ReviewStarted"
review_id: str = ""
def to_envelope(self) -> EventEnvelope:
return EventEnvelope(
event_id=str(uuid.uuid4()),
name=self.name,
occurred_at=utc_now_iso(),
payload={"review_id": self.review_id},
)
@dataclass(frozen=True)
class ViolationFound(DomainEvent):
name: str = "ViolationFound"
review_id: str = ""
rule_id: str = ""
severity: str = ""
detail: str = ""
def to_envelope(self) -> EventEnvelope:
return EventEnvelope(
event_id=str(uuid.uuid4()),
name=self.name,
occurred_at=utc_now_iso(),
payload={
"review_id": self.review_id,
"rule_id": self.rule_id,
"severity": self.severity,
"detail": self.detail,
},
)
@dataclass(frozen=True)
class ReviewCompleted(DomainEvent):
name: str = "ReviewCompleted"
review_id: str = ""
outcome: str = ""
def to_envelope(self) -> EventEnvelope:
return EventEnvelope(
event_id=str(uuid.uuid4()),
name=self.name,
occurred_at=utc_now_iso(),
payload={"review_id": self.review_id, "outcome": self.outcome},
)
@dataclass(frozen=True)
class ReviewApproved(DomainEvent):
name: str = "ReviewApproved"
review_id: str = ""
by: str = ""
def to_envelope(self) -> EventEnvelope:
return EventEnvelope(
event_id=str(uuid.uuid4()),
name=self.name,
occurred_at=utc_now_iso(),
payload={"review_id": self.review_id, "by": self.by},
)
# ---------- EventStore ----------
class EventStore:
def __init__(self, path: str = ":memory:") -> None:
self._conn = sqlite3.connect(path, check_same_thread=False)
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stream_id TEXT NOT NULL,
stream_version INTEGER NOT NULL,
event_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
occurred_at TEXT NOT NULL,
payload TEXT NOT NULL,
UNIQUE(stream_id, stream_version)
);
"""
)
self._conn.commit()
def append(self, stream_id: str, expected_version: int, envelopes: List[EventEnvelope]) -> int:
cur = self._conn.cursor()
cur.execute("SELECT COALESCE(MAX(stream_version), 0) FROM events WHERE stream_id = ?", (stream_id,))
(current,) = cur.fetchone()
if current != expected_version:
raise ValueError(f"concurrency conflict: expected {expected_version}, found {current}")
next_v = expected_version
for env in envelopes:
next_v += 1
cur.execute(
"""
INSERT INTO events (stream_id, stream_version, event_id, name, occurred_at, payload)
VALUES (?, ?, ?, ?, ?, ?)
""",
(stream_id, next_v, env.event_id, env.name, env.occurred_at, json.dumps(env.payload, ensure_ascii=False)),
)
self._conn.commit()
return next_v
def load_stream(self, stream_id: str) -> List[EventEnvelope]:
cur = self._conn.cursor()
cur.execute(
"SELECT event_id, name, occurred_at, payload FROM events WHERE stream_id = ? ORDER BY stream_version ASC",
(stream_id,),
)
rows = cur.fetchall()
out: List[EventEnvelope] = []
for eid, name, occurred_at, payload in rows:
out.append(EventEnvelope(event_id=eid, name=name, occurred_at=occurred_at, payload=json.loads(payload)))
return out
# ---------- 聚合(极简状态机) ----------
class ReviewAggregate:
def __init__(self, review_id: str) -> None:
self.review_id = review_id
self.version: int = 0
self.state: str = "Draft"
self.violations: List[Dict[str, Any]] = []
@classmethod
def from_events(cls, review_id: str, events: List[EventEnvelope]) -> ReviewAggregate:
ar = cls(review_id)
for env in events:
ar._apply(env)
ar.version += 1
return ar
def _apply(self, env: EventEnvelope) -> None:
if env.name == "PRSubmitted":
self.state = "Submitted"
elif env.name == "ReviewStarted":
self.state = "InReview"
elif env.name == "ViolationFound":
self.violations.append(env.payload)
elif env.name == "ReviewCompleted":
self.state = "Completed"
elif env.name == "ReviewApproved":
self.state = "Approved"
def submit_pr(self, repo: str, pr_number: int, actor: str) -> List[DomainEvent]:
if self.state != "Draft":
raise ValueError("invalid transition")
return [PRSubmitted(review_id=self.review_id, repo=repo, pr_number=pr_number, actor=actor)]
def start_review(self) -> List[DomainEvent]:
if self.state not in ("Submitted",):
raise ValueError("invalid transition")
return [ReviewStarted(review_id=self.review_id)]
def add_violation(self, rule_id: str, severity: str, detail: str) -> List[DomainEvent]:
if self.state != "InReview":
raise ValueError("invalid transition")
return [ViolationFound(review_id=self.review_id, rule_id=rule_id, severity=severity, detail=detail)]
def complete(self, outcome: str) -> List[DomainEvent]:
if self.state != "InReview":
raise ValueError("invalid transition")
return [ReviewCompleted(review_id=self.review_id, outcome=outcome)]
def approve(self, by: str) -> List[DomainEvent]:
if self.state != "Completed":
raise ValueError("invalid transition")
return [ReviewApproved(review_id=self.review_id, by=by)]
# ---------- 命令处理器 ----------
@dataclass(frozen=True)
class SubmitPRCommand:
review_id: str
repo: str
pr_number: int
actor: str
@dataclass(frozen=True)
class StartReviewCommand:
review_id: str
@dataclass(frozen=True)
class RecordViolationCommand:
review_id: str
rule_id: str
severity: str
detail: str
@dataclass(frozen=True)
class CompleteReviewCommand:
review_id: str
outcome: str
@dataclass(frozen=True)
class ApproveReviewCommand:
review_id: str
by: str
class ReviewCommandHandler:
def __init__(self, store: EventStore) -> None:
self._store = store
def handle_submit(self, cmd: SubmitPRCommand) -> int:
stream = self._store.load_stream(cmd.review_id)
ar = ReviewAggregate.from_events(cmd.review_id, stream)
new_events = ar.submit_pr(cmd.repo, cmd.pr_number, cmd.actor)
envs = [e.to_envelope() for e in new_events]
return self._store.append(cmd.review_id, ar.version, envs)
def handle_start(self, cmd: StartReviewCommand) -> int:
stream = self._store.load_stream(cmd.review_id)
ar = ReviewAggregate.from_events(cmd.review_id, stream)
new_events = ar.start_review()
envs = [e.to_envelope() for e in new_events]
return self._store.append(cmd.review_id, ar.version, envs)
def handle_violation(self, cmd: RecordViolationCommand) -> int:
stream = self._store.load_stream(cmd.review_id)
ar = ReviewAggregate.from_events(cmd.review_id, stream)
new_events = ar.add_violation(cmd.rule_id, cmd.severity, cmd.detail)
envs = [e.to_envelope() for e in new_events]
return self._store.append(cmd.review_id, ar.version, envs)
def handle_complete(self, cmd: CompleteReviewCommand) -> int:
stream = self._store.load_stream(cmd.review_id)
ar = ReviewAggregate.from_events(cmd.review_id, stream)
new_events = ar.complete(cmd.outcome)
envs = [e.to_envelope() for e in new_events]
return self._store.append(cmd.review_id, ar.version, envs)
def handle_approve(self, cmd: ApproveReviewCommand) -> int:
stream = self._store.load_stream(cmd.review_id)
ar = ReviewAggregate.from_events(cmd.review_id, stream)
new_events = ar.approve(cmd.by)
envs = [e.to_envelope() for e in new_events]
return self._store.append(cmd.review_id, ar.version, envs)
# ---------- 读模型投影 ----------
@dataclass
class ReviewDashboardRow:
review_id: str
repo: str = ""
pr_number: int = 0
state: str = "Draft"
violations: int = 0
last_outcome: str = ""
last_event: str = ""
class ReviewProjector:
def __init__(self) -> None:
self._rows: Dict[str, ReviewDashboardRow] = {}
def apply(self, env: EventEnvelope) -> None:
rid = env.payload.get("review_id", "")
row = self._rows.setdefault(rid, ReviewDashboardRow(review_id=rid))
if env.name == "PRSubmitted":
row.repo = env.payload.get("repo", "")
row.pr_number = int(env.payload.get("pr_number", 0))
row.state = "Submitted"
elif env.name == "ReviewStarted":
row.state = "InReview"
elif env.name == "ViolationFound":
row.violations += 1
elif env.name == "ReviewCompleted":
row.state = "Completed"
row.last_outcome = env.payload.get("outcome", "")
elif env.name == "ReviewApproved":
row.state = "Approved"
row.last_event = env.name
def rebuild(self, store: EventStore, stream_id: str) -> None:
for env in store.load_stream(stream_id):
self.apply(env)
def get(self, review_id: str) -> Optional[ReviewDashboardRow]:
return self._rows.get(review_id)
def project_all_streams(store: EventStore) -> ReviewProjector:
cur = store._conn.cursor()
cur.execute("SELECT DISTINCT stream_id FROM events")
ids = [r[0] for r in cur.fetchall()]
pj = ReviewProjector()
for sid in ids:
pj.rebuild(store, sid)
return pj
def main() -> None:
store = EventStore(":memory:")
handler = ReviewCommandHandler(store)
rid = "rev-" + str(uuid.uuid4())[:8]
handler.handle_submit(SubmitPRCommand(rid, "codesentinel/core", 77, "ci-bot"))
handler.handle_start(StartReviewCommand(rid))
handler.handle_violation(RecordViolationCommand(rid, "RULE-SEC-1", "high", "possible secret"))
handler.handle_complete(CompleteReviewCommand(rid, "needs-approval"))
handler.handle_approve(ApproveReviewCommand(rid, "lead-architect"))
pj = project_all_streams(store)
row = pj.get(rid)
print("dashboard:", row)
print("raw stream:")
for e in store.load_stream(rid):
print(e.name, e.payload)
if __name__ == "__main__":
main()
代码导读:三个关键锚点
append(expected_version):体现 乐观并发;ReviewAggregate.from_events:体现 从事件重建状态;ReviewProjector:体现 CQRS 读侧 可独立演进。
代码走读:ReviewAggregate 为什么是「从事件折叠」而不是「直接改字段」
教学实现里,聚合的方法 submit_pr/start_review/... 并不直接修改 self.state,而是返回 待追加事件列表;真正状态变化发生在 _apply 重放时。这样有两个好处:第一,业务规则与「事实生成」绑定,减少「先改内存后写库失败」的不一致窗口;第二,测试可以只断言事件序列是否正确,而不必依赖数据库。真实项目里,常见写法是在内存中先 apply 新事件得到新状态,再一次性 append——本讲为可读性采用返回列表的写法,你可以按团队习惯调整,但要保持 单一真相来源是事件 这一原则。
代码走读:project_all_streams 的局限
教学版为了简单,扫描 DISTINCT stream_id 全表重建投影。生产应使用 增量投影:记录 last_processed_position 或基于 id 游标消费新事件,否则事件量上来后会每次全表扫描。你也可以把投影器实现为消息消费者:outbox/MQ 推送新事件,投影器按序 apply。
PostgreSQL 迁移提示
把 events 表搬到 PostgreSQL 时,建议:payload JSONB、GENERATED 索引、stream_id 哈希分区(超大规模)。投影可写到 review_dashboard 表,用 ON CONFLICT upsert。
负例测试建议:并发 append 应失败
教学代码可以用两个线程同时对同一 review_id 调用 handle_start 之类制造冲突,或单测里伪造 expected_version 不匹配。没有负例测试,乐观并发很容易在重构时悄悄坏掉。
与 RepositoryContext 的衔接:不要把 Git 原始对象塞进事件
事件里更适合存放 commit_sha、PR 编号、变更摘要哈希 等定位信息;大体积对象走对象存储。否则事件表会在几个月内膨胀到备份与恢复都困难。AI 生成代码时常把「方便」放在第一位,评审要重点拦截。
SQLite 教学实现的边界说明
本讲使用 SQLite 是为了让你零依赖跑通闭环。生产环境请关注:写并发、文件锁、备份一致性、与只读副本的配合。到达一定规模后迁移 PostgreSQL 不是「可选优化」,而是风险控制的必要步骤。提前在代码层抽象 EventStore 接口,会让迁移成本低很多,也便于单元测试替换为内存实现,保持快速反馈、可维护性、演进弹性与团队共识,避免各自为政与重复造轮子,降低长期综合成本与风险。
生产环境实战:落地清单与常见坑
Outbox 与至少一次投递
写事件与发消息要嘛 同一事务 outbox,要嘛接受补偿。不要把「写库成功 + 发 MQ」当成原子操作。
Outbox 表建议字段:id、event_id、payload、published_at、retry_count。worker 扫描未发布行,成功后标记时间戳;失败进入退避重试并告警。对 CodeSentinel,outbox 是连接「事实存储」与「外部世界」的桥梁,桥梁不修,系统就会不断在「写入了但没通知」与「通知了但没写入」之间摇摆。
投影重放与双写
规则变更后重放事件时,注意 投影表清空或版本化。可维护 projection_version,重放时切换到新投影器。
GDPR 与「不可删」冲突
事件不可变可能与 被遗忘权 冲突。实践上常用 加密字段 + 丢弃密钥 或 法律保留策略 下的最小化 payload。不要把 diff 全文长期存事件,存引用与哈希更安全。
合规团队常问:「我们能不能删除某人的数据?」技术团队要准备两类答案:一类是 业务允许归档/匿名化 的流程;一类是 法律要求保留审计记录 的例外条款。把这两类答案提前对齐,能避免上线后在数据库里做危险的物理删除。
观测:投影延迟 SLO
监控 now - last_projected_at,报警延迟超阈值。审核大屏若显示旧状态,会直接影响信任。
与 FastAPI 集成
命令 API:POST /commands/submit-pr → handler → append;查询 API:读 review_dashboard 或调用 projector 缓存。不要把投影计算放在每次 GET 上(除非流量极低)。
多租户:stream_id 命名规范
建议 stream_id 包含 tenant_id 与 review_id,例如 t1:rev:abc,避免不同租户意外冲突。投影表同样要带租户维度索引,否则大屏统计会串租。AI 生成代码时常省略租户前缀,评审要把它当作 P0。
与测试金字塔:事件层的单测策略
优先测试:聚合状态迁移、append 并发冲突、投影器对单事件的确定性。集测试:命令 API 到读模型最终一致(可用轮询等待)。E2E:只保留少量黄金路径。事件溯源如果缺少单测,重构成本会非常高,因为状态分散在「聚合 + 投影 + 读库」三处。
AI 治理
要求模型生成「状态变更」时优先输出 事件列表 而非直接 SQL update;评审检查是否携带 expected_version。
备份与归档:事件表是核心资产
对 CodeSentinel,事件存储的备份策略应与财务审计同级:明确 RPO/RTO,定期演练恢复。归档策略可以按 occurred_at 把冷数据迁到廉价存储,但要有 可检索索引 与 法律保留周期 文档。别等到监管检查才意识到「我们删过事件」。
安全:事件表的访问控制
不是所有内部员工都应能 SELECT * FROM events。建议按租户、按仓库做行级权限,敏感 payload 字段加密。否则一次误用 BI 工具就可能把全公司代码审查细节导出。
与 CI/CD:投影器的发布策略
投影逻辑变更时,常见做法是 双写 或 蓝绿投影表:先并行构建新读模型,再切换查询 API。直接在线改投影 SQL 往往是事故源。把投影器当作可版本化服务,而不是随手脚本。
故障注入:验证「投影落后」时 UI 不撒谎
建议在预发环境注入投影延迟,验证 UI 是否显示「数据刷新中」与版本号,而不是错误展示旧结论。审核系统一旦在投影落后时显示错误状态,会直接影响合入决策。
与 CodeSentinel 报告上下文的对齐
ReportContext 消费 ReviewCompleted 等事件生成报告时,应只依赖事件中的 引用与版本,并在报告中打印 event_id 列表作为证据锚点。这样报告不是「凭空生成」,而是 可追溯到事实序列。
本讲小结
mindmap
root((ES+CQRS))
EventStore
append-only
stream_version
Command
Aggregate
乐观并发
Query
Projector
物化视图
生产
Outbox
投影延迟
合规
思考题
- 如果
ViolationFound事件极其频繁,你会如何 压缩 事件流(snapshot / 滚动汇总)而不丢审计? - 投影失败半途中断,如何 断点续投影?
- 你会如何把本模型与 LangChain 工具调用轨迹 关联到同一
review_id?
延伸练习
尝试在 ReviewAggregate 中增加 ReviewRejected 事件与命令,并同步扩展 ReviewProjector 的状态字段;再写一条测试验证从 Completed 不能回到 InReview。这能帮助你体会「状态机 + 事件」如何共同约束非法路径。
对照表:CRUD 方案 vs 事件溯源方案(便于向管理层解释)
- 审计问题:CRUD 往往只能看到最新行;事件溯源保留完整时间线。
- 规则回放:CRUD 需要额外快照;事件溯源天然支持重放(注意副作用隔离)。
- 查询性能:CRUD 读路径简单;事件溯源需要投影维护读模型。
- 实施成本:CRUD 上手快;事件溯源需要更严的建模与工程纪律。
当你需要争取资源做事件存储与投影器时,这张对照表比「我们要用高级架构」更有说服力。
下一讲预告
模块二收束后,我们将进入更贴近 AI 编排与治理落地的章节:把 审核证据链 与 LLM 调用 通过端口隔离,并在门禁中引入 可验证输出(结构化 JSON、规则后验)。你会看到:事件溯源提供的「事实层」如何成为模型行为的约束地基。也建议你回顾模块二的网关、模式与事件三讲,把它们串成一条 从入口到事实存储 的完整链路图,便于向团队汇报架构演进路线。若你只能带走本讲一句话,建议带走:先记事实,再谈解释;先立版本,再谈并发。