从一段看似合理的重复数据检测代码,聊透Governor Limits的"聚合"本质
引言
在Salesforce开发中,Too many query rows: 50001 这个错误几乎每位Apex开发者都遇到过。我们通常的理解是:"哦,这次SOQL查询返回太多了,得分页或者加条件。"这个理解只对了一半 。今天,我想通过剖析一段社区中常见的"查找重复联系人"的代码,来揭示这个限制更本质、也更易被忽视的一面:它是一个事务(Transaction)级别的总行数限制,而非单次查询的限制。误解这一点,可能会让你的代码在数据量增长后,从"运行良好"突然变成"定时炸弹"。
一、一段"稳健"的重复检测代码
我们先来看看这段旨在查找同一客户(Account)下拥有相同手机号(MobilePhone)联系人的代码。逻辑清晰,分步执行:
Java
// 步骤1:聚合查询找出重复的(MobilePhone, AccountId)组合
List<AggregateResult> duplicateAggregates = [
SELECT COUNT(Id) contactCount, MobilePhone, AccountId
FROM Contact
WHERE AccountId != null
AND Account.RecordType.Name IN ('Customer', 'Prospect')
AND MobilePhone != null
GROUP BY MobilePhone, AccountId
HAVING COUNT(Id) > 1
];
// 假设这里找到了 5000 个重复组合
// 步骤2:提取出涉及到的AccountId和MobilePhone集合
Set<Id> accountIds = new Set<Id>();
Set<String> setMobile = new Set<String>();
Map<Id, Set<String>> accountMobileMap = new Map<Id, Set<String>>();
for(AggregateResult ar : duplicateAggregates) {
Id accountId = (Id)ar.get('AccountId');
String mobilePhone = (String)ar.get('MobilePhone');
setMobile.add(mobilePhone);
accountIds.add(accountId);
// ... 填充 accountMobileMap
}
// 步骤3:查询所有相关的Contact详情
System.debug('符合条件的Contact数量 初筛:' +
[SELECT ID FROM Contact WHERE AccountId in: accountMobileMap.keySet() AND MobilePhone in :setMobile].size()
);//27000条数据
List<Contact> allContacts = [SELECT ID FROM Contact WHERE AccountId in: accountMobileMap.keySet() AND MobilePhone in :setMobile];//报错:`Too many query rows: 50001`
看起来没问题,对吗? 聚合查询可能只返回5000行(duplicateAggregates.size() = 5000)。后续查询基于这5000个组合去查询具体的Contact记录。
但是,System.debug 输出的"初筛"数量,才是问题的关键。如果这个数字接近甚至超过 25,000 ,那么allContacts这个查询就极有可能触发Too many query rows: 50001错误,导致整个事务回滚。
二、核心误区:不是"单次查询",而是"事务合计"
这就是最关键的认知点。Salesforce的官方限制描述是:
Total number of records retrieved by SOQL queries -- 50,000
请注意这里的 "Total" 和 "SOQL queries" (复数)。这意味着,在单个Apex事务 (例如一次Trigger执行、一个按钮点击触发的Apex动作、一个Schedule Job的一次运行)中,所有执行过的SOQL查询所检索到的记录行数累加总和不能超过50,000条。
在上面的代码中:
- 第一个查询 (聚合查询):检索了
Contact表的所有记录行,经过WHERE条件过滤、GROUP BY分组后,最终返回到Apex内存中 的是聚合结果(100行)。但是,服务器端为了执行这个分组,它需要读取多少条原始的Contact记录呢?这个读取的数量,就被计入50,000条的限额中。 - 第二个查询 (
allContacts):又检索了一批Contact记录。 - 这两个查询检索的行数之和,就是事务消耗的总查询行数。
陷阱在于 :即使你的数据库有100万个Contact,聚合查询最终只返回100个聚合行,但为了计算这100个结果,SOQL引擎可能需要扫描(即"检索")数十万条原始记录。这些被扫描的记录,统统都算在50,000的限制内。
三、问题分析与解决方案
针对这种"事务级限制"问题,我们的优化思路必须从"减少整个事务中检索的总行数"和"改变执行模式"入手。
方案1:将debug size注释到,释放27000查询记录的使用
原代码的第二步查询 System.debug('符合条件的Contact数量 初筛:' + [SELECT ID FROM Contact WHERE AccountId in: accountMobileMap.keySet() AND MobilePhone in :setMobile].size()); 是消耗了查询记录数的关键。我们可以将这一步注释从而确保后续代码的执行。
方案2:利用Database.getQueryLocator与批处理(Batch Apex)
这是处理海量数据、突破同步限制的终极武器。 将整个任务封装成一个批处理类。批处理框架会自动将任务分拆成多个独立的事务(每个批次),每个批次都有自己的50,000行查询限额。
java
global class FindDuplicateContactsBatch implements Database.Batchable<sObject>, Database.Stateful {
global Map<String, List<Contact>> duplicateMap;
global FindDuplicateContactsBatch() {
duplicateMap = new Map<String, List<Contact>>();
}
// 启动器查询:这里可以是你最初的聚合逻辑,或者直接查询所有待检测的Contact
global Database.QueryLocator start(Database.BatchableContext BC) {
// 只查询需要的字段,减少数据传输量
return Database.getQueryLocator(
'SELECT Id, AccountId, MobilePhone FROM Contact ' +
'WHERE AccountId != null AND MobilePhone != null ' +
'AND Account.RecordType.Name IN ('Customer', 'Prospect')'
);
}
// 执行逻辑:在每个批次(最多200条记录)中执行重复检测
global void execute(Database.BatchableContext BC, List<Contact> scope) {
// 在这个scope内部进行重复检测逻辑
Map<String, List<Contact>> localMap = new Map<String, List<Contact>>();
for (Contact c : scope) {
String key = c.AccountId + '|' + c.MobilePhone;
if (!localMap.containsKey(key)) {
localMap.put(key, new List<Contact>());
}
localMap.get(key).add(c);
}
// 将本批次发现的重复项合并到全局Map中
for (String key : localMap.keySet()) {
if (localMap.get(key).size() > 1) {
if (!duplicateMap.containsKey(key)) {
duplicateMap.put(key, new List<Contact>());
}
duplicateMap.get(key).addAll(localMap.get(key));
}
}
}
// 收尾工作:所有批次完成后,处理最终结果(例如,发送通知、记录日志)
global void finish(Database.BatchableContext BC) {
System.debug('总共发现 ' + duplicateMap.size() + ' 组重复联系人。');
// 这里可以调用其他逻辑来处理duplicateMap
}
}
// 调用方式:Id batchInstanceId = Database.executeBatch(new FindDuplicateContactsBatch(), 2000);
方案3:利用基于ID排序的分页查询(时间换空间)
思路:如果单个事务的查询记录数少于5w,但是又想分页去处理,可以使用ID排序的分页查询。
Java
Integer batchSize = 20000; // 每批2万条,预留安全边际
Id lastContactId = null;
Boolean hasMoreRecords = true;
Integer totalProcessed = 0;
Map<String, List<Contact>> accMobileContactMap = new Map<String, List<Contact>>();
while(hasMoreRecords) {
String query = 'SELECT Id, AccountId, MobilePhone FROM Contact ' +
'WHERE AccountId IN :accountIds AND MobilePhone IN :mobilePhoneList ';
if(lastContactId != null) {
query += 'AND Id > :lastContactId '; // 关键:基于ID排序分页
}
query += 'ORDER BY Id LIMIT :batchSize';
List<Contact> batch = Database.query(query);
if(batch.isEmpty()) {
hasMoreRecords = false;
} else {
// 处理本批数据
for(Contact con : batch) {
String key = con.AccountId + '|' + con.MobilePhone;
if(!accMobileContactMap.containsKey(key)) {
accMobileContactMap.put(key, new List<Contact>());
}
accMobileContactMap.get(key).add(con);
lastContactId = con.Id; // 更新游标
}
totalProcessed += batch.size();
System.debug('已处理 ' + totalProcessed + ' 条,最近ID: ' + lastContactId);
// 批次不满,说明已到末尾
if(batch.size() < batchSize) {
hasMoreRecords = false;
}
}
}
System.debug('总计处理 ' + totalProcessed + ' 条,构建了 ' + accMobileContactMap.size() + ' 个键值对');
技术要点:
ORDER BY Id+Id > :lastId:确保分页不重复不遗漏- 批次大小(batchSize) :设为20000,为事务中其他查询预留空间
- 游标机制 :
lastContactId追踪处理进度
适用场景 :OFFSET LIMIT只能处理前2000条数据, 基于ID的分页查询没有这个限制。 优点 :同步执行,实时响应;可处理较大数据量但任不可超过5w条。
缺点:代码复杂度增加;Heap Size限制。
总结与最佳实践
- 建立"总量"思维:设计代码时,时刻估算整个事务流程可能触发的SOQL查询总行数,而不仅仅是最后一个查询的结果集大小。
- 优先使用聚合与过滤:在查询层尽可能多地完成数据筛选和聚合,减少返回到Apex层的数据量。
- 拥抱异步处理 :对于数据清洗、批量计算、复杂报表等可能涉及大量数据的操作,首选批处理(Batch Apex) 或队列化(Queueable Apex)。它们是 Salesforce 为处理大规模数据而设计的"正规军"。
- 善用集合与映射 :在Apex中,多使用
Set和Map来进行高效的数据去重和查找,避免在循环中执行SOQL查询(防止触发Number of SOQL queries: 101限制)。 - 监控与日志 :在关键位置添加
System.debug,输出中间结果的数量(正如用户代码中所做的那样),这对于预判性能瓶颈和Limit使用情况至关重要。
希望这篇分析能帮助你真正理解SOQL 50,000条限制的本质。在Salesforce的多租户环境下,理解并妥善处理Governor Limits,是编写健壮、可扩展Apex代码的基石。