Event Sourcing在高并发写入时更轻量,因其核心是追加写、无锁、顺序I/O;关键在于状态变更能否分解为明确、有序、幂等的事件,而非读写比例。

Event Sourcing 为什么在高并发写入时反而更轻量?
因为 EventStore 的核心操作是追加写(append-only),不涉及行级锁、乐观锁重试或复杂事务协调。每次状态变更只生成一条不可变的 Event,写入本质是顺序 I/O,天然适合 SSD 和日志结构存储。
常见误区是认为“写多读少就该用 Event Sourcing”,其实关键不在读写比例,而在「状态变更是否可分解为明确、有序、幂等的事件」。比如订单从 Created → Paid → Shipped,每步都对应一个业务事实,而非单纯字段更新。
- 避免把
UpdateUserCommand直接映射成UserUpdatedEvent——它掩盖了“邮箱改了”还是“头像换了”,应拆成EmailChangedEvent或AvatarUploadedEvent - 高并发下,多个命令可能同时触发同聚合根的状态变更,必须靠
AggregateVersion+ 乐观并发控制(如 SQL Server 的rowversion或 Cosmos DB 的_etag)防止事件覆盖 - 不要在事件处理器里做耗时同步调用(如 HTTP 请求),否则会阻塞事件流;用
Outbox Pattern+ 轮询或消息队列解耦
CQRS 的读模型如何扛住每秒数千查询?
Query Model 不是数据库视图,而是为特定查询场景预构建的扁平化表(如 OrderSummaryView),字段命名、索引、分区策略全部按查询条件定制。它的性能不取决于主库能力,而取决于「能否让每个查询落在单表、单索引、单分片上」。
典型错误是把读模型建模成和写模型一样的嵌套结构(比如把 Order 和 20 个 OrderItem 一起查),这会导致 N+1 查询或大宽表 JOIN,一压就垮。
- 读模型更新延迟接受范围需明确定义(如
≤ 500ms),并用监控指标(EventProcessingLagMs)持续追踪 - 对高频低变化数据(如商品类目),可用
Redis Hash存整行,key 设为category:{id},避免穿透 DB - 分页场景慎用
OFFSET/LIMIT,改用基于游标的分页(如WHERE id > @lastId ORDER BY id LIMIT 100),否则深分页会拖慢整个事件投射进程
Event Sourcing + CQRS 组合在并发冲突时的真实表现
冲突不是发生在读侧,而是在写侧聚合根处理命令时:两个线程几乎同时加载同一版本的聚合,各自生成事件,但只有第一个能成功提交。第二个会抛出 OptimisticConcurrencyException,此时不能静默重试——业务语义可能已失效(比如库存已售罄,重试只会重复扣减)。
正确做法是让应用层感知失败,并决定是放弃、合并、还是降级(如转入人工审核队列)。这也是为什么 CQRS 架构中,Command Handler 必须返回明确结果类型(如 Result<OrderPlaced>),而不是 void。
- 不要在
catch (OptimisticConcurrencyException)里无脑Task.Delay(10).Wait()后重试——这会放大热点聚合的排队延迟 - 对强一致性要求极高的操作(如支付扣款),可在事件发布前加分布式锁(如
Redlock),但要接受锁服务引入的额外故障点 - 聚合根 ID 命名要有业务含义且足够离散(如
order-{userId}-{timestamp}),避免所有订单事件集中写入同一物理分区(尤其在 Cosmos DB / DynamoDB 中)
public class OrderAggregate : AggregateRoot
{
private int _version;
public override int Version => _version;
public OrderAggregate(Guid id) : base(id) { }
public void PlaceOrder(PlaceOrderCommand cmd)
{
// 冲突检测由基类在 SaveChangesAsync 中触发
ApplyChange(new OrderPlacedEvent(Id, cmd.Items, DateTime.UtcNow));
_version++;
}
}
真正难的不是代码怎么写,是判断哪些业务状态值得溯源、哪些查询值得单独建模、以及当事件延迟堆积时,前端要不要展示“数据可能未同步”的提示——这些决策无法靠框架自动完成。










