从 10 秒到 1 秒:一次标准差判读的批量查询与索引优化
本文复盘一次标准差判读功能的性能优化:合并查询、建立内存索引、减少重复遍历,并修复历史实现中的结果覆盖等问题。文中结论以源码和业务观察为依据;新旧结果是否一致尚未经过同批数据验证。
1. 背景与问题现象
这是实验室质控平台的能力验证判读功能。组织方把同一批质控样本分发给各实验室,各实验室上报检测值;平台按样本类别汇总所有机构的值,计算均值M和样本标准差S,再用|x − M|与1S / 1.5S / 2S / 3S比较,给出单样本评价,最后合成机构综合评价。
判读有三条入口:详情页、成绩计算和 Excel 导出,三条路径使用同一套判读逻辑。数据来自filling_sample、qa_task_details、qa_task_sample三张表;qa_filling_tasks.status用于过滤有效填报。
优化前,详情页业务侧需要等待10 秒以上;改造后降到约 1 秒。这个数字来自实际使用中的观察,并非正式压测:没有记录并发量、机器环境和 P95,因此不能把它表述为所有场景下都稳定提升十倍。
2. 性能瓶颈定位
旧实现先查任务明细,再查检测值。两条查询都在循环外,所以问题不是 N+1;真正的浪费发生在 Java 端的数据处理。
// detailsBySample、valuesBySample 是两条查询结果按样本类别分组后的 MapfillingTaskMap.forEach((labId,lab)->{// F 家机构qaTaskSampleMap.forEach((sampleId,sample)->{// S 类样本detailsBySample.get(sampleId).forEach(detail->{// 约 F 条明细// 旧代码此处用明细 ID 查按样本类别 ID 建的索引valuesBySample.get(detail.getId()).stream().collect(Collectors.groupingBy(CodeValueBySample::getId));});});});问题集中在三处:
- 外层已经在遍历机构,内层却再次取出“该类样本下所有机构的明细”;在索引能命中的前提下,同一批记录会被重复处理约
F次; - 最内层对已经查到的记录重新
groupingBy、toMap,属于重复分组和重复查找; - 结果集合也在明细循环内创建和添加,带来了结果覆盖、重复添加等问题(见第 5 节)。
旧实现已经在循环外计算均值和标准差,这部分不是本次优化新增的收益。
3. SQL 查询优化
旧实现先查任务明细(select *,带det.del_flag = 0,没有状态过滤),再查检测值(关联qa_task_details,按status in ('2','4','5')过滤)。两条 SQL 在qa_task_details上重复取数。
合并后的查询只取必要字段,并保留状态过滤:
selectdet.filling_task_id,det.qa_task_sample_id,sample.valuefromfilling_sample samplejoinqa_task_details detondet.id=sample.qa_task_details_idwheresample.template_instance_item_info_id=#{dataSourceId}anddet.task_id=#{taskId}andexists(select1fromqa_filling_tasks fwheref.id=det.filling_task_idandf.statusin('2','4','5'))这组判读数据查询中减少了一次数据库访问,也少传输了一份任务明细结果集(行数取决于实际明细数量,记为D),少做了一次 Java 侧分组。示例省略了实际 SQL 中的det.id、qs.name、det.sample_code等字段;数据库端收益仍取决于执行计划,本文未做 SQL 日志对比。
有两点结果范围差异必须保留,不能宣称新旧 SQL 完全等价:
- 旧明细查询带
det.del_flag = 0,新查询没有;如果存在逻辑删除的任务明细,结果集范围会不同。 - 旧明细查询没有状态过滤,新查询只保留
status in ('2','4','5')。其中2、4在实体注释中分别对应“已填报”“已评分”,5没有注释说明,本文不推断其业务含义。
4. 内存索引与循环优化
拿到N条检测记录后,先按两个访问路径建立索引。这里的all是批量查询返回的记录,sampleIndex是“样本类别 id -> 样本对象”的有序 Map。下面的代码为突出索引与循环结构做了简化,省略了结果组装、异常处理和业务分支,不是可直接替换生产实现的完整代码。
// 样本类别 -> 该类样本下所有机构的检测值(用于统计)Map<Long,List<CodeValueBySample>>bySample=all.stream().collect(Collectors.groupingBy(CodeValueBySample::getQaTaskSampleId));// 机构 -> 该机构的检测值(用于逐家判读)// key 是 qa_filling_tasks.id,与 fillingTaskMap 的 key 同维度Map<Long,List<CodeValueBySample>>byFillingTask=all.stream().collect(Collectors.groupingBy(CodeValueBySample::getFillingTaskId));均值和标准差是样本级不变量,在机构循环外只算一次:
Map<Long,BigDecimal>meanMap=newHashMap<>();Map<Long,BigDecimal>sdMap=newHashMap<>();sampleIndex.forEach((sampleId,sample)->{List<CodeValueBySample>sampleValues=bySample.get(sampleId);if(sampleValues==null||sampleValues.isEmpty()){thrownewIllegalStateException("缺少检测记录,需按业务规则处理");}List<BigDecimal>values=sampleValues.stream().map(CodeValueBySample::getValue).collect(Collectors.toList());meanMap.put(sampleId,EquipStdDevUtils.mean(values,10));sdMap.put(sampleId,EquipStdDevUtils.sampleStandardDeviation(values,10));});判读时按机构循环,在机构级建立一次“样本类别 -> 记录”的索引,再按样本直接查找:
List<SampleStdDevQaFillingTask>results=newArrayList<>();// fillingTaskMap: qa_filling_tasks.id -> SampleStdDevQaFillingTaskbyFillingTask.forEach((labId,labValues)->{// labId 是 getFillingTaskId() 返回的 qa_filling_tasks.idSampleStdDevQaFillingTasklab=fillingTaskMap.get(labId);if(lab==null){thrownewIllegalStateException("缺少机构任务记录,需按业务规则处理");}Map<String,Object>sampleMap=newLinkedHashMap<>();Map<Long,CodeValueBySample>valueIndex=labValues.stream().collect(Collectors.toMap(CodeValueBySample::getQaTaskSampleId,e->e,(oldValue,newValue)->oldValue));sampleIndex.forEach((sampleId,sample)->{CodeValueBySamplerecord=valueIndex.get(sampleId);BigDecimalmean=meanMap.get(sampleId);BigDecimalsd=sdMap.get(sampleId);// record、value、mean、sd 都可能缺失;处理规则待业务确认if(record==null||record.getValue()==null||mean==null||sd==null){thrownewIllegalStateException("缺少样本记录或统计量,需按业务规则处理");}BigDecimalsampleValue=record.getValue();BigDecimalabsValue=sampleValue.subtract(mean.setScale(1,RoundingMode.HALF_UP)).abs();// 用 absValue 与 1S / 1.5S / 2S / 3S 比较 ...sampleMap.put(sample.getName(),sampleValue);// 组装展示字段(简化)});lab.setSample(sampleMap);// 每家机构只组装一次// 计算综合评价并回填 lab.setResult(...)(此处省略)results.add(lab);});说明:本节示例中的IllegalStateException只是为避免空指针而写的示意占位,不代表生产实现;缺失数据是报错、跳过还是按其他规则处理,仍需业务确认。
核心变化只有两点:内层只处理当前机构自己的V条记录;重复的stream + groupingBy变成一次索引构建 +O(1)查找。
当前仓库仍把valueIndex建在样本循环内;上面是整理后的前置写法,属于等价小改动,每机构少建S - 1次索引,V很小时收益有限,不计入已上线的优化。
toMap的合并函数保留第一条,前提是同一机构同一类别至多一条有效记录;若有复测或多指标共用字段,后续记录会被静默丢弃,需先明确聚合规则。
5. 优化效果与正确性检查
结果是业务侧观察到的 10 秒以上降到约 1 秒;查询合并、索引与循环调整、缺陷修复这三类改动对耗时的贡献无法拆分,不能把收益全部归因于某一种优化。
复杂度变化
设F为机构数,S为样本类别数,V为每机构每类样本的检测记录数,N为批量查询返回的记录总数。数据均衡时,N ≈ F × S × V。
| 版本 | 记录处理量 | 说明 |
|---|---|---|
| 旧实现 | 约F × N | 每家机构都把全量数据再走一遍 |
| 新实现 | 约N | 查询、分组、统计、索引和查找各一遍 |
旧实现F × N的前提是:每机构每类一条明细、索引能命中、每条明细V条记录。旧代码索引键不一致,可能提前失败,所以这是结构量级,不是实测。新实现为O(N)的前提是N ≈ F × S × V(V ≥ 1)、每家机构都要遍历S个样本类别,这样F × S的查找项会被N吸收;若V = 0或数据严重不平衡,应按实际记录数和实际遍历次数分别估算。索引与记录同阶,空间复杂度O(N)。
正确性检查
与本次重构直接相关的源码问题有三类:
- 样本结果 Map 在明细循环内创建,同一机构的结果会被后续明细覆盖;
results.add(...)位于明细循环内,同一机构对象可能被重复添加;- 旧代码用明细 ID 查询按样本类别 ID 建立的索引,两个 ID 不属于同一维度,可能取不到数据。
新版按机构循环,组装和添加各一次。但这些只是源码确认,不是生产故障统计;新旧结果尚未同批对比,仓库也没有自动化测试。
待确认风险:缺失记录(含状态3机构)、重复记录,以及单样本判定用 1 位小数均值、综合评价用 1 位小数差值带来的阈值边界差异。需业务确认,本文不设默认行为。
6. 总结与实践经验
这次优化没有复杂技巧,主要是三件事:
- 合并查询:先确认查询是否真的在循环里。旧实现的两条查询都在循环外,问题不是 N+1,而是查询结果存在重叠、后续又重复处理。
- 按访问路径建索引:按样本统计就建“样本 -> 记录”,按机构判读就建“机构 -> 记录”,把重复遍历换成一次分组和直接查找。
- 不变量前置:均值、标准差和阈值倍数与机构无关,放在循环外只算一次。