钉钉同步性能优化(异步化+批量化)+ 头像回填 CDN 解析失败

导读

钉钉同步两个故事:(1) 15000+ 用户同步时 HTTP 超时——根因是 toUpdateUsers 循环内 3 次 DB 查询每人 = 46000 次 SQL round-trip 在一个事务内串行执行,两步优化:异步化(HTTP 立即返回”已受理”,后台静默执行)+ 批量化(循环前 IN 批量预加载到 Map,循环内 O(1) 查 Map),计划阶段就识别出 4 个潜在 bug(路径双重前缀、BaseResultMap 错映射、dirty-check 破坏恢复语义、peek 污染原始 DB 状态)。(2) 头像回填接口返回 200 但成功数始终为 0——admin 服务器在内网,DNS 无法解析钉钉 CDN 域名 static-legacy.dingtalk.com,所有用户全部被静默跳过。

🎧 文章导读

🎵 背景音乐

前言

钉钉同步功能跑通后,规模放大就出问题:15000+ 用户同步时网关超时。问题的本质不是”代码错了”,而是”算法复杂度在小规模下隐藏了性能问题”——这是典型的”小规模下完全合理、大规模下完全不可用”的设计。

同时新加的头像回填接口”返回 200 但没效果”,表面是逻辑分支问题,深挖发现是网络可达性假设不成立。

异步化时序图
图1:异步化时序图

Part 1:性能优化

问题分析

handleDingUserSync 方法中,对每个已存在的钉钉用户,在循环体内依次执行三次数据库查询

1
2
3
4
5
6
7
8
9
10
11
12
// 重构前:循环内每人 3 次 DB 查询
for (UserDTO updateUser : toUpdateUsers) {
Set<String> existingOrgs = userOrganizationService
.listByCondition(orgQuery).stream() // 1 SQL/user
.map(UserOrganizationDTO::getOrgId)
.collect(Collectors.toSet());
ParkOrganizationDTO newParkOrg = parkOrganizationService
.findParkByOrgWalkUp(deptOrgId); // 1 SQL/user
List<UserParkDTO> existParks = userParkService
.listByCondition(parkQuery); // 1 SQL/user
// ...
}

每人 3 次 SQL × 15282 人 = 约 46000 次 SQL round-trip。每次 10-15ms(连接池等待 + 网络延迟),整个循环要数分钟。

更麻烦的是,这一切发生在一个 @Transactional 事务内。46000 次 SQL 全部串行执行,事务持有的数据库连接和锁资源持续数分钟,对数据库的压力不容小觑。

前端 Feign 调用 iot-middle 拉取数据 + 后端 triggerPassUserSync 同步阻塞 through-service,整条链路耗时远超网关的超时阈值。用户触发同步后,网关断开连接,但后台任务仍在跑——结果是同步看似”失败”了,实际上数据已经部分写入,状态不可控

重构思路

方案 A:只做异步化

把同步逻辑丢到后台线程,HTTP 请求立即返回。优点是改动极小(加 @Async 即可),前端不再超时;缺点是后台仍然要跑很久,数据库压力不变。

方案 B:异步化 + 批量化

在异步化的基础上,把逐人查询改为批量预加载。优点是从根本上消除 O(N) 的 SQL 往返;缺点是改动面更大。

选择方案 B:异步化解决用户体验问题,批量化解决数据库压力问题,两者互不依赖、可以分步上线。

重构方案

Step 1: 异步化入口(消除 HTTP 超时)

新建 DingUserSyncTask 组件,用 @Async 注解标记执行方法。但异步化带来新问题:如果用户连续点击两次同步按钮,会并发执行两份同步任务。因此用 AtomicBoolean 实现防重入锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Slf4j
@Component
public class DingUserSyncTask {
private final AtomicBoolean running = new AtomicBoolean(false);

public boolean tryStart() {
return running.compareAndSet(false, true);
}

@Async
public void syncDingUserFromIotMiddle() {
try {
// ...省略 iot-middle 拉取、handleDingUserSync、softDelete...
} finally {
running.set(false); // 确保异常也能释放锁
}
}
}

tryStart() 用 CAS 操作保证原子性,finally 块确保无论成功还是异常都能释放锁。Controller 端通过 tryStart() 的返回值判断是提交任务还是拒绝请求。

Step 2: 批量化查询(消除 SQL 往返放大)

把循环内的逐人查询改为循环外的批量预加载:

