×

90% .NET 性能桎梏:读写耦合,用 CQRS 彻底架构解耦

独孤求败 独孤求败 发表于2026-06-16 10:57:16 浏览41 评论0

抢沙发发表评论

读写共用同一套数据模型,是很多 .NET 项目性能问题的起点。写入要的事务、约束,查询要的投影、缓存,硬塞在一个实体里,最终谁都不舒服。

换个思路:把读模型和写模型分开,各走各的路。这就是 CQRS 要解决的核心问题。今天不讲空话,直接看 .NET 里的三种落地方式和取舍。

CQRS 是什么

命令查询职责分离(Command Query Responsibility Segregation,CQRS)是一种将数据存储的读操作和写操作隔离为不同数据模型的设计模式。它要求:

  • 命令(Command):

     执行状态变更(创建、更新、删除),不返回业务数据,通常只返回操作状态(成功/失败)或受影响的标识。
  • 查询(Query):

     只读数据,不改变系统状态,返回专门针对展示优化的数据传输对象。
  • 数据模型可分离:

     读写模型可以共享一个数据源,也可以独立为不同的存储(主库 + 读库副本 + 搜索引擎等)。

CQRS 解决的核心工程问题是 读/写负载不对称带来的耦合膨胀。在传统 CRUD 中,同一个表和实体模型既要服务复杂的写入业务规则,又要支持多样化的查询场景。读优化通常意味着增加索引、预加载和 JOIN,直接拖慢写入响应;写入时的复杂校验、领域约束又让简单查询被迫加载完整聚合。系统越复杂,这种相互制约越明显。CQRS 通过模型分离,让两边的优化不再冲突。

官网资料:微软 Azure 架构中心对 CQRS 有完整说明


https://learn.microsoft.com/zh-cn/azure/architecture/patterns/cqrs
图片

.NET 生态下最常用的 CQRS 辅助库 MediatR

GitHub: 


https://github.com/LuckyPennySoftware/MediatR
图片

怎么引入

先明确一个基本事实:CQRS 是一种架构理念,不一定需要第三方库。但从 .NET 真实项目来看,引入 MediatR 是目前最主流的方式——它本身是中介者模式的实现,天然适合 CQRS 的消息分发场景。下面用 .NET 8+ ASP.NET Core 示范最小化引入步骤。

安装包(在项目目录下执行):

dotnet add package MediatR
图片

启动配置(Program.cs,必须项):

builder.Services.AddMediatR(cfg =>
{
    // 从当前程序集(或指定程序集)扫描并注册所有 IRequest 与 IRequestHandler
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
});

可选增强(强烈推荐):

  • 结合 FluentValidation 做请求验证管线;
  • 注册 IPipelineBehavior 实现日志、性能监控、全局异常处理;
  • 如需读写分库,可单独注入不同的 DbContext(Command 用主库 DbContext,Query 用只读副本或专用查询数据库)。

验证是否接入成功: 在任何 Controller 或服务中注入 IMediator 并发送一个最简单的查询 Handler(例如返回 true 的 Ping),断点确认 Handler 被命中,日志记录正常即可。

快速上手

示例 1:最小可用接入(Ping / Pong)

这个例子不涉及数据库,只演示 MediatR 如何将 Query 从控制器分发到 Handler。

// Query: 定义请求和响应类型
public record PingQuery() : IRequest<string>;
// Handler: 处理逻辑
public class PingQueryHandler : IRequestHandler<PingQuerystring>
{
    public Task<string> Handle(PingQuery request, CancellationToken ct)
    {
        return Task.FromResult("pong");
    }
}
// 控制器调用
[ApiController]
[Route("api/demo")]
public class DemoController(IMediator mediator) : ControllerBase
{
    [HttpGet("ping")]
    public async Task<IActionResult> Ping()
    {
        var result = await mediator.Send(new PingQuery());
        return Ok(result);
    }
}

预期输出: GET /api/demo/ping 返回 "pong"

示例 2:创建订单命令(写场景 + EF Core)

// 定义 Command
public record CreateOrderCommand(Guid CustomerId, List<OrderItemDto> Items) : IRequest<Guid>;
public record OrderItemDto(Guid ProductId, int Quantity, decimal UnitPrice);
// Handler
public class CreateOrderCommandHandler(     AppDbContext dbContext,     ILogger<CreateOrderCommandHandler> logger)     : IRequestHandler<CreateOrderCommand, Guid>
{
    public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = request.CustomerId,
            CreatedAt = DateTime.UtcNow,
            Items = request.Items.Select(i => new OrderItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                UnitPrice = i.UnitPrice,
            }).ToList()
        };
        dbContext.Orders.Add(order);
        await dbContext.SaveChangesAsync(ct);
        logger.LogInformation("Order created: {OrderId}", order.Id);
        return order.Id;
    }
}

