列表页别逐条查:我在 Rust CRM 里用 is_in + HashMap 干掉 N+1

发布时间:2026/5/28 2:45:25

列表页别逐条查:我在 Rust CRM 里用 is_in + HashMap 干掉 N+1 写后台系统的时候有一种性能问题特别隐蔽列表页只是多显示几个名称数据库查询次数却突然翻倍。比如订单列表。订单表里有customer_uuid页面上要展示客户名订单还可能来自服务请求页面上又要展示服务项目名。需求看起来很普通于是很容易写出这种代码// 伪代码别这么写letordersOrderEntity::find().offset(...).limit(...).all(db).await?;fororderinorders{letcustomerContactEntity::find().filter(ContactColumn::ContactUuid.eq(order.customer_uuid)).one(db).await?;letrequestServiceRequestEntity::find().filter(ServiceRequestColumn::Uuid.eq(order.request_id)).one(db).await?;// 再根据 request.service_catalog_uuid 查服务项目...}这段代码最大的问题不是“慢”而是慢得很稳定地不可控。分页 20 条时看起来还能忍分页 100 条、字段再多几个、网络抖一下列表页就开始卡。更麻烦的是这类问题经常在开发环境看不出来因为本地数据太少、数据库就在旁边。所以我后来在 Pico-CRM 的查询层定了一个很朴素的习惯列表页里不要在for/map里查数据库。一、N1 在订单列表里长什么样Pico-CRM 是一个家政服务 CRM核心流程是客户 - 服务需求 - 订单 - 排班 - 完工订单列表的主数据来自orders表但页面并不只看订单本身还要展示客户姓名来自contacts服务项目订单关联服务请求再由服务请求关联service_catalogs订单状态、时间、金额等订单字段也就是说一行订单 DTO 里混了几类展示字段。如果按照逐行查询来算分页 20 条时最差情况大概是1 次订单分页查询 20 次客户查询 20 次服务请求查询 20 次服务项目查询 61 次查询分页大小一改查询次数跟着涨。这就是典型的 N1。这里的 “N” 不是一个抽象概念而是后台列表页的分页条数。产品觉得 20 条太少想改成 50 条接口压力就会马上变大。二、先分页再批量补字段Pico-CRM 里订单列表的实现放在backend/src/infrastructure/queries/crm/order_query_impl.rs真实流程是这样lettotalselect.clone().count(txn).await.map_err(|e|format!(query orders count error: {},e))?;letmodelsselect.order_by_desc(Column::InsertedAt).offset(Some((query.page-1)*query.page_size)).limit(Some(query.page_size)).all(txn).await.map_err(|e|format!(query orders error: {},e))?;第一步还是正常的主表分页先查总数再查当前页数据。重点在第二步只对当前页数据收集关联 ID。letcustomer_ids:HashSetUuidmodels.iter().filter_map(|model|model.customer_uuid).collect();letrequest_ids:HashSetUuidmodels.iter().filter_map(|model|model.request_id).collect();这里用HashSet有两个好处自动去重避免同一个客户被查多次当前页没有关联数据时可以直接跳过后续查询然后客户信息一次查完letmutcustomer_map:HashMapUuid,StringHashMap::new();if!customer_ids.is_empty(){letcustomersContactEntity::find().filter(ContactColumn::ContactUuid.is_in(customer_ids.clone())).all(txn).await.map_err(|e|format!(query customers error: {},e))?;forcustomerincustomers{customer_map.insert(customer.contact_uuid,customer.user_name);}}这段代码的核心就两件事WHERE contact_uuid IN (...) 查询结果塞进 HashMapUuid, String后面回填 DTO 时直接按 ID 查内存 mapifletSome(customer_uuid)customer_uuid{view.customer_namecustomer_map.get(customer_uuid).cloned();}查询次数不会随着订单条数线性增长。三、关联链路多一层也不要回到逐条查客户名只隔一张表比较简单。服务项目名稍微绕一点订单里不是直接存service_catalog_uuid而是存了request_id。要拿服务项目名需要走orders.request_id - service_requests.uuid - service_requests.service_catalog_uuid - service_catalogs.name这时很多人会下意识写成循环里查两次。Pico-CRM 里还是同一个套路只是拆成两段批量查询。先批量查服务请求并建立request_id - service_catalog_uuid的映射letmutrequest_service_catalog_map:HashMapUuid,OptionUuidHashMap::new();if!request_ids.is_empty(){letrequestsServiceRequestEntity::find().filter(ServiceRequestColumn::Uuid.is_in(request_ids.clone())).all(txn).await.map_err(|e|format!(query service requests error: {},e))?;forrequestinrequests{request_service_catalog_map.insert(request.uuid,request.service_catalog_uuid,);}}再从这些请求里收集服务项目 IDletservice_catalog_ids:HashSetUuidrequest_service_catalog_map.values().filter_map(|value|*value).collect();最后批量查服务目录letmutservice_catalog_map:HashMapUuid,StringHashMap::new();if!service_catalog_ids.is_empty(){letcatalogsServiceCatalogEntity::find().filter(ServiceCatalogColumn::Uuid.is_in(service_catalog_ids.clone())).all(txn).await.map_err(|e|format!(query service catalogs error: {},e))?;forcatalogincatalogs{service_catalog_map.insert(catalog.uuid,catalog.name);}}回填时再把两层 map 串起来ifletSome(request_id)request_id{ifletSome(service_catalog_uuid)request_service_catalog_map.get(request_id).and_then(|value|*value){view.service_catalog_uuidSome(service_catalog_uuid.to_string());view.service_catalog_nameservice_catalog_map.get(service_catalog_uuid).cloned();}}这样一来订单列表的查询次数变成了稳定的几次1 次订单 count 1 次订单分页 1 次客户批量查询 1 次服务请求批量查询 1 次服务目录批量查询分页 20 条是这些查询分页 100 条还是这些查询。当然IN (...)的参数数量也不是无限的。但后台分页列表通常是几十到一两百条放在这个场景里比每行查一次稳得多。四、为什么不直接写一个大 join这个问题很自然既然都是查关联字段为什么不直接 SQL join我的答案是能 join但不是所有列表页都值得 join 成一条 SQL。订单列表这里有几个特点主表必须先按订单条件分页客户名、服务项目名只是展示字段服务项目隔了一层服务请求DTO 回填时还要保留一些Option语义查询层本来就是读模型适配层不是领域规则所在如果用 join也能做。但 SQL 会越来越长SeaORM 查询表达式也会更重。后面再加一个展示字段比如创建人、派工人、服务人员join 链会继续膨胀。而现在这种写法的结构很清楚主表分页 - 收集本页关联 ID - 按表批量查询 - HashMap 回填 DTO它牺牲了一点“单 SQL 的纯粹”换来的是代码可读性和稳定查询次数。我不是反对 join。像强过滤、强排序、必须由数据库完成聚合的场景就应该让 SQL 来做。但对于后台列表页的展示字段补齐尤其是“分页以后补名称”的场景内存 join 很好用。五、服务请求列表也用了同一套模式同样的实现也出现在backend/src/infrastructure/queries/crm/service_request_query_impl.rs服务请求列表要展示三类名称客户姓名contacts创建人姓名users服务项目名service_catalogs代码先从当前页模型里收集三组 IDletcustomer_ids:HashSetUuidmodels.iter().map(|model|model.customer_uuid).collect();letuser_ids:HashSetUuidmodels.iter().map(|model|model.creator_uuid).collect();letservice_catalog_ids:HashSetUuidmodels.iter().filter_map(|model|model.service_catalog_uuid).collect();然后分别用is_in批量查三张表构建三个 mapletmutcontact_map:HashMapUuid,StringHashMap::new();letmutuser_map:HashMapUuid,StringHashMap::new();letmutservice_catalog_map:HashMapUuid,StringHashMap::new();最后统一回填view.contact_namecontact_map.get(customer_uuid).cloned();view.creator_nameuser_map.get(creator_uuid).cloned();view.service_catalog_nameservice_catalog_uuid.and_then(|uuid|service_catalog_map.get(uuid).cloned());这就是我比较喜欢的代码形态读起来很笨但每一步都明确。谁负责分页谁负责批量查谁负责 DTO 字段回填都能一眼看出来。六、员工绩效统计不只是补名称聚合也能这么做is_in HashMap不只适合补名称。Pico-CRM 的员工列表里有一段绩效统计代码在backend/src/infrastructure/queries/identity/user_query_impl.rs它要算每个家政人员的已完成服务次数评价数平均评分售后数投诉数退款数返工数这里如果逐个员工查一遍排班、评价、售后查询次数会更夸张。所以实现里先对当前页员工 ID 批量查已完成排班letschedule_itemsScheduleEntity::find().filter(ScheduleColumn::AssignedUserUuid.is_in(user_ids.iter().copied())).filter(ScheduleColumn::Status.eq(done)).all(txn).await.map_err(|e|format!(query user completed schedules error: {},e))?;评价也批量查letfeedback_itemsOrderFeedbackEntity::find().filter(OrderFeedbackColumn::UserUuid.is_in(user_ids.iter().copied())).all(txn).await.map_err(|e|format!(query user feedback stats error: {},e))?;然后把订单和员工关系做成 mapletschedule_order_mapschedule_items.iter().map(|item|(item.order_uuid,item.assigned_user_uuid)).collect::HashMap_,_();售后表是按订单关联的于是先从schedule_order_map收集订单 ID再批量查售后letorder_idsschedule_order_map.keys().copied().collect::Vec_();letafter_sales_casesiforder_ids.is_empty(){Vec::new()}else{AfterSalesEntity::find().filter(AfterSalesColumn::OrderUuid.is_in(order_ids.clone())).all(txn).await.map_err(|e|format!(query user after sales cases error: {},e))?};统计结果则落到letmutstats_mapHashMap::Uuid,UserPerformanceStats::new();后面不再查数据库而是在内存里累加foriteminschedule_items{letentrystats_map.entry(item.assigned_user_uuid).or_default();entry.completed_service_count1;}平均评分也是先收集再统一计算letmutrating_sumsHashMap::Uuid,(i64,u64)::new();foriteminfeedback_items{letSome(user_uuid)item.user_uuidelse{continue;};letentrystats_map.entry(user_uuid).or_default();entry.feedback_count1;ifletSome(rating)item.rating{letrating_entryrating_sums.entry(user_uuid).or_insert((0,0));rating_entry.0ratingasi64;rating_entry.11;}}这段和订单列表本质一样当前页主实体 - 收集关联键 - 批量查相关表 - 用 HashMap 归并结果只不过订单列表是回填展示字段员工绩效是回填统计字段。七、这套写法的边界这个模式很好用但不是银弹。我现在大概按这几个标准判断。适合用is_in HashMap的场景主表已经分页关联字段只是展示或轻量统计关联数据量跟分页大小同级业务上允许在应用层组装 DTO查询可读性比“单条 SQL”更重要更适合 SQL join / group by 的场景需要按关联表字段过滤需要按关联表字段排序聚合数据量远大于当前页需要数据库利用索引和执行计划完成复杂计算返回数据必须严格由一条一致性查询保证还有一个细节Pico-CRM 的这些查询基本都包在with_shared_txn()里同一个列表查询里的多次读取发生在一个事务上下文中。这不是为了解决所有一致性问题而是让一次查询组装过程的数据库访问边界更清晰。总结N1 查询不一定要靠复杂框架解决。在很多后台系统里最实用的办法反而是这三步HashSet 收 ID SeaORM is_in 批量查 HashMap 回填 DTOPico-CRM 里的订单列表、服务请求列表、员工绩效统计都用了这个套路。它没有什么炫技的地方但能把“分页越大查询越多”的问题压回到稳定的几次数据库访问。我现在看列表页接口第一眼就会搜一件事有没有在循环里查数据库。如果有基本就该停下来把 ID 先收集起来。项目开源在 GitHub搜Pico-CRM可以看到完整代码。你们项目里的列表页关联字段是习惯直接 join还是应用层批量补字段评论区聊聊。

相关新闻