OnCall 时段路由:跨午夜、左闭右开与「Local 不是时区」
Alioth 技术笔记 · 第 09 篇 · 通知 / 值班 仓库:github.com/aide-family... | 在线演示:web.alioth.asia/
导语
值班表最容易出的问题不是"没通知",而是"通知错了人":交接时刻两个人同时收到、跨午夜的那一班谁都没收到、多副本部署后同一个用户被告知"你不在班"。这三个问题背后是同一件事------时间窗口的语义没定义清楚。Alioth 的 OnCall 用星期 + HH:mm 起止描述时段,规则只有一句话:左闭右开,跨午夜时星期归属开始那天,时区必须是 IANA 名称。这篇把这句话拆成代码。
一、窗口的表达
go
// 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 这一分钟)。
二、匹配:位图 + 一天中的分钟数
编译后每个窗口压成一个位图加两个整数:
go
type Window struct {
weekdays uint8 // 周一到周日依次占一位
start int // 一天中的分钟数
end int
}
匹配只有十行,但分两种形态:
go
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。有一个显式拒绝:
go
// 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 显式换算,才能让"在班"成为全局一致的判断。
匹配点还有两个细节:
go
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 是通知下的第二套路由,匹配条件与告警订阅相同。两边同时命中时,代码把它们合并成一组通知组:
go
// dispatchEventNotifications 把命中的订阅和 OnCall 合并成一组通知组后生成任务。
// 两套路由共用一次接收方去重;同一指纹在重复通知间隔内不再产生 FIRING 任务。
合并规则是"取最长":
go
maxInterval := 0
for _, source := range sources {
if !sourceContributes(source, active) { continue }
if source.Interval > maxInterval { maxInterval = source.Interval }
}
为什么是最大值而不是最小值?因为订阅和 OnCall 的间隔代表"这个渠道最多多久打扰一次"。取最小值会让两套配置里更激进的那个生效,用户明明把 OnCall 设成 30 分钟、订阅设成 5 分钟,结果每 5 分钟响一次------去重的意义就没了。
恢复通知走另一套判断:不受间隔限制,但只在真正发出过 FIRING 通知时才发。被间隔压掉、或者彻底失败的任务,恢复时不会补一条"已恢复"让人莫名其妙。
六、几个真实的坑
- 窗口存 JSON 文本。 通知组绑定上保存的是
time_windowsJSON,匹配时Unmarshal + Compile;解析失败返回false(不在班),而不是"全天在班"------宁可漏判也不误判。 - 同组内重复绑定要去重。 一个 OnCall 绑定多个通知组时,
seenmap 防止同一个组被追加两次,否则收件人合并阶段要多做一次无用功。 - 轮换挂在绑定上。 每条绑定可以带
rotation_uid,在班判断通过后再交给值班轮换(rotation)决定具体是谁;时段和人员是两层,不要混在一张表里。 - 停用的组不参与间隔计算。
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、非法星期等拒绝路径。手工验证:
bash
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 时区决定多副本是否算出同一个答案。这三条任何一条松动,值班表就会在最需要它的时候悄悄失效------而这种失效,通常要等到下一次半夜故障才被发现。