1. 为什么 MultiMapper 值得单独拎出来讲
1.1 从一次真实的性能翻车说起
前两年接手一个订单查询模块,需求很朴素:根据订单号拉出订单主表、客户信息、收货地址、商品明细。我一开始用的是 EF Core,写起来确实舒服,Include一把梭,代码也干净。上线之后单条查询没问题,但压测一跑,QPS 上到 800 左右,数据库连接池就开始报警,接口 P99 直接飙到 1.2 秒。后来把这条链路单独拎出来用 Dapper 重写,SQL 还是那条 SQL,只是把多结果集映射换成了 Dapper 的 MultiMapper,P99 直接掉到 180 毫秒左右,连接池也稳了。
那次之后我就养成了一个习惯:凡是读多写少、关联层级固定、对延迟敏感的查询场景,优先考虑 Dapper 的 MultiMapping,而不是 ORM 的导航属性。这不是说 EF Core 不好,而是两者的定位不一样——ORM 解决的是"开发效率",Dapper 解决的是"执行效率",而 MultiMapper 恰好是 Dapper 里最能体现这个定位的功能点。
1.2 MultiMapper 到底解决什么问题
一句话概括:它能把一条 SQL 返回的多行结果,按照你指定的规则,折叠成对象树。
举个最典型的例子。假设你要查一个订单,订单下面有多个商品明细。传统做法有两种:
- 第一种:先查订单主表,再根据订单 ID 查明细表,两次数据库往返。这叫 N+1 问题的简化版,订单多了就是灾难。
- 第二种:用
JOIN一次性查出来,但返回的是扁平的笛卡尔积行,订单信息会重复出现在每一行里,你得在 C# 代码里手动去重、组装。
MultiMapper 就是为第二种场景准备的。你写一条带JOIN的 SQL,Dapper 在读取每一行的时候,调用你提供的映射函数,把当前行拆成"主对象 + 子对象",然后自动帮你把重复的主对象合并、把子对象挂到对应的主对象下面。整个过程只走一次数据库,内存里做折叠,代码量比手写组装少一大半。
1.3 适合谁看这篇内容
如果你符合下面任意一条,这篇内容对你会比较有用:
- 已经在用 Dapper,但只会
Query<T>单表映射,遇到关联查询就退回手写循环; - 用 EF Core 遇到性能瓶颈,想局部替换成 Dapper 但不知道怎么处理一对多;
- 面试被问到"Dapper 怎么处理多表关联",只能答出"用 QueryMultiple";
- 想搞清楚
splitOn、multiMap委托、QueryMultiple这三者到底有什么区别和适用场景。
我会从原理讲到实操,再讲到踩过的坑,尽量把每个参数为什么这么设讲清楚。代码都是可以直接抄的,但更重要的是理解背后的取舍逻辑。
2. 先把三个容易混淆的概念掰开
2.1 单映射、MultiMapping、QueryMultiple 的区别
很多人第一次接触 Dapper 多表查询,会被这三个词绕晕。我用一张表把它们讲清楚。
| 概念 | 方法签名核心 | 数据库往返次数 | 适用场景 | 返回结构 |
|---|---|---|---|---|
| 单映射 | Query<T> | 1 次 | 单表或结果集列名与属性一一对应 | 扁平列表 |
| MultiMapping | Query<T1,T2,TReturn> | 1 次 | 一条 SQL 带 JOIN,一对多/一对一 | 对象树 |
| QueryMultiple | QueryMultiple | 1 次 | 多条 SQL 用分号隔开,多个独立结果集 | 多个结果集,手动组装 |
关键区别在于:MultiMapping 处理的是"一条 SQL 的多行折叠",QueryMultiple 处理的是"多条 SQL 的多个结果集"。
举个例子,订单 + 明细,用 MultiMapping 是:
SELECT o.Id, o.OrderNo, o.Amount, d.Id, d.ProductName, d.Qty FROM Orders o INNER JOIN OrderDetails d ON d.OrderId = o.Id WHERE o.Id = @id用 QueryMultiple 是:
SELECT Id, OrderNo, Amount FROM Orders WHERE Id = @id; SELECT Id, ProductName, Qty FROM OrderDetails WHERE OrderId = @id;前者一次 JOIN 拿全部,后者两次独立查询。前者的问题是主表字段会重复传输(订单信息在每一行明细里都出现一次),后者的问题是两次往返但数据传输更紧凑。数据量小的时候两者差别不大,数据量大的时候要具体测。
2.2 为什么 MultiMapping 需要 splitOn
这是新手最容易卡住的地方。Dapper 读到的是一行扁平的列,它怎么知道哪几列属于主对象、哪几列属于子对象?
答案就是splitOn参数。它告诉 Dapper:从哪一列开始,后面的列属于下一个对象。
var sql = @" SELECT o.Id, o.OrderNo, o.Amount, d.Id AS DetailId, d.ProductName, d.Qty FROM Orders o INNER JOIN OrderDetails d ON d.OrderId = o.Id WHERE o.Id = @id"; var orderDict = new Dictionary<int, Order>(); var result = connection.Query<Order, OrderDetail, Order>( sql, (order, detail) => { if (!orderDict.TryGetValue(order.Id, out var existing)) { existing = order; existing.Details = new List<OrderDetail>(); orderDict[order.Id] = existing; } if (detail != null) existing.Details.Add(detail); return existing; }, splitOn: "DetailId", param: new { id = 1 } );注意这里有个关键坑:splitOn默认值是"Id"。如果你的子表主键也叫Id,而主表主键也叫Id,Dapper 会从第一个Id就开始切分,导致主对象只映射到Id这一列,后面的列全被当成子对象。所以子表的列一定要起别名,比如d.Id AS DetailId,然后splitOn: "DetailId"。
2.3 委托返回值和字典去重的配合逻辑
上面代码里那个orderDict不是可有可无的。Dapper 的 MultiMapping 委托是每一行都会调用一次,它不会自动帮你合并主对象。如果你直接return order,那么返回的列表里会有 N 条重复的订单(N 等于明细行数)。
所以标准做法是:在委托外部维护一个字典,用主键做 key。第一次遇到某个主键时创建对象并放进字典,后续遇到相同主键时直接从字典取,只往里面追加子对象。最后返回的result里其实是重复的引用,你需要再Distinct()或者直接取字典的Values。
var orders = orderDict.Values.ToList();这一步很多人会漏,导致拿到的列表长度不对。我见过有同事排查了半天,以为是 Dapper 的 bug,其实就是没去重。
3. 一对多、多对一、多对多的实操写法
3.1 一对多:订单带明细的标准模板
这是最常用的场景,我把完整可运行的模板贴出来,包括实体定义、SQL、映射代码。
public class Order { public int Id { get; set; } public string OrderNo { get; set; } public decimal Amount { get; set; } public List<OrderDetail> Details { get; set; } = new(); } public class OrderDetail { public int Id { get; set; } public int OrderId { get; set; } public string ProductName { get; set; } public int Qty { get; set; } }public List<Order> GetOrdersWithDetails(IEnumerable<int> orderIds) { var sql = @" SELECT o.Id, o.OrderNo, o.Amount, d.Id AS DetailId, d.OrderId, d.ProductName, d.Qty FROM Orders o LEFT JOIN OrderDetails d ON d.OrderId = o.Id WHERE o.Id IN @orderIds"; var dict = new Dictionary<int, Order>(); var rows = connection.Query<Order, OrderDetail, Order>( sql, (order, detail) => { if (!dict.TryGetValue(order.Id, out var entry)) { entry = order; entry.Details = new List<OrderDetail>(); dict[order.Id] = entry; } if (detail != null && detail.Id != 0) entry.Details.Add(detail); return entry; }, splitOn: "DetailId", param: new { orderIds } ); return dict.Values.ToList(); }几个细节值得说:
- 用
LEFT JOIN而不是INNER JOIN,这样没有明细的订单也能查出来。但要注意,没有明细时detail会是null或者一个所有属性都是默认值的对象,所以判断条件要写detail != null && detail.Id != 0。 IN @orderIds是 Dapper 的批量参数语法,会自动展开成IN (@orderIds1, @orderIds2, ...),比手动拼字符串安全得多。- 字典的 key 用
int主键,如果主键是Guid或string,字典类型相应改掉即可。
3.2 多对一:明细带出所属订单
反过来,如果你是从明细列表出发,想带出每条明细所属的订单信息,映射方向就反过来了。
var sql = @" SELECT d.Id AS DetailId, d.ProductName, d.Qty, o.Id, o.OrderNo, o.Amount FROM OrderDetails d INNER JOIN Orders o ON o.Id = d.OrderId WHERE d.OrderId = @orderId"; var details = connection.Query<OrderDetail, Order, OrderDetail>( sql, (detail, order) => { detail.Order = order; return detail; }, splitOn: "Id", param: new { orderId } ).ToList();这里splitOn: "Id"用的是默认值,因为主对象是OrderDetail,它的主键列被别名成了DetailId,而Order的主键列是Id,所以从Id开始切分正好。splitOn 指的是"第二个对象从哪一列开始",不是第一个对象的主键列,这个概念一定要扭过来。
3.3 多对多:用中间表拆成两个一对多
多对多没有直接支持,标准做法是把它拆成两个一对多。比如学生和课程,中间表是选课记录。
var sql = @" SELECT s.Id, s.Name, c.Id AS CourseId, c.CourseName, e.Score FROM Students s INNER JOIN Enrollments e ON e.StudentId = s.Id INNER JOIN Courses c ON c.Id = e.CourseId WHERE s.Id IN @studentIds"; var dict = new Dictionary<int, Student>(); connection.Query<Student, Course, Student>( sql, (student, course) => { if (!dict.TryGetValue(student.Id, out var entry)) { entry = student; entry.Courses = new List<Course>(); dict[student.Id] = entry; } if (course != null && course.Id != 0) { course.Score = /* 从行里取 Score,需要额外处理 */; entry.Courses.Add(course); } return entry; }, splitOn: "CourseId", param: new { studentIds } );这里有个麻烦:Score是中间表的字段,既不属于Student也不属于Course。Dapper 的 MultiMapping 只能映射到两个对象上,中间表字段没地方放。解决办法有两个:
- 把
Score塞进Course对象里,作为一个"选课时的附加属性",虽然语义上不太干净但最省事; - 用
QueryMultiple或者干脆查三次,中间表单独查。
我一般选第一种,因为多对多场景下中间表字段通常不多,塞进子对象里可以接受。如果中间表字段很多,那说明这个关系本身可能就该建模成一个独立实体,而不是简单的多对多。
4. 性能优化的几个关键点
4.1 笛卡尔积膨胀问题
MultiMapping 最大的性能隐患是笛卡尔积膨胀。假设一个订单有 100 条明细,你 JOIN 了订单表、明细表、还有物流表(假设每个订单 5 条物流记录),那么结果集行数就是 100 × 5 = 500 行。订单信息在这 500 行里重复了 500 次,网络传输和内存都浪费了。
我实测过一个极端案例:主表 1 条记录,两个子表各 200 条,JOIN 之后 40000 行,查询耗时从 30 毫秒涨到 2.3 秒。这不是 Dapper 的问题,是 SQL 层面的问题。
解决办法:
- 如果两个子表之间没有关联,用
QueryMultiple分两次查,各自 200 行,总共 400 行,比 40000 行快得多; - 如果必须 JOIN,考虑在 SQL 层面用
JSON聚合(MySQL 5.7+、PostgreSQL 都支持),把子表聚合成 JSON 列,Dapper 侧用JsonSerializer反序列化。
4.2 字典去重的内存开销
字典去重虽然必要,但也有成本。如果结果集有 10 万行,字典里会有 10 万个 entry(假设主键都不重复),内存占用不小。这时候可以考虑:
- 用
QueryMultiple替代,避免重复数据; - 如果一定要用 MultiMapping,把字典的初始容量设大一点,减少扩容次数:
var dict = new Dictionary<int, Order>(capacity: 10000);这个优化看起来微不足道,但在大数据量下能省下不少 GC 时间。我实测过 10 万行场景,预设容量能减少约 15% 的映射耗时。
4.3 异步与流式读取
Dapper 的QueryAsync和Query在 MultiMapping 场景下行为一致,但异步版本在高并发下优势明显。另外,如果结果集很大,可以用buffered: false开启流式读取,边读边处理,减少内存峰值。
var rows = await connection.QueryAsync<Order, OrderDetail, Order>( sql, (order, detail) => { /* ... */ }, splitOn: "DetailId", param: new { orderIds }, buffered: false );注意buffered: false时,连接在枚举完成前不能关闭,否则会抛异常。这个坑我踩过,当时在using块里返回了未枚举的IEnumerable,出了作用域连接就关了,一枚举就报错。
5. 常见问题与排查速查表
5.1 映射结果为 null 或字段丢失
这是最高频的问题,我整理成表格方便对照。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 主对象只有 Id 有值 | splitOn 默认值 "Id" 提前切分 | 打印 SQL 列顺序 | 子表主键起别名,splitOn 指向别名 |
| 子对象为 null | JOIN 无匹配行 | 检查 JOIN 条件 | 用 LEFT JOIN 并判空 |
| 列表长度等于行数 | 没做字典去重 | 看返回列表是否有重复主键 | 加字典去重 |
| 某列始终为默认值 | 列名与属性名不匹配 | 对比 SQL 列名和属性名 | 起别名或配置映射 |
| 多子表时数据错乱 | 两个子表 JOIN 产生笛卡尔积 | 看行数是否异常膨胀 | 改用 QueryMultiple |
5.2 列名大小写与下划线问题
Dapper 默认的列名匹配是不区分大小写的,但不支持下划线转驼峰。也就是说数据库列order_no不会自动映射到属性OrderNo。解决办法:
- SQL 里起别名:
o.order_no AS OrderNo; - 或者全局配置
Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true,这个开关在启动时设一次即可。
我一般选第一种,因为显式别名更可控,也方便排查。全局开关虽然省事,但一旦某个查询的列名恰好符合下划线规则,可能会产生意料之外的映射。
5.3 值类型与可空类型的坑
如果子表字段在数据库里是NULL,而 C# 属性是int这种值类型,Dapper 会抛异常。要么把属性改成int?,要么在 SQL 里用ISNULL给默认值。这个坑在LEFT JOIN场景下特别常见,因为左连接必然产生 NULL。
// 错误:Qty 是 int,但 LEFT JOIN 可能返回 NULL public int Qty { get; set; } // 正确:改成可空 public int? Qty { get; set; }5.4 事务中的 MultiMapping
在事务里用 MultiMapping 和普通查询没区别,但要注意事务的隔离级别会影响 JOIN 的可见性。如果事务隔离级别是ReadCommitted,而 JOIN 的两张表在事务中被其他会话修改,可能出现主表和子表数据不一致的情况。这种场景下建议把隔离级别提到RepeatableRead,或者干脆在事务外先查好数据再进事务处理。
6. 几个我踩过的坑和私房技巧
6.1 委托里不要做耗时操作
MultiMapping 的委托是每行调用一次,如果结果集有 1 万行,委托就执行 1 万次。我见过有人在委托里做字符串拼接、日志打印、甚至数据库查询,结果性能惨不忍睹。委托里只做最纯粹的映射和字典操作,其他逻辑全部挪到映射完成之后。
6.2 用 record 类型简化实体
C# 9 之后可以用record定义实体,配合init属性,代码更简洁。但要注意 Dapper 对record的支持需要属性有set或init,纯构造函数赋值的record需要额外配置。我一般用record class加{ get; init; },实测没问题。
6.3 把映射逻辑抽成扩展方法
如果项目里 MultiMapping 用得多,建议把字典去重那套逻辑抽成泛型扩展方法,避免每个查询都写一遍。
public static IEnumerable<TParent> QueryParentChild<TParent, TChild, TKey>( this IDbConnection conn, string sql, Func<TParent, TKey> parentKey, Func<TChild, TKey> childKey, Action<TParent, TChild> attach, string splitOn, object param = null) { var dict = new Dictionary<TKey, TParent>(); conn.Query<TParent, TChild, TParent>( sql, (parent, child) => { var key = parentKey(parent); if (!dict.TryGetValue(key, out var entry)) { entry = parent; dict[key] = entry; } if (child != null) attach(entry, child); return entry; }, splitOn: splitOn, param: param); return dict.Values; }这样调用方只需要传 key 选择器和挂载动作,代码量能减少一半以上。这个扩展方法我在三个项目里用过,稳定性没问题。
6.4 测试时打印真实 SQL
Dapper 本身不打印 SQL,排查问题时很不方便。我的做法是包一层IDbConnection的装饰器,在Query调用前把 SQL 和参数打到日志里。或者用MiniProfiler这类轻量工具,能直接看到 SQL 和执行时间。不要靠猜,一定要看到真实执行的 SQL,很多映射问题看一眼 SQL 列顺序就明白了。
6.5 什么时候不该用 MultiMapping
最后说个反向经验。下面这些场景,我建议不要用 MultiMapping:
- 子表数据量可能很大(比如一个订单有上千条明细),笛卡尔积膨胀严重;
- 关联层级超过两层(比如订单 → 明细 → 明细的明细),Dapper 的
Query最多支持 7 个泛型参数,但代码会变得极难维护; - 需要动态拼接查询条件,SQL 本身就很复杂,再叠加映射逻辑容易出错。
这些场景下,QueryMultiple或者干脆分多次查询,往往更清晰、更可控。工具是拿来解决问题的,不是拿来炫技的。
我在实际项目里的体会是:MultiMapping 最适合"一对多且子表数量可控"的场景,比如订单带明细、文章带评论、用户带角色。一旦超出这个范围,就该考虑换方案了。这个边界感比会用 API 更重要。