首页
/ Metabase 通知后端深度解析:Notification 统一模型、投递管道与 GraalJS 渲染管线

Metabase 通知后端深度解析:Notification 统一模型、投递管道与 GraalJS 渲染管线

2026-09-05 21:58:58作者:齐添朝

本文以 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 的通知体系由三套相互配合的子系统构成:

  1. metabase.notification — 现代统一通知框架(Alert、卡片通知、系统事件通知);
  2. metabase.pulse — 遗留 Pulse 系统,目前主要承担 Dashboard 订阅(dashboard subscription);
  3. 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_typepayload_idactive
: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 风格条件检查”在源码中有两层实现:

  1. NotificationCardsend_conditionsrc/metabase/notification/models.clj 中定义为 #{:has_result :goal_above :goal_below})——卡片查询结果与目标线(goal line)比较,决定“是否发送”;默认 :send_condition :has_result:send_once false 在 insert 钩子中自动补齐。
  2. metabase.notification.conditionsrc/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! 依次完成:

  1. 孤儿 payload 防御:卡片通知若其 NotificationCard 记录已被级联删除,则删除该 notification 并抛错,避免触发无意义的查询执行;
  2. 收件人域名校验(企业版 allow-list);
  3. 执行 payloadnotification.payload/notification-payload 生成带上下文的 payload,随后 notification.payload/skip-reason 决定是否跳过(如发送条件不满足);
  4. 逐 handler 渲染与发送:对每个 handler 调用 channel/render-notification(多方法,按 [channel-type payload-type] 双键分派)得到消息序列,再对每条消息调用带重试的 channel-send-retrying!
  5. 任务历史与指标:全程包裹 task-history/with-task-history 与 analytics 指标(send-ok/send-error/channel-send-ok/channel-send-errorsend-duration-msconcurrent-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-errorsabort-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/

  • Emailimpl/email.clj 配合 channel/email.clj):SMTP 发送、HTML 渲染、内联图片(CID 引用)、CSV/XLSX 附件;消息构造集中在 channel/email/messages.clj,Handlebars 模板位于 src/metabase/channel/email/(如 dashboard_subscription.hbsnotification_card.hbsbroken_subscription_notification.hbscard_notification_archived.hbs 等 30 余个 .hbs 文件);
  • Slackimpl/slack.clj 配合 channel/slack.clj):Slack API 集成,含频道/用户缓存、token 管理、OAuth 流程;图表以文件上传方式投递;缓存由后台任务 src/metabase/channel/task/refresh_slack_channel_user_cache.clj 周期性刷新;
  • HTTP webhookimpl/http.clj):把通知 payload 投递到任意 HTTP 端点。

模板渲染基于 Handlebars(src/metabase/channel/template/core.cljhandlebars.cljhandlebars_helper.clj),深链回跳地址由 channel/urls.clj 生成。

4.1 新增一个投递通道的检查清单

专家文档给出的 7 步流程,对照源码结构可细化为:

  1. channel/impl/<channel>.clj 中实现 can-connect?render-notification(每种 payload 类型各一个方法)、send! 三组多方法;
  2. 处理认证(OAuth、API key 等),认证失败时抛出带 :error-type 的异常以参与重试判定(参考 Slack 的 :slack/invalid-token 模式);
  3. 按通道格式约束适配渲染输出(如 Slack 对附件大小的限制);
  4. 处理图片/附件投递与清理(Slack 上传的文件、邮件 CID 内联图都有生命周期问题);
  5. channel/settings.clj 体系中登记通道配置项;
  6. 接入 notificationpulse 两条发送管道(仪表盘订阅仍走 Pulse,不能只接 Notification 一侧);
  7. 用含大仪表盘(20+ 卡片意味着 20 次查询执行与 20 次图表渲染)的真实 payload 做压测。

对应的测试可参考 test/metabase/channel/ 下的 impl/render/email_test.cljslack_test.clj 等既有用例。

5. 渲染管线:从查询结果到 HTML/PNG

metabase.channel.rendersrc/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 与同一份已解析的 bundle Source:引擎随第一个上下文创建、随最后一个上下文关闭;每个上下文把共享 source 求值进自己的 realm。因此无论池扩到几个上下文,解析后的 bundle 只保留一份——“把池上限从 1 提到 2 或 3 只需改一行”;
  • 池的最小值为 0:空闲时收缩到 0 并关闭引擎(GraalVM 不会在 GC 时回收 context/engine),空闲后的首次渲染会重建;
  • 每次渲染独占一个上下文,因此同一上下文上的渲染是串行的。

沙箱强度从 create-context 可见:allowHostAccess HostAccess/NONE、类查找谓词恒返回 falseno-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,全部挂在一个持久化的 SendNotification Job 下;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 执行日志SendNotification Job 记录完整的 Quartz 上下文(scheduled fire time、实际 fire time、recovering、refire count、scheduler id),配合 task-history 可回答“某次触发为什么晚了/漏了”。

task-history 体系(src/metabase/task_history/)为每一次通知执行留存带计时、成败与输出的记录,是调试交付失败的第一站。

7. 遗留 Pulse 系统与迁移

metabase.pulsesrc/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-7sun→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)与源码结构,推荐的标准排障顺序是:

  1. 先分辨系统:是 metabase.notification 路径(Alert/卡片/系统事件)还是 metabase.pulse 路径(Dashboard 订阅)?notification.send/hydrate-notification:notification/dashboard 的分支处理是两条路径并存的直接证据;
  2. 沿管道定位:触发 → payload 执行 → 条件检查 → 通道渲染 → 发送,逐段核对 task history 记录(notification-triggernotification-sendchannel-send 三种 task 各有独立记录);
  3. 渲染问题单独隔离:缺图表、格式错误通常与投递无关,先用预览渲染(render/preview.clj)或 REPL 单独渲染该可视化,确认是否 GraalJS 上下文超时/内存问题;
  4. 检查外部服务:SMTP 日志、Slack API 返回码、webhook 超时;注意重试日志中 retry_errors 的累积报告与“不重试”的 warn 日志(无效 token / 频道不存在会直接终止重试);
  5. 调度问题看 Quartz:trigger 是否被创建(启动 diff 对账)、时区是否正确(报告时区 Setting)、misfire 是否补发。

代码质量方面,专家文档要求遵循 Metabase 的 Clojure 规范、为可靠性构建(重试、错误跟踪、优雅降级)、处理外部服务不可用(SMTP 宕机、Slack 限流需退避)、测试真实 payload、尽可能保证幂等。相关测试入口包括 test/metabase/notification/send_test.cljmodels_test.cljcondition_test.cljtest/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),都有清晰的代码入口与测试参照。

登录后查看全文
热门项目推荐
相关项目推荐