钉钉同步性能优化(异步化+批量化)+ 头像回填 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 | // 重构前:循环内每人 3 次 DB 查询 |
每人 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 |
|
tryStart() 用 CAS 操作保证原子性,finally 块确保无论成功还是异常都能释放锁。Controller 端通过 tryStart() 的返回值判断是提交任务还是拒绝请求。
Step 2: 批量化查询(消除 SQL 往返放大)
把循环内的逐人查询改为循环外的批量预加载:
1 | // 重构后:循环前 3 组批量查询,循环内 0 次 DB 查询 |
循环内改为直接查 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.xml 的 BaseResultMap 把 org_id 和 park_id 都映射到 parkId。如果用 resultMap="BaseResultMap" 查询批量方法,getOrgId() 会恒为 null,导致园区 dirty-check 失效。加上项目没有配置 mapUnderscoreToCamelCase,裸 resultType 也不行。最终采用显式 AS 别名的方式绕过:
1 | <select id="listActiveByUserIds" resultType="...UserParkDTO"> |
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 工作记录]]
- [[钉钉用户同步性能优化/重构分析]]
- [[钉钉头像回填失败分析/问题分析与修复]]