Skip to content
bitzorcas
中EN

Guide

Dapper 只读连接池与极限查询优化

实战指南:在 BitzOrcas.Modern 中使用 Dapper 与只读从库连接池,压榨复杂报表、全文大屏与高并发分页查询的极限吞吐。

Last updated

Dapper 只读连接池与极限查询优化 (Dapper Read Models & Connection Pooling)

在 CQRS 架构模式下,读操作(Query)与写操作(Command)面临着截然不同的性能与业务诉求:

  • 写操作:强调业务聚合完整性、严格的一致性事务与领域事件触发(适合 SqlSugar / EF Core);
  • 读操作:强调极致低延迟、大并发承受力、灵活的多表关联投影以及零内存多余分配。

BitzOrcas.Modern 在 src/Framework/BitzOrcas.Infrastructure.Dapper 中为复杂读操作提供了专用的 Dapper 只读连接池通道(Read-Only Connection Pooling)。通过物理隔离写主库与读从库的连接串,让大型复杂看板报表与海量数据分页查询直接跑在接近裸金属速度的 ADO.NET 管线上。


1. 只读连接工厂接口:IDapperConnectionFactory

框架提供了强类型连接工厂抽象,自动根据配置解析读库连接并附加连接生命周期管理:

using System.Data.Common;
using System.Threading;
using System.Threading.Tasks;
namespace BitzOrcas.Infrastructure.Dapper;
/// <summary>
/// Dapper 专用数据库连接工厂端口
/// </summary>
public interface IDapperConnectionFactory
{
/// <summary>
/// 获取打开的只读从库数据库连接(自动从 ReadOnly 连接池分配)
/// </summary>
Task<DbConnection> CreateReadOnlyConnectionAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 获取打开的强一致性主库连接(用于少数写后即读的特殊场景)
/// </summary>
Task<DbConnection> CreateMasterConnectionAsync(CancellationToken cancellationToken = default);
}

2. CQRS 读查询 Handler 实战:复杂分页看板

以下是一个典型的复杂跨表查询 Handler,通过 Dapper 实现高效投影与多租户强制隔离:

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BitzOrcas.Application.Abstractions.Tenancy;
using BitzOrcas.Domain.Abstractions.Queries;
using BitzOrcas.Domain.Results;
using BitzOrcas.Infrastructure.Dapper;
using Dapper;
using Mediator;
namespace BitzOrcas.Modules.Litigation.Application.Queries.Cases;
/// <summary>
/// 案件综合看板分页查询
/// </summary>
public sealed record GetCaseDashboardQuery(
string? Keyword,
string? CaseType,
int PageIndex,
int PageSize
) : IQuery<Result<PagedResult<CaseDashboardRowDto>>>;
/// <summary>
/// Dapper 只读看板查询处理器
/// </summary>
public sealed class GetCaseDashboardQueryHandler(
IDapperConnectionFactory connectionFactory,
ICurrentTenant currentTenant) : IQueryHandler<GetCaseDashboardQuery, Result<PagedResult<CaseDashboardRowDto>>>
{
public async ValueTask<Result<PagedResult<CaseDashboardRowDto>>> Handle(
GetCaseDashboardQuery query,
CancellationToken cancellationToken)
{
var tenant = currentTenant.Tenant;
if (!tenant.IsAvailable)
{
return Result.Failure<PagedResult<CaseDashboardRowDto>>(LitigationErrors.TenantRequired);
}
await using var connection = await connectionFactory.CreateReadOnlyConnectionAsync(cancellationToken);
const string sqlCount = """
SELECT COUNT(1)
FROM LitigationCase c WITH (NOLOCK)
WHERE c.TenantId = @TenantId
AND c.IsDeleted = 0
AND (@Keyword IS NULL OR c.Title LIKE '%' + @Keyword + '%' OR c.CaseNumber LIKE '%' + @Keyword + '%')
AND (@CaseType IS NULL OR c.CaseType = @CaseType);
""";
const string sqlData = """
SELECT
c.Id AS CaseId,
c.CaseNumber,
c.Title,
c.CaseType,
c.ClaimAmount,
c.Status,
c.CreateTime,
l.RealName AS LeadAttorneyName
FROM LitigationCase c WITH (NOLOCK)
LEFT JOIN SysUser l WITH (NOLOCK) ON c.LeadAttorneyId = l.Id
WHERE c.TenantId = @TenantId
AND c.IsDeleted = 0
AND (@Keyword IS NULL OR c.Title LIKE '%' + @Keyword + '%' OR c.CaseNumber LIKE '%' + @Keyword + '%')
AND (@CaseType IS NULL OR c.CaseType = @CaseType)
ORDER BY c.CreateTime DESC
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;
""";
var parameters = new
{
TenantId = tenant.EffectiveTenantId,
Keyword = string.IsNullOrWhiteSpace(query.Keyword) ? null : query.Keyword,
CaseType = string.IsNullOrWhiteSpace(query.CaseType) ? null : query.CaseType,
Offset = (query.PageIndex - 1) * query.PageSize,
PageSize = query.PageSize
};
var totalCount = await connection.ExecuteScalarAsync<int>(new CommandDefinition(sqlCount, parameters, cancellationToken: cancellationToken));
var items = await connection.QueryAsync<CaseDashboardRowDto>(new CommandDefinition(sqlData, parameters, cancellationToken: cancellationToken));
var pagedResult = new PagedResult<CaseDashboardRowDto>(items.AsList(), totalCount, query.PageIndex, query.PageSize);
return Result.Success(pagedResult);
}
}

3. 防注入与多租户强制规则

在使用 Dapper 编写高性能 SQL 时,必须遵守以下安全铁律:

  1. 绝对禁止字符串内联拼接:所有动态查询条件必须通过 @参数名 匿名对象或 DynamicParameters 传入,彻底免疫 SQL 注入攻击;
  2. 多租户必带过滤器:原生 SQL 查询无法自动触发 ORM 的全局拦截器,因此每一条 SQL 语句的 WHERE 条件中必须首先显式包含 TenantId = @TenantId;
  3. 只读 Hint:针对 SQL Server 历史报表聚合,可安全使用 WITH (NOLOCK) 避免与主库的写入排他锁(X 锁)发生锁争抢;
  4. 分页游标规范:统一使用 SQL 标准的 OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY,杜绝使用开销巨大的全表拉取在内存中 Skip/Take。

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%