1
2
3
4
5
6
7
8
9
10
// 重构后:循环前 3 组批量查询,循环内 0 次 DB 查询
Map<String, Set<String>> existingOrgsByUserId = userOrganizationService
.listActiveByUserIds(updateUserIds).stream()
.collect(Collectors.groupingBy(UserOrganizationDTO::getUserId,
Collectors.mapping(UserOrganizationDTO::getOrgId, Collectors.toSet())));
Map<String, List<UserParkDTO>> existingParksByUserId = userParkService
.listActiveByUserIds(updateUserIds).stream()
.collect(Collectors.groupingBy(UserParkDTO::getUserId));
Map<String, ParkOrganizationDTO> parkOrgByDeptOrgId = parkOrganizationService
.findParksByOrgWalkUp(deptOrgIds);

循环内改为直接查 Map,不再碰数据库。新增的三个批量查询方法分别对应 mapper 接口、service 接口和 XML 实现。其中 findParksByOrgWalkUp 需要改造原有的单条 CTE 递归查询,用 IN (...) 替代单个参数,同时保持递归上溯逻辑不变。

评审阶段的关键发现

[!danger] 计划阶段发现的 4 个问题
这些问题如果在编码后才发现,会导致线上 bug 或需要返工。在计划阶段提前识别并修正,节省了大量时间。

1. @PostMapping 路径双重前缀

计划里误写 /sync/syncDingUserFromIotMiddle,但 SyncController 类上已有 @RequestMapping("/sync"),拼出来变成 /sync/sync/...,会导致 404。发现后改回 /syncDingUserFromIotMiddle

2. UserPark BaseResultMap 错映射

UserParkMapper.xmlBaseResultMaporg_idpark_id 都映射到 parkId。如果用 resultMap="BaseResultMap" 查询批量方法,getOrgId() 会恒为 null,导致园区 dirty-check 失效。加上项目没有配置 mapUnderscoreToCamelCase,裸 resultType 也不行。最终采用显式 AS 别名的方式绕过:

1
2
3
4
5
6
7
8
<select id="listActiveByUserIds" resultType="...UserParkDTO">
SELECT user_id AS userId,
org_id AS orgId,
park_id AS parkId
FROM sys_user_park
WHERE user_id IN (...)
AND delete_flag = '0'
</select>

3. 主表 dirty-check 破坏 delete_flag 恢复语义

listByCondition 不默认过滤 delete_flag,匹配用户的 DB delete_flag 可能是 '1'(上一轮 softDelete 置删)。当前逻辑中”恢复为正常”写是 through 同步能读到的前提。dirty-check 跳过未变更用户会漏掉只需恢复的账号。将主表 dirty-check 从首版中移除

4. existDingUserMap 的 deleteFlag 被 peek 污染

peek(user -> user.setDeleteFlag(YES)) 只改内存,后续 toUpdate 又改回 NO——原始 DB 状态已丢失,无法用于 dirty-check 判断。随主表 dirty-check 一起移除。

这四个发现中,第 2 和第 3 个是真正会导致线上 bug 的问题。如果没有在计划阶段逐行 trace 到 mapper XML,它们很可能在集成测试甚至上线后才暴露

影响评估

改动范围 文件数 风险 回归测试
异步化入口(@Async + 防重入) 3 触发同步 → 立即返回 → 二次触发被拒
批量化查询(mapper/service/XML) 13 同步完成后对比用户数、部门关系、园区关系

编译已通过(BUILD SUCCESS),但 Task 3 的手动验证待执行:启动 admin → curl 触发同步 → 确认立即返回 → 二次触发被拒 → 日志确认后台完成。

性能对比柱状图
图3:性能对比柱状图

Part 2:头像回填失败

问题背景

南网项目的用户同步流程从钉钉中台(DataPhin)拉取用户信息后,会把基本字段写入 sys_user 表。但头像字段 face_pic 一直留空——原因是早期的同步代码根本没有自动下载头像这一步,头像全靠人工在前端拍照或手动上传。

后来开发了 backfillDingAvatar 接口,意图一次性把存量用户的钉钉头像下载到本地存储(aided 服务的 /fs/hikFacePic/ 目录),再把本地路径回填到 sys_user.face_pic。接口调上去返回 200,日志也没有报错,但成功计数始终是 0。

数据库现状一目了然:约 15000 名用户中,face_pic 为 NULL 的有 5102 人,为空字符串的有 10000 人(主同步新建时设的默认值),标记为 "2" 的有 180 人(拒绝标记),而真正有正常路径的——0 人

排查思路

