OnCall 时段路由:跨午夜、左闭右开与「Local 不是时区」

0 阅读6分钟

OnCall 时段路由:跨午夜、左闭右开与「Local 不是时区」

Alioth 技术笔记 · 第 09 篇 · 通知 / 值班 仓库:github.com/aide-family… | 在线演示:web.alioth.asia/

导语

值班表最容易出的问题不是"没通知",而是"通知错了人":交接时刻两个人同时收到、跨午夜的那一班谁都没收到、多副本部署后同一个用户被告知"你不在班"。这三个问题背后是同一件事——时间窗口的语义没定义清楚。Alioth 的 OnCall 用星期 + HH:mm 起止描述时段,规则只有一句话:左闭右开,跨午夜时星期归属开始那天,时区必须是 IANA 名称。这篇把这句话拆成代码。

一、窗口的表达

// Spec 是时间窗口的接口和 JSON 形态。Weekdays 取 MONDAY 到 SUNDAY,
// Start/End 取 HH:mm;Start 晚于 End 表示跨午夜。
type Spec struct {
    Weekdays []string `json:"weekdays"`
    Start    string   `json:"start"`
    End      string   `json:"end"`
}

保存时 Normalize 做四件事:

  • 星期按周一到周日排序、去重,["FRIDAY","MONDAY","MONDAY"] 变成 ["MONDAY","FRIDAY"],保证同一种配置存成同一种字节;
  • 时间补零,9:00 规范化成 09:00;
  • 拒绝 start == end——22:00-22:00 到底是全天还是零长度?这种歧义必须在保存时消灭,而不是在匹配时猜;
  • 重复窗口合并,单个通知组最多 20 个窗口。

没有窗口 = 全天候接收,这是"7×24 值班"的表达方式,不需要用户去填 00:00-23:59(那样还会漏掉 23:59 这一分钟)。

二、匹配:位图 + 一天中的分钟数

编译后每个窗口压成一个位图加两个整数:

type Window struct {
    weekdays uint8   // 周一到周日依次占一位
    start    int     // 一天中的分钟数
    end      int
}

匹配只有十行,但分两种形态:

func (window Window) match(at time.Time) bool {
    minute := at.Hour()*60 + at.Minute()
    day := weekdayBit(at.Weekday())
    if window.start < window.end {
        return window.weekdays&day != 0 && minute >= window.start && minute < window.end
    }
    // 跨午夜:开始那天的星期的晚间时段,或前一天星期的凌晨时段。
    if window.weekdays&day != 0 && minute >= window.start {
        return true
    }
    previous := weekdayBit(at.AddDate(0, 0, -1).Weekday())
    return window.weekdays&previous != 0 && minute < window.end
}

跨午夜分支是整套逻辑里最容易写错的地方。FRIDAY 22:00-06:00 的语义是"周五晚到周六凌晨",所以周六 03:00 属于这一班——它由 前一天是周五 推出,而不是"周六也在窗口里"。用 at.AddDate(0, 0, -1) 取前一天,比手写 (int(day)+6)%7 更难写错,也自动处理了周日回退到周六。

weekdayBit 里还有一处 Go 特有的坑:time.Weekday 的周日是 0,而位图按周一到周日排,所以周日要单独映射到第 6 位,其余减一。

三、为什么必须左闭右开

假设两个班次:22:00-06:00 和 06:00-22:00。如果结束边界是闭的(minute <= end),06:00 这一分钟同时属于两个班次,同一个人会收到两条、或者两个组都收到。左开右闭(minute >= start && minute < end)让 06:00 精确落在下一班,交接没有重叠也没有空档。

这个约定必须贯彻到所有地方,包括「下一个班次从什么时候开始」这类展示逻辑。窗口语义只要有一处写成闭区间,用户就会在某个整点看到重复通知,而这个 bug 一天只出现两次,极难复现。

四、时区:Local 必须被拒绝

时区以 IANA 名称保存(Asia/Shanghai、Europe/Berlin),空名回落到 Asia/Shanghai。有一个显式拒绝:

// Local 依赖进程所在主机的时区,多副本会各算各的;它不是 IANA 时区名,必须显式拒绝。
if strings.EqualFold(name, "Local") || strings.EqualFold(name, "localtime") {
    return nil, merr.Invalid("invalid on-call timezone")
}

这是多副本部署的经典陷阱:容器里 TZ 没设置时 Local 就是 UTC,于是同一个值班表在两个副本上算出不同的在班人员。存 IANA 名称、在匹配时用 time.LoadLocation 显式换算,才能让"在班"成为全局一致的判断。

匹配点还有两个细节:

now := time.Now()
for i, row := range matchedRows {
    location, err := oncall.LoadLocation(row.Timezone)
    if err != nil {
        // 保存时已经校验时区;历史脏数据只跳过该 OnCall,不能让事件写入失败。
        continue
    }
    at := now.In(location)
    ...
}
  • 同一次分发只取一次 now,所有 OnCall 用同一个时刻判断,避免跨分钟边界时前后不一致;
  • 脏时区只跳过该条,不抛错。值班表配置错了不该让整条告警事件写不进去——宁可少通知一个人,也不能让事件丢失。

五、与订阅合并:一次去重,取最长间隔

OnCall 是通知下的第二套路由,匹配条件与告警订阅相同。两边同时命中时,代码把它们合并成一组通知组:

// dispatchEventNotifications 把命中的订阅和 OnCall 合并成一组通知组后生成任务。
// 两套路由共用一次接收方去重;同一指纹在重复通知间隔内不再产生 FIRING 任务。

合并规则是"取最长":

maxInterval := 0
for _, source := range sources {
    if !sourceContributes(source, active) { continue }
    if source.Interval > maxInterval { maxInterval = source.Interval }
}

为什么是最大值而不是最小值?因为订阅和 OnCall 的间隔代表"这个渠道最多多久打扰一次"。取最小值会让两套配置里更激进的那个生效,用户明明把 OnCall 设成 30 分钟、订阅设成 5 分钟,结果每 5 分钟响一次——去重的意义就没了。

恢复通知走另一套判断:不受间隔限制,但只在真正发出过 FIRING 通知时才发。被间隔压掉、或者彻底失败的任务,恢复时不会补一条"已恢复"让人莫名其妙。

六、几个真实的坑

  1. 窗口存 JSON 文本。 通知组绑定上保存的是 time_windows JSON,匹配时 Unmarshal + Compile;解析失败返回 false(不在班),而不是"全天在班"——宁可漏判也不误判。
  2. 同组内重复绑定要去重。 一个 OnCall 绑定多个通知组时,seen map 防止同一个组被追加两次,否则收件人合并阶段要多做一次无用功。
  3. 轮换挂在绑定上。 每条绑定可以带 rotation_uid,在班判断通过后再交给值班轮换(rotation)决定具体是谁;时段和人员是两层,不要混在一张表里。
  4. 停用的组不参与间隔计算。 activeNotificationGroups 先过滤,停用的组既不投递也不影响 maxInterval,否则一个停用组的长间隔会把其他组的通知也压掉。

七、怎么验证

app/alioth/pkg/oncall/oncall_test.go 与 app/alioth/internal/biz/notification/my_oncall_group_window_test.go 覆盖了跨午夜、边界分钟、星期排序、非法时区;app/alioth/internal/biz/notification/oncall_test.go 覆盖了 start == end、25:00、非法星期等拒绝路径。手工验证:

git clone https://github.com/aide-family/moon.git && cd moon
make dev-up
open http://127.0.0.1:8080

配一个 FRIDAY 22:00-06:00 的窗口和两个班次相邻的时段,然后在 05:59、06:00 两个时刻各触发一次告警,看通知是否只落在正确的那个人身上。

结语

时段路由的正确性不取决于代码多少,而取决于三条约定有没有被贯彻到底:左闭右开决定交接是否重叠,跨午夜归属开始那天决定凌晨三点该叫谁,IANA 时区决定多副本是否算出同一个答案。这三条任何一条松动,值班表就会在最需要它的时候悄悄失效——而这种失效,通常要等到下一次半夜故障才被发现。