
生活化应用的数据架构演进从单表 CRUD 到事件溯源的家务管家设计一、家务管理应用的数据复杂度被低估一个家务管理应用的 MVP 阶段只有两个表tasks待办事项和members家庭成员。原型在 3 人家庭中运行良好。扩展到 5 人家庭后需求变得复杂轮流值日——本周张三负责厨房李四负责客厅下周互换任务补偿——某人因病缺席其他人需要交换任务历史追溯——想看过去一个月谁倒垃圾最多。用 CRUD 模式处理这些需求会导致tasks表的assignee_id字段不断被 UPDATE 覆盖历史分配记录完全丢失轮流排班的逻辑写在应用层一个 Bug 可能导致两周的排班数据全部错误且无法回滚到错误之前的状态。家务管理这类生活化应用的数据特征与电商/金融不同写操作的频率高于读操作勾选完成、重新分配、删除任务数据变更有明确的业务含义分配和完成是两个不同的领域事件且用户偶尔需要查看历史变更记录。二、事件溯源在生活场景中的适用性事件溯源Event Sourcing的核心思想是不存储当前状态而是存储导致状态变化的所有事件。通过重放事件序列重建当前状态graph TB subgraph 事件流 E1[事件1: 创建任务br/倒垃圾 / 张三] E2[事件2: 完成任务br/倒垃圾 / completed] E3[事件3: 重新分配br/倒垃圾 → 李四] E4[事件4: 完成任务br/倒垃圾 / completed] E5[事件5: 补偿事件br/李四请假 / 张三代完成] end subgraph 当前状态投影 P[Projection:br/当前任务列表br/历史完成统计br/成员贡献排行] end subgraph 事件溯源 S[Event Storebr/只追加不修改] end E1 -- S E2 -- S E3 -- S E4 -- S E5 -- S S -- P关键特性事件存储是只追加的append-only相当于天然的审计日志。当前状态通过重放事件计算得出Projection。如果需要回到上周三的状态只需重放到对应时间点的事件即可。三、事件溯源的轻量实现// 家务管理领域的事件溯源实现 // 设计意图事件只追加不修改当前状态通过重放事件投影得出 // 避免引入完整 CQRS 框架用轻量模式覆盖生活场景 // 领域事件——不可变的业务行为记录 type ChoreEvent | { type: ChoreCreated; choreId: string; title: string; assigneeId: string; createdAt: number; // Unix timestamp } | { type: ChoreCompleted; choreId: string; completedBy: string; completedAt: number; } | { type: ChoreReassigned; choreId: string; fromMemberId: string; toMemberId: string; reason: string; reassignedAt: number; } | { type: ChoreCompensated; choreId: string; originalAssigneeId: string; compensatedById: string; reason: string; compensatedAt: number; }; // 当前状态投影——从事件流计算得出的可查询视图 interface ChoreState { chores: Mapstring, { id: string; title: string; assigneeId: string; status: pending | completed; completedBy?: string; completedAt?: number; }; // 历史统计——方便家庭会议时展示 completedCount: Mapstring, number; // memberId → 完成数 } class ChoreEventStore { // 事件持久化——生产环境应使用数据库存储 private events: ChoreEvent[] []; // 内存中的当前投影——每次事件后增量更新 private currentState: ChoreState { chores: new Map(), completedCount: new Map(), }; // 追加事件——只追加不修改已有事件 append(event: ChoreEvent): void { // 业务规则校验——在追加前检查 this.validateEvent(event); this.events.push(event); // 增量更新投影——只处理新增的这一个事件 this.applyEvent(event); } // 从零重建当前状态——启动时或全量恢复时使用 rebuildFromEvents(): ChoreState { const state: ChoreState { chores: new Map(), completedCount: new Map(), }; for (const event of this.events) { switch (event.type) { case ChoreCreated: state.chores.set(event.choreId, { id: event.choreId, title: event.title, assigneeId: event.assigneeId, status: pending, }); break; case ChoreCompleted: const chore state.chores.get(event.choreId); if (chore) { chore.status completed; chore.completedBy event.completedBy; chore.completedAt event.completedAt; // 更新完成统计 const count state.completedCount.get(event.completedBy) || 0; state.completedCount.set(event.completedBy, count 1); } break; case ChoreReassigned: const reassignedChore state.chores.get(event.choreId); if (reassignedChore reassignedChore.status pending) { reassignedChore.assigneeId event.toMemberId; } break; case ChoreCompensated: // 补偿事件——记录代做统计归给代做人 const count2 state.completedCount.get(event.compensatedById) || 0; state.completedCount.set(event.compensatedById, count2 1); break; } } return state; } // 获取某个时间点的历史状态——用于上周的状态查询 getStateAt(timestamp: number): ChoreState { const state: ChoreState { chores: new Map(), completedCount: new Map(), }; for (const event of this.events) { // 只重放到目标时间点之前的事件 const eventTime this.getEventTimestamp(event); if (eventTime timestamp) break; this.applyEventToState(event, state); } return state; } // 获取成员贡献排行——可直接从投影查询 getLeaderboard(): Array{ memberId: string; count: number } { return Array.from(this.currentState.completedCount.entries()) .map(([memberId, count]) ({ memberId, count })) .sort((a, b) b.count - a.count); } private validateEvent(event: ChoreEvent): void { // 校验不能完成不存在的任务 if (event.type ChoreCompleted) { const chore this.currentState.chores.get(event.choreId); if (!chore) { throw new Error(任务 ${event.choreId} 不存在); } if (chore.status completed) { throw new Error(任务 ${event.choreId} 已经完成); } } // 校验事件时间不能早于前一个事件 const eventTime this.getEventTimestamp(event); if (this.events.length 0) { const lastEventTime this.getEventTimestamp( this.events[this.events.length - 1] ); if (eventTime lastEventTime) { throw new Error( 事件时间 ${eventTime} 不能早于上一个事件 ${lastEventTime} ); } } } private applyEvent(event: ChoreEvent): void { this.applyEventToState(event, this.currentState); } private applyEventToState( event: ChoreEvent, state: ChoreState, ): void { // 与 rebuildFromEvents 中的 switch 逻辑相同 // 此处省略重复代码 } private getEventTimestamp(event: ChoreEvent): number { switch (event.type) { case ChoreCreated: return event.createdAt; case ChoreCompleted: return event.completedAt; case ChoreReassigned: return event.reassignedAt; case ChoreCompensated: return event.compensatedAt; } } }三个设计决策增量更新投影——每次追加事件后只处理增量避免每次查询都重放全部事件时间旅行查询——getStateAt(timestamp)支持历史状态回溯实现无需额外字段业务规则校验在追加前执行——validateEvent防止脏事件进入事件流。四、事件溯源的生活场景适用边界不适合事件数量爆炸的场景。如果家庭成员每天完成 20 个任务一年产生 7300 个事件。事件流查询当前待办事项需要处理 7300 条数据的投影——虽然可以通过快照Snapshot优化但引入了额外的复杂度。不适合需要强一致性的场景。事件溯源最终一致性模型下的投影更新存在延迟。在家务场景中这个延迟可接受谁倒了垃圾的统计晚几秒更新不影响使用但金融场景不行。版本升级的兼容性。当ChoreCreated事件增加新字段时如priority旧事件没有这个字段。投影逻辑需要处理新旧事件的共存通常通过默认值填充实现。五、总结事件溯源在生活化应用中的适用模式事件只追加不修改——天然的审计日志和时间旅行能力投影层将事件流转化为可查询视图——并发查询不影响事件写入增量更新投影避免全量重放——查询性能不受事件总量线性影响。适用条件判断写操作频率高于读操作——适合事件溯源需要有历史状态查询能力——事件溯源的核心优势事件总量在可控范围内年增长 10 万条——不需要复杂的快照优化。