Metabase 通知后端深度解析:Notification 统一模型、投递管道与 GraalJS 渲染管线
本文以 Metabase 仓库中的通知后端专家文档(.claude/agents/notifications-backend-expert.md)为主线,结合 src/metabase/notification/、src/metabase/channel/ 等真实源码,系统梳理 Metabase 通知系统的完整技术栈:统一 Notification 模型(payload + subscriptions + handlers)、遗留 Pulse 系统及其迁移路径、Email/Slack/HTTP 三类投递通道、基于 GraalJS 在 JVM 内渲染图表的管线,以及 Quartz 驱动的时区感知调度基础设施。读完后你能够独立定位“订阅邮件缺少图表”“通知集中触发压垮 SMTP”这类典型问题,并理解新增投递通道所需的完整改造面。
1. 通知系统的总体架构
Metabase 的通知体系由三套相互配合的子系统构成:
metabase.notification— 现代统一通知框架(Alert、卡片通知、系统事件通知);metabase.pulse— 遗留 Pulse 系统,目前主要承担 Dashboard 订阅(dashboard subscription);metabase.channel— 投递通道抽象层与渲染管线(邮件、Slack、HTTP webhook)。
src/metabase/notification/README.md 给出了最基础的数据流图:
+-------------------+
| Subscription | <- 何时触发(cron / 系统事件)
+-------------------+
|
+==============|================+
|| NOTIFICATION | |
|| Execute (payload) v | <- 执行查询,生成 payload
+===============================+
|
+==============|=================+
|| CHANNEL | |
|| Template Engine v | <- 渲染成通道消息
|| Destination v | <- 投递到收件人
+================================+
一个 Notification 由三个核心组件构成:
- Payload:要发送的实际数据内容;
- Handlers:决定 payload 如何渲染、投递到哪里(通道 + 可选模板 + 收件人列表);
- Subscriptions:决定何时发送(cron 调度或系统事件触发)。
README 中还给出了最小可运行的 REPL 示例(来自 src/metabase/notification/README.md):
(require '[metabase.notification.test-util :as notification.tu])
(require '[metabase.notification.core :as notification])
(notification.tu/with-card-notification
[notification {:card {:dataset_query (mt/mbql-query users)}
:subscriptions [{:type :notification-subscription/cron
:cron_schedule "0 0 0 * * ?"}]
:handlers [{:channel_type :channel/slack
:recipients [{:type :notification-recipient/raw-value
:details {:value "#general"}}]}
{:channel_type :channel/email
:recipients [{:type :notification-recipient/user
:user_id (mt/user->id :crowberto)}]}]}]
(notification/send-notification! notification :notification/sync? true))
这条语句创建了一个带 1 个 cron 订阅、2 个 handler(Slack + 邮件)的卡片通知并同步发送,是理解整个数据流的最佳入口。
2. 统一模型:Notification 的五张表
从 src/metabase/notification/models.clj 的源码结构看,Notification 实体被拆分为五张数据库表,全部通过 Toucan2 模型访问:
| 模型 | 表名 | 职责 |
|---|---|---|
:model/Notification |
notification |
主体,持有 payload_type、payload_id、active |
:model/NotificationSubscription |
notification_subscription |
触发条件(cron 或系统事件) |
:model/NotificationHandler |
notification_handler |
投递通道(+ 可选模板引用) |
:model/NotificationRecipient |
notification_recipient |
收件人 |
:model/NotificationCard |
notification_card |
卡片型 payload 的发送条件 |
2.1 Payload 类型
models.clj 中定义的合法 payload 类型集合为:
(def notification-types
#{:notification/system-event
:notification/dashboard
:notification/card
;; for testing only
:notification/testing})
每种 payload 的执行实现在 src/metabase/notification/payload/impl/ 下:card.clj(执行卡片查询)、dashboard.clj(执行仪表盘上所有卡片)、system_event.clj(系统事件)。src/metabase/notification/payload/core.clj 中的 payload 多方法按 :payload_type 分派执行,执行结果再被 notification-payload 函数装饰上 :creator(创建人姓名/邮箱)和 :context(应用名、logo、按钮样式等模板上下文)后交给各 handler。对于大型查询结果,payload 层提供临时存储(payload/temp_storage.clj)把结果落盘,避免在内存中搬运大结果集。
值得注意的是 send.clj 中的一处注释:“:notification/dashboard is still on pulse”——即仪表盘订阅目前仍走遗留 Pulse 代码路径(metabase.pulse.send),这与专家文档中“Dashboard 订阅是挂在仪表盘上的 Pulse”的描述一致。
2.2 Subscription 类型与 Quartz 联动
订阅支持两种类型(subscription-types):
:notification-subscription/cron:cron 表达式调度,由 Quartz 调度器管理,每个订阅对应一个独立的 trigger(详见第 6 节);:notification-subscription/system-event:系统事件触发(如评论创建、Slack 事件、transform 失败等),必须携带event_name。
模型层通过 Toucan2 的 insert/update/delete 钩子把模型变更与 Quartz trigger 同步:define-after-insert / define-before-update 调用 update-subscription-trigger!,删除时调用 delete-trigger-for-subscription!(均以 requiring-resolve 动态解析 src/metabase/notification/task/send.clj 以避免循环依赖)。另外,当 Notification 的 :active 字段变更时,会批量更新或删除其所有 cron 订阅对应的 trigger——这意味着停用一条通知会立即从调度器中摘除其触发器。
2.3 Recipient 类型
收件人有四种类型(notification-recipient-types):
:notification-recipient/user:具体用户(user_id);:notification-recipient/group:权限组(permissions_group_id);:notification-recipient/raw-value:原始值,如 Slack 频道名#general或外部邮箱地址(details.value);:notification-recipient/template:模板化收件人(details.pattern,可选is_optional),支持按结果行动态生成收件人。
details 字段通过 transform-encrypted-json 加密存储。企业版还在此处叠加了 subscription-allowed-domains 域名白名单校验(validate-raw-value-email-domain!),任何写入路径(包括未认证的取消订阅恢复端点)都无法绕过。
2.4 卡片发送条件(Alert 条件检查)
专家文档提到的“alert 风格条件检查”在源码中有两层实现:
NotificationCard的send_condition(src/metabase/notification/models.clj 中定义为#{:has_result :goal_above :goal_below})——卡片查询结果与目标线(goal line)比较,决定“是否发送”;默认:send_condition :has_result、:send_once false在 insert 钩子中自动补齐。metabase.notification.condition(src/metabase/notification/condition.clj)——一个通用的数组表达式求值器,支持and/or/not、比较运算符(= != > < >= <=)、context数据访问与count/min/max函数,例如:
["and"
[">", ["count", ["context", "rows"]], 0]
["=", ["context", "user_id"], 1]]
需要说明的是:该命名空间的文档字符串表明它“原本为 notification conditions 开发,目前保留给未来使用”。从源码结构看,当前线上 Alert 的实际条件判断主要依赖 send_condition 枚举值;条件表达式求值器是为此保留的可复用基础设施。理解这一点有助于在排查“条件不生效”时正确定位代码路径。
3. 发送管道:重试、优先级队列与并发控制
src/metabase/notification/send.clj 是整条投递管道的核心。入口函数 send-notification! 接受 :notification/sync? 选项,同步路径直接调用 send-notification-sync!,异步路径则进入 dispatcher 队列。
3.1 同步发送的主流程
send-notification-sync! 依次完成:
- 孤儿 payload 防御:卡片通知若其
NotificationCard记录已被级联删除,则删除该 notification 并抛错,避免触发无意义的查询执行; - 收件人域名校验(企业版 allow-list);
- 执行 payload:
notification.payload/notification-payload生成带上下文的 payload,随后notification.payload/skip-reason决定是否跳过(如发送条件不满足); - 逐 handler 渲染与发送:对每个 handler 调用
channel/render-notification(多方法,按[channel-type payload-type]双键分派)得到消息序列,再对每条消息调用带重试的channel-send-retrying!; - 任务历史与指标:全程包裹
task-history/with-task-history与 analytics 指标(send-ok/send-error/channel-send-ok/channel-send-error、send-duration-ms、concurrent-tasks等)。
3.2 通道发送重试策略
channel-send-retrying! 使用 metabase.util.retry 实现指数退避重试,默认配置(default-retry-config)为:
{:max-retries (if config/is-dev? 1 6) ;; dev 环境 1 次,生产 6 次
:initial-interval-millis 500
:multiplier 2.0
:jitter-factor 0.1
:max-interval-millis 30000}
两个值得注意的细节:
- 不可重试错误:
:slack/invalid-token与:slack/channel-not-found被明确列入unretriable-errors,abort-if会立即终止重试——无效 token 重试 6 次毫无意义; - 重试报告落盘:每次重试的错误消息与时间戳累积进
retry_errors,最终合并进 task history 的task_details,这是排查“Slack 间歇性上传失败”的关键审计数据。
3.3 双 Dispatcher:去重优先级队列 vs 简单阻塞队列
异步发送时,dispatch! 按 payload 类型选择两个不同的线程池 dispatcher:
dedup-priority-dispatcher(卡片/仪表盘通知):底层是DedupPriorityQueue——一个按 deadline 排序、按notification id去重的线程安全优先级队列。同一 id 的通知重复入队时只保留最新版本(最新的 creator、active 状态、handler 信息更可靠)。deadline 由subscription->deadline依据 cron 频率计算:平均触发间隔小于 1 分钟的订阅只给 5 秒宽限,小于 5 分钟的给 10 秒,小于 30 分钟给 15 秒,小于 1 小时给 30 秒,其余给 60 秒——触发频率越高的通知,越容易被判定“过期”而被更新版本顶替,避免过期的高频订阅占住工作线程;simple-blocking-dispatcher(系统事件通知):普通ArrayBlockingQueue(容量 1000),FIFO 处理。
线程池大小由 src/metabase/notification/settings.clj 中的 Setting 控制:
| Setting | 默认值 | 说明 |
|---|---|---|
notification-thread-pool-size |
3 | 常规通知发送线程数;官方文档建议:若长查询堵死通知队列导致 Alert 停发,可尝试调大此值 |
notification-system-event-thread-pool-size |
5 | 系统事件通知线程数 |
notification-temp-file-size-max-bytes |
10485760(10 MiB) | payload 落盘临时文件的最大字节数,设 0 可禁用限制 |
此外 send-notification! 会记录 :notification/triggered-at-ns 元数据,同步发送前若触发到实际执行之间产生了等待,会通过 wait-duration-ms 指标上报——这是观察“Quartz 触发 → 队列消化”延迟的直接手段。
4. 通道层:可插拔的投递抽象
src/metabase/channel/core.clj 定义了通道协议的三个多方法(命名空间注释标明“API 仍在开发中,可能变更”):
(defmulti can-connect?
"检查能否用 details 连接到 channel-type;失败时返回/抛出
{:errors {字段 错误信息}} 以便 UI 展示字段级错误。"
(fn [channel-type _details] channel-type))
(defmulti render-notification
"给定 notification payload,返回该 handler 的消息序列;
消息格式必须与 send! 期望的格式一致。"
(fn [channel-type notification-payload _handler]
[channel-type (:payload-type notification-payload)]))
(defmulti send!
"向通道发送一条消息。"
(fn [channel _message] (:type channel)))
render-notification 用 [channel-type payload-type] 二元组分派,意味着每个通道都要针对每种 payload 类型实现各自的渲染。现有实现在 src/metabase/channel/impl/:
- Email(
impl/email.clj配合channel/email.clj):SMTP 发送、HTML 渲染、内联图片(CID 引用)、CSV/XLSX 附件;消息构造集中在channel/email/messages.clj,Handlebars 模板位于 src/metabase/channel/email/(如dashboard_subscription.hbs、notification_card.hbs、broken_subscription_notification.hbs、card_notification_archived.hbs等 30 余个.hbs文件); - Slack(
impl/slack.clj配合channel/slack.clj):Slack API 集成,含频道/用户缓存、token 管理、OAuth 流程;图表以文件上传方式投递;缓存由后台任务 src/metabase/channel/task/refresh_slack_channel_user_cache.clj 周期性刷新; - HTTP webhook(
impl/http.clj):把通知 payload 投递到任意 HTTP 端点。
模板渲染基于 Handlebars(src/metabase/channel/template/:core.clj、handlebars.clj、handlebars_helper.clj),深链回跳地址由 channel/urls.clj 生成。
4.1 新增一个投递通道的检查清单
专家文档给出的 7 步流程,对照源码结构可细化为:
- 在
channel/impl/<channel>.clj中实现can-connect?、render-notification(每种 payload 类型各一个方法)、send!三组多方法; - 处理认证(OAuth、API key 等),认证失败时抛出带
:error-type的异常以参与重试判定(参考 Slack 的:slack/invalid-token模式); - 按通道格式约束适配渲染输出(如 Slack 对附件大小的限制);
- 处理图片/附件投递与清理(Slack 上传的文件、邮件 CID 内联图都有生命周期问题);
- 在
channel/settings.clj体系中登记通道配置项; - 接入
notification与pulse两条发送管道(仪表盘订阅仍走 Pulse,不能只接 Notification 一侧); - 用含大仪表盘(20+ 卡片意味着 20 次查询执行与 20 次图表渲染)的真实 payload 做压测。
对应的测试可参考 test/metabase/channel/ 下的 impl/、render/、email_test.clj、slack_test.clj 等既有用例。
5. 渲染管线:从查询结果到 HTML/PNG
metabase.channel.render(src/metabase/channel/render/)负责把查询结果转换为可视化输出,是“邮件里图表丢失”类问题的第一排查现场:
render/body.clj:按可视化类型(表格、柱状图、折线图、标量、进度条、漏斗、地图等)分派到 HTML 或图片渲染;render/table.clj+table_data.clj:结果集 → 带样式的 HTML 表格,含列格式化、截断、行数上限;render/js/:GraalJS 图表渲染,JVM 内执行与浏览器相同的 static-viz JS 代码,产出 SVG 再栅格化为 PNG;render/image_bundle.clj+render/png.clj:为邮件内嵌与 Slack 上传准备图表图片包;render/preview.clj:通知配置 UI 的预览渲染;render/style.clj:渲染输出的 CSS 与样式。
5.1 GraalJS 沙箱:池化上下文与共享引擎
src/metabase/channel/render/js/graal.clj 是该管线技术上最有趣的部分。其命名空间文档精确描述了当前设计:
- 在 JVM 进程内运行 static-viz JS,使用最多 3 个沙箱化 GraalVM 上下文的池(dirigiste Pool 管理);
- 在标准 JDK 上以解释模式运行(不启用 Graal 编译器),并通过引擎级选项
engine.WarnInterpreterOnly=false静默警告; - 池中所有上下文共享同一个
Engine与同一份已解析的 bundleSource:引擎随第一个上下文创建、随最后一个上下文关闭;每个上下文把共享 source 求值进自己的 realm。因此无论池扩到几个上下文,解析后的 bundle 只保留一份——“把池上限从 1 提到 2 或 3 只需改一行”; - 池的最小值为 0:空闲时收缩到 0 并关闭引擎(GraalVM 不会在 GC 时回收 context/engine),空闲后的首次渲染会重建;
- 每次渲染独占一个上下文,因此同一上下文上的渲染是串行的。
沙箱强度从 create-context 可见:allowHostAccess HostAccess/NONE、类查找谓词恒返回 false(no-host-class-lookup)、allowIO false——JS 无法触碰宿主类、文件系统,所有数据必须以 JSON 字符串传入并在 JS 侧解析。这解释了专家文档的告诫:GraalJS 是渲染瓶颈——复杂可视化可能超时或内存吃紧,图表渲染失败往往是 JS 上下文问题而非投递问题,应先在 render/preview.clj 层面隔离测试渲染。
6. 调度基础设施:Quartz Trigger 与时区感知
src/metabase/notification/task/send.clj 展示了 Quartz 集成细节:
- Trigger 命名约定:每个 cron 订阅对应 key 为
metabase.task.notification.trigger.subscription.<id>的 CronTrigger,全部挂在一个持久化的SendNotificationJob 下;Job 通过DisallowConcurrentExecution语义与job-data中的subscription-id传递上下文; - 时区:
send-notification-timezone按优先级取driver/report-timezone(报告时区 Setting)→ 系统时区 →UTC。报告时区变更时,update-send-notification-triggers-timezone!会遍历全部 trigger 并对时区不一致者reschedule-trigger!(对应events/report_timezone_updated.clj事件)。这正对应专家文档强调的“通知必须在用户时区的正确时刻触发,而不是服务器时区”; - Misfire 策略:
with-misfire-handling-instruction-fire-and-proceed——即使上次触发错过了(如实例重启窗口),也要补发一次再继续正常调度; - Trigger 生命周期管理:
update-subscription-trigger!按“类型变更 → 删除;非 cron → 忽略;不存在 → 创建;cron 表达式变化 → 删除后重建”的顺序收敛,模型层的增删改钩子与它联动; - 启动期自愈:
InitNotificationTriggers任务在每次实例启动时运行init-send-notification-triggers!,对“现有 trigger 集合”与“数据库中 active 的 cron 订阅集合”做 diff,删除多余 trigger、补建缺失 trigger。其文档字符串解释了背景:Alert 从 Pulse 迁移到 Notification 之后(v53.2024-12-12T08:05:00迁移),需要在启动时为存量订阅补齐 trigger,而由于无法保证“只运行一次”的迁移,只好每次启动都跑一遍对账; - Job 执行日志:
SendNotificationJob 记录完整的 Quartz 上下文(scheduled fire time、实际 fire time、recovering、refire count、scheduler id),配合task-history可回答“某次触发为什么晚了/漏了”。
task-history 体系(src/metabase/task_history/)为每一次通知执行留存带计时、成败与输出的记录,是调试交付失败的第一站。
7. 遗留 Pulse 系统与迁移
metabase.pulse(src/metabase/pulse/)是通知系统的前身:
- Pulse 模型(
pulse/models/):卡片的定时通道投递;Dashboard 订阅即挂在仪表盘上的 Pulse; pulse/send.clj+task/:按调度执行 Pulse 的发送管道;- 迁移:src/metabase/app_db/custom_migrations/pulse_to_notification.clj 将遗留 Pulse 转换为 Notification 记录。从该文件源码看,迁移的核心难点之一是调度表达式的转换:Pulse 的旧式调度以
{seconds minutes hours day-of-month month day-of-week year}键值对 +frame(first/last + 星期)结构存储,迁移逻辑负责把星期名映射为 Quartz cron 的1-7(sun→1 … sat→7),并支持“每月第一个周一”(1#1)、“每月最后一个周五”(6L)这类 cron 惯用法。
专家文档提醒的迁移边界案例在源码中得到印证:多通道 Pulse、每通道独立 schedule、特殊收件人配置都需要逐一映射为 handler + recipient 结构;同时如第 2 节所述,send-notification-sync! 对“Pulse 转换来的通知”(携带 :payload 而非 :payload_id)有专门的孤儿 payload 判定豁免。产品文档侧,Dashboard 订阅见 docs/dashboards/subscriptions.md,Alert 见 docs/questions/alerts.md。
8. 调试路径与工程实践
综合专家文档的调查方法(Investigation Approach)与源码结构,推荐的标准排障顺序是:
- 先分辨系统:是
metabase.notification路径(Alert/卡片/系统事件)还是metabase.pulse路径(Dashboard 订阅)?notification.send/hydrate-notification对:notification/dashboard的分支处理是两条路径并存的直接证据; - 沿管道定位:触发 → payload 执行 → 条件检查 → 通道渲染 → 发送,逐段核对 task history 记录(
notification-trigger、notification-send、channel-send三种 task 各有独立记录); - 渲染问题单独隔离:缺图表、格式错误通常与投递无关,先用预览渲染(
render/preview.clj)或 REPL 单独渲染该可视化,确认是否 GraalJS 上下文超时/内存问题; - 检查外部服务:SMTP 日志、Slack API 返回码、webhook 超时;注意重试日志中
retry_errors的累积报告与“不重试”的 warn 日志(无效 token / 频道不存在会直接终止重试); - 调度问题看 Quartz:trigger 是否被创建(启动 diff 对账)、时区是否正确(报告时区 Setting)、misfire 是否补发。
代码质量方面,专家文档要求遵循 Metabase 的 Clojure 规范、为可靠性构建(重试、错误跟踪、优雅降级)、处理外部服务不可用(SMTP 宕机、Slack 限流需退避)、测试真实 payload、尽可能保证幂等。相关测试入口包括 test/metabase/notification/send_test.clj、models_test.clj、condition_test.clj 与 test/metabase/channel/ 下的通道与渲染用例;仓库 README 与 docs/developers-guide/devenv.md 描述了开发环境搭建方式,可用于在本地 REPL 中复现 payload 执行、单图渲染与 Quartz trigger 状态检查。
9. 已知坑位清单(Important Caveats)
专家文档末尾列出的“重要注意事项”几乎都能在源码中找到对应实现,可作为设计评审时的对照清单:
| 坑位 | 源码对应 |
|---|---|
| GraalJS 渲染慢、吃内存,复杂可视化可能超时 | src/metabase/channel/render/js/graal.clj 的池化/解释模式设计 |
| 邮件 HTML 需要“回到 1990 年代”:Outlook/Gmail/Apple Mail 渲染各异,必须内联 CSS、用 table 做布局 | src/metabase/channel/email/ 各 .hbs 模板 |
| Slack 批量发送会撞 API 限流,需要正确退避 | channel-send-retrying! 的指数退避 + 不可重试错误短路 |
| Pulse → Notification 迁移的边界案例(多通道、分通道 schedule、异常收件人) | src/metabase/app_db/custom_migrations/pulse_to_notification.clj |
| 时区感知调度必须用用户/报告时区而非服务器时区 | send-notification-timezone 与 trigger 时区重建逻辑 |
| 大仪表盘订阅 = N 次查询 + N 次图表渲染,资源密集 | 去重优先级队列 + 频率化 deadline 设计 |
| 图表图片生命周期:Slack 上传文件需清理,邮件内联图依赖 CID 引用 | render/image_bundle.clj 与 email 附件逻辑 |
小结
Metabase 通知后端是一套典型的“模型层(Toucan2 多表 + malli 校验)→ 调度层(Quartz per-subscription trigger)→ 管道层(去重优先级队列 + 指数退避重试 + task history 全量审计)→ 通道层(三多方法协议 + 分通道渲染)→ 渲染层(Handlebars 模板 + GraalJS 沙箱图表渲染)”的分层架构。其工程亮点在于:per-subscription 的 Quartz trigger 让调度变更与模型写操作强一致;按 cron 频率动态计算的 deadline 与按 id 去重的优先级队列,使高频订阅不会饿死彼此;GraalJS 共享 Engine + 池化 Context 的设计在内存与并发渲染之间取得了平衡。理解这套结构后,无论是修复“邮件缺图”、改造 Slack 通道,还是设计新的投递渠道(如 Microsoft Teams),都有清晰的代码入口与测试参照。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00