八万行物化与八个坑:钉钉公告统一通知的全层上线实录
导读
一天之内把钉钉公告功能堆到第三层:P1 同步落库、P2 统一列表合并、P3 全员物化 + 站内信触达 + 每小时定时同步。三轮外部 review 抓出的问题全部核实属实并修复;然后是本次最有价值的部分——生产上线排障实录,八个坑每个都真实发生。文末附一份可复用的上线部署清单。
前言
钉钉公告(blackboard)功能不是一天做完的,但 9 月 11 日是密度最高的一天:在 P1(公告同步落库 sys_notice)和 P2(统一通知列表把公告与站内消息合并分页)之上,追加了三层——P3 全员物化(每条公告 × 每个员工落一行,已读未读有行级载体)、站内信触达(新公告全员发铃铛)、每小时定时同步。功能之后是合并、发版、上线,以及一连串只有在生产环境才会遇到的坑。
一、物化:让”谁看了谁没看”有行级载体
P3 的核心是一条物化约定:
站内信触达(biz_type=101)有个漂亮的取舍:点击标已读时联动物化行,前端零改动——跳转需求被明确砍掉,复用现有铃铛链路。定时同步则是 @EnableScheduling + @Scheduled,cron 和开关都可配(ding.notice-sync-cron 默认每小时整点)。

图 1:一条公告 × 每个员工一行——71656 = 13 × 5512 就是这么来的
二、合并分页:两个数据源,一页不重不漏
P2 统一列表的技术核心是两个数据源的合并分页:站内消息在 smart 库,公告在 admin 库(Feign 拉全量后内存合并)。分页拦截器指望不上,改成 Service 全权分页:
筛选语义也全部对齐:messageType=6 只查公告不进消息 SQL;signStatus 过滤时公告不参与(公告无签收概念,恒 -1,与消息 SQL 行为对称);日期范围对公告按 publishTime 内存过滤。前端还踩了个富文本样式坑:钉钉导出的正文每个 <p> 带 margin:0 内联样式,普通 CSS 压不过内联优先级,必须 !important 才能拉开段落间距。
三、三轮 review 精华:虚拟生成列救了唯一键
三轮外部 review 抓的问题全部核实属实。第二轮的关键一条:补录通知在事务内发 Feign——handleDingUserSync 带 @Transactional,事务内调远程一旦回滚会留孤儿通知,改成 runAfterCommit(TransactionSynchronization 惯用法)。
第三轮四条都值得单列:
- 全表唯一键会挡巡检合法双通知(P1)。巡检的”下发/超时”通知共用
(biz_type, biz_id, user_id)属合法两条,全表唯一键会误伤,迁移 DELETE 还会误删历史。解法相当优雅——虚拟生成列:
- listByCondition 缺 sourceType 过滤(P1):过滤条件只加在 listByPage,而物化查的是 listByCondition——本地公告会被误物化并发出”新的钉钉公告”站内信。补齐 + 契约测试守护。
- 补录筛选时机提前(P2):员工口径 SQL 依赖组织关系表,新员工的组织关系在事务后段才落库,事务内先查会把正常新员工过滤掉。此前 E2E 的”下次全量同步补齐”恰是兜底路径,掩盖了当场补录失效——查询挪进 afterCommit 后,mock 新员工当场补录实测通过。
- INSERT IGNORE 部分忽略仍推送(P2):被并发忽略的行带着随机 id 推给前端,点开标已读必失败。affected 小于批大小时回查真实落库行再推。
四、上线排障实录:八个坑,每个都真实发生
这是本篇最值钱的部分。功能在本地 E2E 全过(物化 71656=13×5512、幂等归零、差量补齐、批量已读联动未读 11→0、双通知共存、定时实测,单测 admin 49 + smart 25 全绿),上线依然连环踩坑:
| # | 坑 | 现象 | 根因与解法 |
|---|---|---|---|
| ① | 缺列 | 统一列表 500:Unknown column 'm.attachment_json' |
同事的附件功能列,部署 SQL 清单漏项。一条 ALTER 补上,免重启 |
| ② | 钉钉凭证为空 | 51 个抽样用户全部拉取失败 | ding.notice-app-key/secret 默认空,本地靠启动参数注入、线上 Nacos 没配。补配置 + 重启 |
| ③ | 服务器出不了公网 | 配完凭证仍全失败,UnknownHostException: oapi.dingtalk.com |
切内网代理 notice-oapi-proxy-endpoint=10.152.62.53:8989(免登天天在用的路径,必通) |
| ④ | 包名传截断 | 物化调用报”系统繁忙” | 传输把 SNAPSHOT 截成 SNAPSHO,跑的还是旧包(无物化接口,Feign 404)。传完必须核对文件名/md5 |
| ⑤ | 食堂 XML 坏标签 | smart 启动失败:元素类型 "where" 必须由匹配的结束标记终止 |
同事粘贴丢了 </where></select>。XML 不参与编译、纯 Mockito 单测不解析 XML——编译+单测全绿仍漏。合并别人代码后必须启动一次 |
| ⑥ | 打包漏 -am |
ClassNotFoundException: CanteenMealOrderRecordDTO |
mvn -pl X package 不带 -am 用了本地仓库旧 jar。老坑复犯——抢时间忘了 |
| ⑦ | 首刷超时(预期内) | 点同步后页面”系统繁忙” | 8 万+行批量插入超 Feign 读超时,admin 报错但 smart 后台继续跑完。等 3 分钟查行数自洽,再点一次幂等归零收工 |
| ⑧ | 指定发送人收不到钉钉 | 404 /middleDing/sendText |
sendText 是通知中心新接口,线上 integrate-iot 是旧包。补打 iot 包(带 -am)部署 |

