先讲个故事。我认识一个做电商的朋友,他们的订单查询页面,一到双十一就卡成PPT,后来查日志发现,一个订单详情要关联十几张表,数据库CPU直接爆掉。后来改用了CQRS,把读和写拆开,查询确实快了不少,但新的问题又来了:那些预计算好的读模型数据,经常要等很久才更新,而且一旦数据量大了,预计算本身就成了新的瓶颈。今天咱们就围绕这个“读模型的数据预计算”聊一聊到底该怎么优化。
一、查询慢,到底慢在哪?
大多数业务系统一开始都是单体应用,写和读共用一套数据库表。订单表、订单明细表、商品表、用户表,一张查询要做一堆join。数据量小的时候无所谓,数据量一上来,光join就能拖垮数据库。
这个时候有人想到了CQRS:把“改数据”和“查数据”分开。改数据走命令模型,负责忠实记录每一次变化;查数据走读模型,专门为查询服务,可以建索引、冗余字段,甚至把多张表的数据合并成一张宽表。这样读模型就能做到“一条SQL搞定”,响应自然快。
这里的“预计算”指的是什么呢?简单说,就是提前把结果算好存起来,而不是等到用户查询那一刻才现算。比如统计某个月的总销售额,与其每次从几千张订单明细里sum,不如每天凌晨更新一个“销售月汇总表”,查询时直接读这张表。
二、CQRS里的读模型和预计算
打个比方。你把CQRS想象成一个餐厅。写模型就是后厨,负责接单、炒菜。读模型就是前厅的菜单牌,上面写着“今日推荐”。如果没有预计算,就相当于每次客人问“今天有什么菜”,服务员还得跑进后厨问一圈才能答。预计算就是提前把菜单牌写好,客人随时看,秒回。
但问题来了:菜单牌写的不对怎么办?后厨菜变了,菜单牌没改,客人就会抱怨。这就是CQRS里说的“最终一致性”——读模型的数据可能暂时不是最新,但过一会儿会追平。而预计算逻辑的优化,主要就是解决两个问题:一是怎么算得快,二是怎么保持数据不会太旧。
三、预计算逻辑的几个“坑”
先说几个我见过的实际坑,很多人都踩过。
第一个坑:在写请求里同步做预计算。假设每下一个新订单,就直接在事务里更新“订单合计表”。订单量小的时候没事,订单量一大,写请求就得等预计算完成才能返回,写链路变长,下单体验变差。
第二个坑:查询时还是现算。有的系统表面上建了“汇总表”,但查询接口里没走汇总表,还是去扫原始订单表。这就等于没预计算,白搭。
第三个坑:更新时机想当然。比如以为每天凌晨跑批就行,但业务是24小时在线,半夜的订单第二天才能看到汇总,老板一看数据不对,立刻炸毛。
第四个坑:全量重算。有时候为了简单,直接truncate汇总表再重新统计所有订单。数据量一大,这个任务跑一两个小时,期间读模型数据还是空的,查询更慢。
四、优化实践:从“伪预计算”到“真预计算”
说了这么多坑,那正确的姿势是什么?我的经验是四个字:按需预计算。别想着把所有数据都算好,只算那些真正高频使用的统计值。然后,用事件驱动来更新预计算结果,别再用定时任务硬扫。
4.1 同步触发预计算(简单但不完美)
先从最基础的开始。我们有一个订单服务,保存订单的同时,更新一个“每日销售统计表”。示例统一使用 C# 12 + .NET 8。
public class OrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db)
{
_db = db;
}
/// <summary>
/// 创建订单,同时更新每日统计(同步方式)
/// </summary>
public async Task CreateOrderAsync(Order order, CancellationToken ct)
{
// 先保存订单
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
// 再更新统计表 —— 这就是预计算
var today = DateTime.Today;
var stat = await _db.DailyStatistics
.FirstOrDefaultAsync(x => x.Date == today, ct);
if (stat is null)
{
stat = new DailyStatistic { Date = today, OrderCount = 0, TotalAmount = 0m };
_db.DailyStatistics.Add(stat);
}
// 只更新当天的订单数量和金额,这就是增量计算
stat.OrderCount += 1;
stat.TotalAmount += order.Amount;
await _db.SaveChangesAsync(ct);
}
}
这段代码看起来没问题吧?但它就是最典型的“同步预计算”。订单多的时候,数据库每次要加锁更新同一天的行,并发一上来,锁等待特别严重。所以这种方式适合写入量很小的后台系统,不适合真实电商大流量场景。
4.2 异步事件驱动预计算(推荐)
更合理的做法是把预计算和写操作隔离开。写模型接受命令,保存订单,然后发布一个“订单已创建”事件。读模型订阅这个事件,异步去更新统计表。这样写操作不用等预计算完成,响应自然更快。
我们用RabbitMQ做消息队列,示例代码如下。
// 命令侧:订单创建后,发布事件
public class OrderCommandHandler
{
private readonly AppDbContext _db;
private readonly IMessageBus _bus;
public OrderCommandHandler(AppDbContext db, IMessageBus bus)
{
_db = db;
_bus = bus;
}
public async Task Handle(CreateOrderCommand command, CancellationToken ct)
{
// 1. 保存订单(命令模型只碰自己的写库)
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = command.CustomerId,
Amount = command.Amount,
CreatedAt = DateTime.UtcNow
};
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
// 2. 发布事件,通知读模型去预计算
await _bus.PublishAsync(new OrderCreatedEvent
{
OrderId = order.Id,
Amount = order.Amount,
CreatedDate = order.CreatedAt.Date
});
}
}
// 读模型侧:订阅事件,更新统计表
public class DailyStatisticProjector : IEventHandler<OrderCreatedEvent>
{
private readonly ReadDbContext _readDb;
public DailyStatisticProjector(ReadDbContext readDb)
{
_readDb = readDb;
}
public async Task Handle(OrderCreatedEvent message, CancellationToken ct)
{
var today = message.CreatedDate;
// 读模型库里的统计表
var stat = await _readDb.DailyStatistics
.FirstOrDefaultAsync(x => x.Date == today, ct);
if (stat is null)
{
stat = new DailyStatistic { Date = today, OrderCount = 0, TotalAmount = 0m };
_readDb.DailyStatistics.Add(stat);
}
// 增量更新:只加这一单的金额和数量
stat.OrderCount += 1;
stat.TotalAmount += message.Amount;
await _readDb.SaveChangesAsync(ct);
}
}
你看,左边写订单,右边更新统计,两者不在同一个事务里,靠消息传递。这样做有几点好处:写请求只需要保证自己那一步成功,统计稍有延迟可以接受;万一统计失败,还可以从消息队列里重新消费,不会影响用户下单。
4.3 增量计算,别动不动全量算
上面的代码里面其实已经体现了增量思想:我们读到了当天的统计行,然后只加一单,并没有去刷新整张表。但很多人改造的时候,还是会习惯写成“删掉今天的,再重新汇总今天所有订单”。这就是所谓的全量重算,数据量大时会非常慢。
那什么时候会用到全量重算呢?一般是历史数据修复,或者发现投影出错的时候。正常流程里我们应该使用“幂等”的增量更新方式。什么意思?就是同样的消息消费两次,结果不会错。还是上面的例子,如果消息重复投递,OrderCount会增加两次。解决办法是在事件里带上一个唯一的消息ID,在投影的时候去重。下面是添加了幂等处理的示例:
public class DailyStatisticProjector : IEventHandler<OrderCreatedEvent>
{
private readonly ReadDbContext _readDb;
public async Task Handle(OrderCreatedEvent message, CancellationToken ct)
{
// 同一个事件消费过就不再重复处理
var processed = await _readDb.ProcessedMessages
.AnyAsync(x => x.MessageId == message.MessageId, ct);
if (processed) return;
var today = message.CreatedDate;
var stat = await _readDb.DailyStatistics
.FirstOrDefaultAsync(x => x.Date == today, ct);
if (stat is null)
{
stat = new DailyStatistic { Date = today, OrderCount = 0, TotalAmount = 0m };
_readDb.DailyStatistics.Add(stat);
}
stat.OrderCount += 1;
stat.TotalAmount += message.Amount;
// 记录这条消息已经处理过了
_readDb.ProcessedMessages.Add(new ProcessedMessage { MessageId = message.MessageId });
await _readDb.SaveChangesAsync(ct);
}
}
五、再配个缓存,锦上添花
预计算好的数据,虽然比原始表快,但如果每天都很大,比如统计粒度到了“每小时成交量”,那读模型表本身也会膨胀。这时候可以再加一层缓存,把最常查的预计算结果放到Redis里,设置过期时间,比如30秒。查询时先看缓存,缓存没有再看数据库。
我们用一个简单的接口示例:
public class OrderQueryService
{
private readonly IRedisClient _redis;
private readonly ReadDbContext _readDb;
public OrderQueryService(IRedisClient redis, ReadDbContext readDb)
{
_redis = redis;
_readDb = readDb;
}
/// <summary>
/// 获取某天的销售额,优先走Redis缓存
/// </summary>
public async Task<decimal> GetDailySalesAsync(DateTime date, CancellationToken ct)
{
var cacheKey = $"sales:daily:{date:yyyyMMdd}";
// 尝试从缓存读取
var cached = await _redis.GetAsync<decimal?>(cacheKey);
if (cached.HasValue) return cached.Value;
// 缓存没命中,读预计算好的读模型表
var total = await _readDb.DailyStatistics
.Where(x => x.Date == date)
.Select(x => x.TotalAmount)
.FirstOrDefaultAsync(ct);
// 写回缓存,30秒过期
await _redis.SetAsync(cacheKey, total, TimeSpan.FromSeconds(30));
return total;
}
}
这样读多写少的场景,Redis大大降低了数据库的压力。注意缓存过期时间不要太长,否则预计算结果更新了,缓存还是旧的,体验会很差。30秒对于大多数报表型数据足够了。
六、聊聊优缺点和适用场景
聊了这么多,那用这套CQRS预计算方案到底图什么?肯定先看优点。
优点很明显:查询性能高,因为读模型是专门为查询设计的宽表,加上预计算和缓存,首屏响应轻轻松松。其次,读写各自独立扩展,比如大促时给读模型多挂几个只读副本,即使写库扛不住,读库也能撑。再次,业务逻辑更清晰,写模型只管命令,读模型只管投影,代码好维护许多。
但缺点也躲不掉。第一个就是最终一致性,刚下的订单不会立刻出现在统计里,可能有秒级甚至分钟级延迟。对延迟敏感的强一致场景,这个方案不适合。第二是系统复杂度上升,消息队列、事件总线、多个数据库,这些都是要花钱花精力运维的。第三是数据修正麻烦,一旦预计算逻辑有bug,你不仅仅要改代码,还要重建历史投影数据,这个成本可能非常高。
所以说,CQRS预计算适合什么样的应用呢?主要是写少读多、查询复杂、对一致性要求不那么苛刻的系统,比如报表统计、用户中心、订单列表这类。如果你是做一个简单的博客留言板,那完全没必要,直接用关系数据库查就好了。
七、这些坑你千万注意
我最后再强调几个注意事项。
第一,别过度设计。预计算不是银弹。如果你的查询只有几千条数据,直接带索引查就很快,非要去搞一套消息队列,反而是给自己找麻烦。
第二,要关注消息的可靠性和顺序。异步预计算依赖消息队列,如果消息丢了,数据就对不上。生产环境必须开启持久化,并且要监控消费积压。有些业务还要求顺序处理,比如同一个用户的订单必须按时间顺序投影,这时可以按用户ID路由到同一个队列分区。
第三,预计算结果要有版本号或时间戳。当投影逻辑升级的时候,老版本的数据要能识别并触发重算。我在项目里习惯给统计表加一个 SchemaVersion 字段,每次逻辑改变就升级版本,然后跑一个一次性迁移任务。
第四,别忘了补偿机制。如果消息消费失败多次,可以放进死信队列,并配套一个定时巡检任务,找出不一致的地方然后重新计算。
八、最后说两句
读模型数据预计算这件事,说难不难,说简单也不简单。核心就是两条:第一,只算高频用的数据;第二,用事件驱动去更新它,别用定时全量跑。在我的经验里,绝大多数查询性能问题都是因为“把所有计算留到了查询时”,而CQRS给了我们一个很好的思路——把计算提前到写入时。后面你再碰到查询慢,不妨先想一想:这些结果能不能提前算好?用什么方式更新合适?希望这篇文章能帮你理清思路。
评论
围绕“CQRS架构下读模型数据预计算逻辑的优化实践”参与讨论