八万行物化与八个坑:钉钉公告统一通知的全层上线实录

导读

一天之内把钉钉公告功能堆到第三层:P1 同步落库、P2 统一列表合并、P3 全员物化 + 站内信触达 + 每小时定时同步。三轮外部 review 抓出的问题全部核实属实并修复;然后是本次最有价值的部分——生产上线排障实录,八个坑每个都真实发生。文末附一份可复用的上线部署清单。

前言

钉钉公告(blackboard)功能不是一天做完的,但 9 月 11 日是密度最高的一天:在 P1(公告同步落库 sys_notice)和 P2(统一通知列表把公告与站内消息合并分页)之上,追加了三层——P3 全员物化(每条公告 × 每个员工落一行,已读未读有行级载体)、站内信触达(新公告全员发铃铛)、每小时定时同步。功能之后是合并、发版、上线,以及一连串只有在生产环境才会遇到的坑。

一、物化:让”谁看了谁没看”有行级载体

P3 的核心是一条物化约定:

物化行约定(message_pushing, message_type=6)
message_id  = noticeId                    -- 与公告一对一
create_time = receive = publishTime       -- 用公告发布时间,不是同步时刻
content=''  sign_status=-1  create_id='ding-sync'  park/tenant=NULL

幂等双轨:物化行与站内信各自查重补齐,无跨表事务

站内信触达(biz_type=101)有个漂亮的取舍:点击标已读时联动物化行,前端零改动——跳转需求被明确砍掉,复用现有铃铛链路。定时同步则是 @EnableScheduling + @Scheduled,cron 和开关都可配(ding.notice-sync-cron 默认每小时整点)。

一条公告物化成全员各行
图 1:一条公告 × 每个员工一行——71656 = 13 × 5512 就是这么来的

二、合并分页:两个数据源,一页不重不漏

P2 统一列表的技术核心是两个数据源的合并分页:站内消息在 smart 库,公告在 admin 库(Feign 拉全量后内存合并)。分页拦截器指望不上,改成 Service 全权分页:

合并分页算法(正确性经数学推演)
公告按 createTime 倒序合并进全局序列;
msgOffset = max(0, offset - boardCount)   -- 公告占掉的格子从消息位移里扣掉
msgLimit  = pageSize + boardCount         -- 恰好覆盖全局 [offset, offset+pageSize)
totalCount = 消息数 + 公告数               -- 不重不漏

降级:admin 拉取异常 → 公告按空列表处理,log.warn,不拖垮通知列表

筛选语义也全部对齐:messageType=6 只查公告不进消息 SQL;signStatus 过滤时公告不参与(公告无签收概念,恒 -1,与消息 SQL 行为对称);日期范围对公告按 publishTime 内存过滤。前端还踩了个富文本样式坑:钉钉导出的正文每个 <p>margin:0 内联样式,普通 CSS 压不过内联优先级,必须 !important 才能拉开段落间距。

三、三轮 review 精华:虚拟生成列救了唯一键

三轮外部 review 抓的问题全部核实属实。第二轮的关键一条:补录通知在事务内发 Feign——handleDingUserSync@Transactional,事务内调远程一旦回滚会留孤儿通知,改成 runAfterCommit(TransactionSynchronization 惯用法)。

第三轮四条都值得单列:

  1. 全表唯一键会挡巡检合法双通知(P1)。巡检的”下发/超时”通知共用 (biz_type, biz_id, user_id) 属合法两条,全表唯一键会误伤,迁移 DELETE 还会误删历史。解法相当优雅——虚拟生成列
生成列唯一键(MySQL ≥ 5.7)
board_uniq_key = CASE WHEN 是公告行 THEN 业务键 ELSE NULL END  -- 虚拟生成列
UNIQUE KEY uk_mp_board_user (board_uniq_key, user_id)
-- 妙处:MySQL 唯一索引不约束 NULL,非公告行零影响,公告行精确防重
  1. listByCondition 缺 sourceType 过滤(P1):过滤条件只加在 listByPage,而物化查的是 listByCondition——本地公告会被误物化并发出”新的钉钉公告”站内信。补齐 + 契约测试守护。
  2. 补录筛选时机提前(P2):员工口径 SQL 依赖组织关系表,新员工的组织关系在事务后段才落库,事务内先查会把正常新员工过滤掉。此前 E2E 的”下次全量同步补齐”恰是兜底路径,掩盖了当场补录失效——查询挪进 afterCommit 后,mock 新员工当场补录实测通过。
  3. 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——合并别人代码后,启动一次是最便宜的验证。

结语

这一天从三层功能写到八个生产坑,最深的体会是:本地全绿只是入场券,生产环境有自己的题库。好在每个坑都被当场记录、当天修复、沉淀进清单——下一次上线,这张清单就是别人的避雷图。