图 2:八个坑没有一个是代码逻辑错误——全是部署链路的环境与流程问题
八个坑的共同特征值得品味:没有一个是功能逻辑错误——全是配置漏项、凭证、网络、包传输、XML 良构、依赖版本这类”部署链路”问题。本地 E2E 再全,也盖不住这些只在生产环境存在的变量。
五、可复用的上线部署清单
收尾沉淀的清单:SQL(两个库共 4 个脚本,生成列迁移前要备份核查 101 重复行);Nacos(ding 块最终形态含 app-key/secret/agent-id、双代理端点);jar(smart / admin / integrate-iot 三个,打包必带 -am);验证四步——①同步日志”物化新增=几万” ②库三数自洽(公告数×员工数=物化行=站内信)③再点一次归零(幂等)④员工视角走一遍列表/详情/已读/铃铛。
留下的已知限制也记录在案:超管”全部”视角会看到 71656 行公告副本(方案已量化推荐,拍板”算了不做了”);物化行物理删除后下次同步会补回,逻辑删除则占唯一键、重发被 IGNORE——“删过的公告不重弹铃铛”恰好是期望行为;钉钉没有全量历史接口,同步是滚动窗口,停机期间滚出”最新十条”的较早公告可能永久漏。
经验总结
- 物化行的
create_time用业务时间(公告发布时刻)不用同步时刻——排序和”当日”语义才对; - 唯一键要精确约束目标行集时,虚拟生成列 + NULL 不受约束是 MySQL 的标准解法;
- 事务内别发远程调用:补录、通知一律 afterCommit;
- E2E 的兜底路径会掩盖当场失效——验证时要区分”当场生效”和”下次补齐”;
- 部署清单的完整性本身就是质量:漏一条 SQL、截断一个包名,都是事故;
- XML 不参与编译、Mockito 不解析 XML——合并别人代码后,启动一次是最便宜的验证。
结语
这一天从三层功能写到八个生产坑,最深的体会是:本地全绿只是入场券,生产环境有自己的题库。好在每个坑都被当场记录、当天修复、沉淀进清单——下一次上线,这张清单就是别人的避雷图。