📜 Event Sourcing

不存当前状态,存所有事件——从"是什么"到"怎么来的"
数据采集:2025-05 · 参考:martinfowler.com, kurrent.io, langchain-ai.github.io

核心思想

📋 Martin Fowler 的定义(2005)

"Capture all changes to an application state as a sequence of events."

传统 CRUD:直接修改状态。用户余额从 100 变成 80 → 数据库 UPDATE balance = 80。
Event Sourcing:记录导致状态变化的事件。→ INSERT EVENT "扣款 20,原因:购买商品"。

用一个例子来理解:

❌ 传统 CRUD

accounts 表:
| id | balance |
| 1 |   80   |

丢失了所有历史信息:为什么是 80?什么时候变的?中间经历了什么?

✅ Event Sourcing

events 流:
#1 AccountCreated {balance: 0}
#2 Deposited {amount: 100}
#3 Withdrawn {amount: 20}
→ 当前余额 = 0 + 100 - 20 = 80

完整历史:每个状态变化都有据可查,可以回溯到任意时间点。

银行从不说"你的余额是 X"——银行说"你发生了这些交易"。余额是交易事件的投影(projection),不是被存储的原始数据。Event Sourcing 就是把这种思维应用到软件系统中。

事件流可视化

🏦 账户事件流模拟器

📊 当前状态投影 (Projection)
余额: ¥0 | 状态: 新创建

Event Sourcing 的三大能力

🔄 Complete Rebuild(完全重建)

丢弃所有当前状态,从头重放所有事件,重建到最新状态。用途:修复 bug 后重算、数据库迁移、新 projection 上线。

🕐 Temporal Query(时间查询)

查询任意时间点的状态。通过从头重放到特定事件号。用途:审计("上周二下午 3 点的余额是多少?")、合规检查、数据分析。

🔙 Event Replay(事件回放)

如果发现某个历史事件是错误的,可以"撤销"它并重放后续事件。或者,如果事件到达顺序错误(异步系统常见),可以重新排序后重放。类似 Git 的 rebase。

LangGraph Checkpoint = Event Sourcing

🤖 LangGraph 的 Checkpoint 机制

LangGraph 是目前最流行的 Agent 框架之一,它的 Checkpoint 机制本质上就是 Event Sourcing:

  • Agent 的每个步骤(LLM 调用、工具调用、状态更新)被记录为一个 checkpoint
  • 当前状态 = 从初始状态重放所有 checkpoint 的结果
  • 支持 time travel:回到任意 checkpoint,从那里重新执行
  • 支持 branching:从某个 checkpoint 分叉出不同的执行路径

这不就是 Martin Fowler 描述的 Temporal Query 和 Event Replay 吗?

LangGraph Checkpoint 代码示例

from langgraph.graph import StateGraph
from langgraph.checkpoint.memory import MemorySaver
from langgraph.checkpoint.postgres import PostgresSaver

# 1. 定义 Agent 状态
class AgentState(TypedDict):
    messages: list[BaseMessage]
    next_action: str
    tool_results: list[dict]

# 2. 创建带 Checkpoint 的图
workflow = StateGraph(AgentState)
workflow.add_node("think", think_node)
workflow.add_node("act", act_node)
workflow.add_node("observe", observe_node)
workflow.add_edge("think", "act")
workflow.add_edge("act", "observe")
workflow.add_edge("observe", "think")

# 3. 使用 PostgresSaver 持久化 checkpoint(Event Sourcing!)
checkpointer = PostgresSaver.from_conn_string("postgresql://...")
app = workflow.compile(checkpointer=checkpointer)

# 4. 运行 Agent——每个步骤都是一个"事件"
config = {"configurable": {"thread_id": "user-123"}}
result = app.invoke({"messages": [HumanMessage("帮我订机票")]}, config)

# 5. Time Travel——回到某个 checkpoint
history = list(app.get_state_history(config))
for state in history:
    print(f"Checkpoint: {state.config['configurable']['checkpoint_id']}")
    print(f"  Next: {state.next}")
    print(f"  Values: {state.values}")

# 6. 从某个 checkpoint 重新执行(Event Replay!)
old_checkpoint = history[3]  # 回到第4个步骤
app.invoke(None, old_checkpoint.config)  # 从这里继续执行

# 7. 分叉执行(branching)
app.invoke(
    {"messages": [HumanMessage("换一个航班")]},
    old_checkpoint.config  # 从同一个 checkpoint 分叉
)

💡 为什么这很重要?