一开始看到”返回 200 但成功数为 0”,直觉是逻辑分支有问题——可能某个条件判断把所有用户都过滤掉了。于是先看 backfillDingAvatar 的主循环逻辑,发现它对每个用户调用 uploadDingAvatar 方法,如果该方法返回空字符串就跳过。那问题就转移到了 uploadDingAvatar 为什么返回空字符串。

顺着 uploadDingAvatar 往下追,第一步是调用 UrlToMultipartFileConverter.convert(dingUser.getAvatar()),把钉钉返回的头像 URL 下载成 MultipartFile。钉钉头像的 URL 格式是 https://static-legacy.dingtalk.com/media/xxx.jpg,这是一个外网 CDN 地址。而这台 admin 服务器部署在内网,DNS 无法解析这个域名——convert 方法内部的 HTTP 请求直接抛了 java.net.UnknownHostException

关键在于异常处理的设计:uploadDingAvatar 用 try-catch 包住了整个流程,catch 块里只打了日志就返回空字符串。由于日志级别或日志量的原因,这个异常信息被淹没了,表面上看起来一切正常。

为什么 face_pic 必须存本地路径

便捷通行模块(through-service)的 PassDistSyncer 读头像的方式是在配置的 pathPrefix(如 /data/sharestore)后面直接拼接 face_pic 的值:

  • 如果 face_pic 是本地路径 /fs/hikFacePic/xxx.jpg,拼出来是 /data/sharestore/fs/hikFacePic/xxx.jpg,这是正确的文件系统路径
  • 如果 face_pic 是钉钉 URL,拼出来就变成了 /data/sharestore/https://static-legacy.dingtalk.com/xxx.jpg——一个不伦不类的坏路径,IOUtils 去读必然失败

所以 face_pic 必须是本地路径,这决定了头像必须先下载再存储

修复方案

方案一:admin 服务器配外网代理

UrlToMultipartFileConverter 的底层 HTTP 客户端走代理服务器下载钉钉头像。这是改动量最小的方案——零代码改动,只需运维在服务器上配置 HTTP 代理环境变量或在 HTTP 客户端中注入代理地址。但需要运维配合,且代理服务器本身需要能访问钉钉 CDN。

方案二:中台提供头像下载接口

不直接访问钉钉 CDN,而是调用钉钉中台(iot-middle)的接口获取图片字节流。admin 和中台在同一个内网环境,网络互通。这个方案需要中台配合新增一个头像下载接口,但彻底绕开了外网访问的问题。

方案三:暂不自动回填

回填接口保留代码不动,等网络环境打通后再执行。现有头像继续靠人工上传。这是零改动方案,适合短期内不紧急的场景。

经验总结

批量查询优化
图2:批量查询优化

异常处理的粒度

uploadDingAvatar 把所有异常一网打尽返回空字符串,表面上是”容错”,实际上把可恢复的错误(网络不通,换个方式下载就行)和不可恢复的错误(URL 本身无效)混为一谈。建议:

  • 区分 IOException(网络层失败,可重试或降级)和其他异常(逻辑错误,应告警)
  • 在接口返回值中区分”全部成功”、”部分失败”、”全部失败”三种状态

网络假设要在设计阶段验证

涉及外网 URL 下载的功能,上线前需要确认服务器的网络出口策略。这次的问题完全可以在设计评审时发现——只要问一句”admin 服务器能访问钉钉 CDN 吗?”

接口语义要诚实

返回 200 但成功数为 0,从调用方角度看是”成功了但什么都没做”,这比返回 500 更危险,因为它不会触发告警。建议当成功数为 0 时返回非 200 状态码,或至少在响应体中标记 warning 级别的提示。

性能问题的最佳发现时机是计划阶段

这次优化的核心收获是:性能问题的最佳发现时机是计划阶段,而不是编码阶段。在做实现计划时逐行 trace 调用链(controller → service → mapper → XML),不仅帮助理解数据流,还能提前发现数据模型层面的陷阱(比如 BaseResultMap 的错映射)。

通用 mapper 的 OGNL 坑再次出现

框架通用 mapper 的 XML 用 OGNL 求值实体字段,非空 Date 字段被渲染成 != '' 会触发解析错误。任何涉及通用 mapper 的改动,都必须一路看到 XML 才能确认安全

批量查询的 IN 子句有长度限制

如果 updateUserIds 列表超过几千条,某些数据库驱动可能会出问题。目前 15000 人应该在安全范围内,但如果未来规模继续增长,可能需要分片 IN 查询。

关联

  • [[2026-06-25 工作记录]]
  • [[钉钉用户同步性能优化/重构分析]]
  • [[钉钉头像回填失败分析/问题分析与修复]]