示例 3:查询订单详情(读场景 + 只读查询模型)

注意: 以下代码需要引入 using Microsoft.EntityFrameworkCore; 以使用 FirstOrDefaultAsync 等异步扩展方法。读模型 DTO 建议使用 record 以保证不可变性并避免 EF Core 投影时可能遇到的构造函数问题。

// 定义 Query
public record GetOrderDetailQuery(Guid OrderId) : IRequest<OrderDetailDto?>;
// 只读 DTO 使用 record,不可变且支持位置解构
public record OrderDetailDto(     Guid OrderId,     Guid CustomerId,     DateTime CreatedAt,     decimal TotalAmount,     List<OrderItemDetailDto> Items );
public record OrderItemDetailDto(Guid ProductId, int Quantity, decimal UnitPrice);
// Handler 直接使用只读 DbContext(连接读库副本或主库的只读视图)
public class GetOrderDetailQueryHandler(     AppDbContextReadOnly readOnlyContext)     : IRequestHandler<GetOrderDetailQuery, OrderDetailDto?>
{
    public async Task<OrderDetailDto?> Handle(GetOrderDetailQuery request, CancellationToken ct)
    {
        // 注意:需要 using Microsoft.EntityFrameworkCore;
        var result = await readOnlyContext.Orders
            .Where(o => o.Id == request.OrderId)
            .Select(o => new OrderDetailDto(
                o.Id,
                o.CustomerId,
                o.CreatedAt,
                o.Items.Sum(i => i.Quantity * i.UnitPrice),
                o.Items.Select(i => new OrderItemDetailDto(i.ProductId, i.Quantity, i.UnitPrice)).ToList()
            ))
            .FirstOrDefaultAsync(ct);
        return result;
    }
}

读写分离架构示意(逻辑分离 vs 物理分离)

CQRS 不强制要求物理分库。下图中实线表示命令走主库,虚线表示查询走读库副本,但如果你只在同一个数据库里做逻辑分离(比如查询专门走视图或只读 DbContext 配置),可以暂时忽略虚线部分。

图片

示例 4:Pipeline 行为 —— 统一日志与异常处理(进阶)

编译注意事项: 以下代码适配 MediatR 12.x 及以上版本。IPipelineBehavior<,> 的约束要求 TRequest : notnull,并且 Handle 方法的第二个参数类型为 RequestHandlerDelegate<TResponse>

using MediatR;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
public class LoggingBehavior<TRequestTResponse> : IPipelineBehavior<TRequestTResponse>
    where TRequest : notnull   // MediatR 12+ 要求的泛型约束
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger) => _logger = logger;
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        var requestName = typeof(TRequest).Name;
        _logger.LogInformation("Handling {RequestName}", requestName);
        var sw = Stopwatch.StartNew();
        try
        {
            var response = await next();
            sw.Stop();
            _logger.LogInformation("Handled {RequestName} in {ElapsedMs}ms", requestName, sw.ElapsedMilliseconds);
            return response;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error handling {RequestName}", requestName);
            throw;
        }
    }
}
// 在 Program.cs 中注册(按顺序,先注册的先执行)
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));

示例 5:读写分库的配置思路与注意事项

⚠️ 重要警示: 物理读写分离会引入主从复制延迟。订单创建后立即查询读库可能看不到新数据(延迟几十毫秒到数秒)。业务上需要接受最终一致性,或在前端做写后等待重试、后端做“写后读主库”的特殊处理。

// 注册两个 DbContext:一个用于 Command(主库),一个用于 Query(只读副本)
// 注意:两个 DbContext 的名称和实体映射应保持独立,避免 EF Core 模型缓存冲突。
builder.Services.AddDbContext<CommandDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("MasterDb")));
builder.Services.AddDbContext<QueryDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("ReadReplicaDb"),
        sqlOptions => sqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)));
// 在 Handler 中按需注入对应的 DbContext
// 命令 Handler 注入 CommandDbContext,查询 Handler 注入 QueryDbContext

常见报错与修复

报错现象
触发条件
修复方式
Unable to resolve service for type MediatR.IMediator
忘记调用 AddMediatR 或程序集扫描错误
检查 Program.cs 中 RegisterServicesFromAssembly 参数的程序集是否包含 Handler
Request handler not found
Handler 未实现 IRequestHandler<,> 或未注册
确认 Handler 是 public 且类型参数匹配,AddMediatR 扫描到了正确程序集
命令中返回复杂 DTO,违反 CQRS 原则
Command 返回了 IRequest<List<OrderDto>>
改为返回 IRequest<Guid> 或 Unit (void),查询单独使用 Query
System.InvalidOperationException
:验证失败(FluentValidation)
在 Pipeline 中使用了验证器,但未注册 IValidator<T> 或程序集扫描缺失
执行 builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly),并确保每个 Request 有对应的 Validator
EF Core 异步方法 FirstOrDefaultAsync 找不到
缺少 using Microsoft.EntityFrameworkCore;
在代码文件头部添加该 using 指令