传统 Agent 实现是"一次性"的——执行完就没了,出了问题只能重来。LangGraph 的 Checkpoint 意味着:

  • 调试:回到出错的那一步,检查状态,修改后继续
  • 回滚:Agent 做错了决定?回退到决策点重新来
  • A/B 测试:从同一个 checkpoint 分叉出不同的执行路径,比较结果
  • 审计:完整记录 Agent 的每一步操作

这些能力都是 Event Sourcing 的直接产物——没有事件流,就没有时间旅行。

KurrentDB:Event Sourcing 专用数据库

🗄️ KurrentDB(原 EventStoreDB)关键数据(来源:kurrent.io

  • 50M+ 下载,F500 企业部署
  • 微秒级延迟,99.9% SLA
  • SOC 2 Type II 认证
  • 12+ 年持续开发——不是新项目,是久经考验的
  • 原生不可变事件流(Immutable Event Streams)
  • 内置全局事件排序(Global Event Ordering)
  • 多流原子追加(Multi-Stream Atomic Appends)——替代 Saga 模式
  • 内置发布/订阅(Native Real-Time Subscriptions)——不需要外部消息代理
  • 高级投影引擎(Advanced Projections Engine)——类似存储过程但作用于事件流
  • 乐观并发控制(Optimistic Concurrency Built-In)
  • 客户端库:.NET, Node.js, Python, Java, Go, Rust

为什么不用 PostgreSQL?

维度PostgreSQL + 事件表KurrentDB
事件追加INSERT 到表——需要自己管理顺序原生 append-only 流,全局排序保证
订阅机制Polling 或 LISTEN/NOTIFY(不靠谱)内置 catch-up 和 persistent subscription
投影应用层实现(需要额外框架)内置投影引擎,数据库内计算
并发控制自己实现乐观锁内置版本号检查
多流事务2PC 或 Saga原生多流原子追加
事件保留需要自己管理归档分层归档,透明读穿透
入门成本低(已有 PG)中等(新数据库)

KurrentDB 的核心论点:"Stop fighting your database." 用 CRUD 数据库做 Event Sourcing,就像用锤子拧螺丝——能做,但痛苦且不可靠。

Kafka:事件流平台

Apache Kafka 是 Event Sourcing 的另一个重要工具,但定位与 KurrentDB 不同:

维度KurrentDBApache Kafka
定位Event Sourcing 数据库事件流平台 / 消息代理
事件粒度业务事件(Domain Event)集成事件 / 数据变更事件
数据保留无限期保留可配置保留期(默认7天)
投影内置引擎需要 Kafka Streams / ksqlDB
查询按流和位置直接查找不是数据库,不直接查询
吞吐量万级/秒百万级/秒
典型场景聚合根、CQRS 写端事件总线、数据管道、日志聚合

在实践中,两者经常配合使用:KurrentDB 做 Event Store(存储业务事件),Kafka 做 Event Bus(分发集成事件)。

Event Sourcing 实战

Python 实现(无框架)

import json
from dataclasses import dataclass
from datetime import datetime
from typing import Any

# 1. 定义事件
@dataclass
class Event:
    type: str
    data: dict
    timestamp: str
    version: int

# 2. Event Store(简化版)
class EventStore:
    def __init__(self):
        self.streams: dict[str, list[Event]] = {}
    
    def append(self, stream_id: str, events: list[Event], expected_version: int = -1):
        """追加事件到流,带乐观并发控制"""
        stream = self.streams.setdefault(stream_id, [])
        if stream and stream[-1].version != expected_version:
            raise ConcurrencyError(f"Expected version {expected_version}, got {stream[-1].version}")
        for i, event in enumerate(events):
            event.version = expected_version + 1 + i
            stream.append(event)
    
    def read(self, stream_id: str, from_version: int = 0) -> list[Event]:
        """读取流中的事件"""
        return [e for e in self.streams.get(stream_id, []) if e.version >= from_version]

# 3. 聚合根(Aggregate)
class BankAccount:
    def __init__(self, stream_id: str, store: EventStore):
        self.stream_id = stream_id
        self.store = store
        self.balance = 0
        self.is_frozen = False
        self.version = -1
        self._uncommitted: list[Event] = []
    
    def _apply(self, event: Event):
        """应用事件到状态——这是 projection"""
        if event.type == "AccountCreated":
            self.balance = 0
            self.is_frozen = False
        elif event.type == "Deposited":
            self.balance += event.data["amount"]
        elif event.type == "Withdrawn":
            self.balance -= event.data["amount"]
        elif event.type == "AccountFrozen":
            self.is_frozen = True
        elif event.type == "AccountUnfrozen":
            self.is_frozen = False
        self.version = event.version
    
    def load(self):
        """从事件流重建状态"""
        for event in self.store.read(self.stream_id):
            self._apply(event)
    
    def deposit(self, amount: int):
        """业务命令 → 产生事件"""
        if amount <= 0:
            raise ValueError("Amount must be positive")
        self._uncommitted.append(Event(
            type="Deposited",
            data={"amount": amount},
            timestamp=datetime.now().isoformat(),
            version=0  # will be set on commit
        ))
        # 立即应用到本地状态
        self._apply(self._uncommitted[-1])
    
    def withdraw(self, amount: int):
        if self.is_frozen:
            raise AccountFrozenError()
        if amount > self.balance:
            raise InsufficientFundsError()
        self._uncommitted.append(Event(
            type="Withdrawn",
            data={"amount": amount},
            timestamp=datetime.now().isoformat(),
            version=0
        ))
        self._apply(self._uncommitted[-1])
    
    def commit(self):
        """提交未持久化的事件"""
        if self._uncommitted:
            self.store.append(self.stream_id, self._uncommitted, self.version - len(self._uncommitted))
            self._uncommitted = []

# 4. 使用
store = EventStore()
account = BankAccount("acc-001", store)
account.load()          # 重建状态
account.deposit(100)    # 余额: 100
account.withdraw(20)    # 余额: 80
account.commit()        # 持久化事件

# 5. 时间旅行——回到第2个事件
account2 = BankAccount("acc-001", store)
for event in store.read("acc-001")[:2]:  # 只重放前2个事件
    account2._apply(event)
print(f"余额(事件2时): {account2.balance}")  # 100

🤖 Agent 系统中的 Event Sourcing

Agent 执行历史 = 事件流

每个 Agent 的执行过程天然就是一系列事件:

// Agent 事件流示例
[
  { type: "user_message",  data: { text: "帮我订机票" } },
  { type: "llm_call",      data: { model: "gpt-4o", prompt: "..." } },
  { type: "tool_call",     data: { tool: "search_flights", args: {...} } },
  { type: "tool_result",   data: { result: [...] } },
  { type: "llm_call",      data: { model: "gpt-4o", prompt: "..." } },
  { type: "tool_call",     data: { tool: "book_flight", args: {...} } },
  { type: "tool_result",   data: { result: { booking_id: "..." } } },
  { type: "agent_reply",   data: { text: "已为您预订..." } }
]

OpenClaw Trajectory = Event Sourcing

OpenClaw 的 trajectory 追踪系统实际上就是一个 Event Store:

  • openclaw sessions export-trajectory 导出事件流
  • 每个事件有类型(user_message, llm_call, tool_call, tool_result 等)
  • 可以重放到任意时间点查看 Agent 状态
  • 可以统计工具使用频率、token 消耗等——这些都是 projection

Trajectory 监控面板本质上就是 Event Sourcing 的一个 real-time projection。参见 Agent 架构知识库

Event Sourcing 在 SaaS 中的应用

Event Sourcing 不仅仅是 Agent 的专利,在 SaaS 中同样关键:

  • 审计日志:金融系统必须记录每笔交易——Event Sourcing 是天然选择
  • 数据回溯:"上周的定价是什么?"——Temporal Query 直接回答
  • 多视图:同一事件流可以投影出不同的读模型——CQRS 的写端
  • 调试:用户报告 bug?重放他们的事件流复现问题

常见误区与权衡

❌ "Event Sourcing 适合所有场景"

Event Sourcing 增加了复杂度。如果你的应用不需要审计、不需要时间查询、不需要事件回放——用 CRUD 就好。Event Sourcing 是一种架构决策,不是默认选择。

❌ "事件流可以无限增长"

理论上可以,但实践中需要快照(snapshot)机制:定期保存当前状态的快照,加载时从最近的快照开始重放,而不是从头开始。KurrentDB 的分层归档也解决了这个问题。

❌ "Event Sourcing 就是消息队列"

Event Sourcing 关注的是状态的持久化和重建,消息队列关注的是事件的分发和处理。两者经常配合,但不是同一个东西。Kafka 是消息队列,KurrentDB 是 Event Store。

⚖️ Event Sourcing 的真实代价

收益代价
完整审计轨迹事件流存储空间增长
时间旅行 / 状态回溯重建状态需要重放所有事件(需要快照优化)
多个 Projection(读模型)最终一致性——Projection 可能滞后
事件驱动架构天然解耦事件 schema 演化需要向后兼容
调试和故障分析学习曲线陡峭,团队需要新思维模式

🔗 延伸阅读