适用场景

  • 高并发写入 + 复杂查询:

     典型的电商订单、物联网设备数据上报。写入需要保证强一致性,查询需要灵活的聚合、分页、过滤。读写分离后互不影响。
  • 微服务间的数据展示:

     服务 A 的写入模型与前端需要的展示模型差异巨大,CQRS 允许为每个订阅方单独构建查询模型(类似 API 组合层)。
  • 单体应用中的“慢查询”热点:

     在现有单体里,不需要拆分数据库,仅逻辑分离 CQRS(同一个数据库,但读写使用不同的实体和 DbContext 配置),就能让 DBA 单独为查询模型建索引,避免干扰写入性能。
  • 不适合的场景:

     简单 CRUD 后台管理系统(读写模型几乎没有差异,额外分离只会增加代码量)。强一致性要求极高的实时对战系统(CQRS 的最终一致性设计可能不适用)。

关于最终一致性的提醒: 如果你选择了物理读写分离(主从架构),查询端默认是不保证立即可见的。对于“用户写后立即看自己写入结果”的场景,要么让前端临时从命令响应中取数据展示,要么强制该查询走主库。这不是 CQRS 的缺陷,而是分布式系统的基本权衡。

实战建议

坑位 1:滥用 CQRS,为每个表都写 Command 和 Query 触发条件:团队刚接触 CQRS,把简单的单表增删改查也拆成命令和查询,代码量翻倍。
 错误表现:开发效率降低,维护成本上升,收益为零。
 修复策略:只对“读写逻辑存在明显差异”或“查询性能敏感”的业务使用 CQRS;普通的 CRUD 保留传统 Repository 或直接 DbContext。

坑位 2:命令中返回大量业务数据 触发条件:为了减少一次请求,在 CreateOrderCommand 的响应里返回完整的订单详情。
 错误表现:模糊了读写边界,命令执行后可能会再被查询模型二次修改,导致数据不一致。
 修复策略:命令返回 Guid(资源 ID)或简单的 Unit(类似 void);客户端如需展示新数据,应主动发送对应的 Query。

坑位 3:直接复制写模型作为读模型的 DTO 触发条件:读模型 DTO 与实体属性完全一致,甚至包含了 ICollection 导航属性。
 错误表现:查询时仍然触发懒加载 N+1 问题,或序列化循环引用。
 修复策略:为每个查询场景单独设计扁平化的 DTO,使用 Select 投影或 View 模型,只选择前端需要的字段。

坑位 4(新增):忽视主从复制延迟带来的用户体验问题 触发条件:做了物理读写分离,订单创建后立刻跳转到“我的订单”页面(查询走读库)。
 错误表现:用户看不到刚下的订单,怀疑系统故障,重复提交。
 修复策略:在写操作响应中返回新创建的标识,前端先暂存展示;或对关键查询设置“写后读主库”策略(例如通过请求头路由到主库)。

选型建议

更适合 CQRS 的场景:

  • 读写负载比超过 10:1 或 1:10,有明显的热点查询或复杂写入。
  • 团队规模 ≥ 5 人,有专职后端能承担一定的代码量增长。
  • 预期未来可能拆分为微服务,CQRS 可自然过渡为“命令服务 + 查询服务”。
  • 要求支持多种查询终端(Web、App、报表、第三方 API),每种终端对数据结构要求不同。

不太适合的场景:

  • 内部运营后台、原型验证项目、单人维护的小工具。
  • 写模型和读模型的差异极小(例如大部分接口都是简单的列表+详情)。
  • 团队对中介者模式、异步消息不熟悉,且上线周期极短(两周内交付)。

整体倾向:从局部开始。选一个读写矛盾最突出的模块(如订单查询报表)先试水 CQRS,不要全盘推翻。你可以先从 一阶 CQRS(逻辑分离,同一个数据库) 开始,尝到甜头后再考虑是否引入物理读写分离。上线后度量性能提升和代码复杂度,再决定是否推广。中型以上团队,并发量 > 500 QPS,强烈建议考虑。

总结收尾

读写共用一套模型带来的性能摩擦,往往不是优化 SQL 能根治的。CQRS 把“写”和“读”拆成两条独立车道,让各自按最优方式演进。你不需要立刻改造整个系统——下一个让人头疼的报表或列表页,试着用 Query 对象 + 专门 DTO 实现,对比一下改动前后的代码清晰度和响应时间。

可以直接执行的一步:选一个现有接口,把查询逻辑从 Service 或 Controller 里抽成独立的 Query Handler,只返回纯 DTO。不改数据库,不改表结构,就看你是否敢迈出这一小步。


群贤毕至

访客