从一个促销需求说起
业务上要支持这样的规则:
- 每月 29 日, 肉类半价
- 每周五晚上十点以后, 积分翻倍
一开始的想法是在数据库里建几个字段: 星期几, 从几点到几点, 每月第几天. 但很快就发现字段不够用 —— 下一条规则可能是"每月最后一个周五", 再下一条是"每季度第一天", 每加一种就要加字段, 或者加一张表.
盯着这些规则看了一会, 会发现它们和 crontab 长得很像. "每周五 22 点"就是 0 22 * * 5, "每月 29 日零点"就是 0 0 29 * *. crontab 本来就是为了描述"周期性地在什么时候"而设计的, 而且它已经存在几十年, 所有工程师都认识, 现成的解析库到处都是.
差的只有一点: crontab 描述的是时间点, 而我们需要的是时间段.
补上这一点其实很简单 —— 给 cron 表达式加一个"持续多久":
cron 表达式 + 时长 = 周期性的时间段cronrange
这个想法已经有人做了, 1set/cronrange. 表达式长这样:
DR=120; TZ=Asia/Tokyo; 0 22 * * 5分号隔开的三段:
DR=120是时长, 单位分钟;TZ=Asia/Tokyo是时区, 可选, 用 IANA 时区数据库里的名字;0 22 * * 5是 cron 表达式, 表示时间段的起点.
开头那两条规则就成了:
每月 29 日全天肉类半价 DR=1440; TZ=Asia/Tokyo; 0 0 29 * *
每周五 22 点起两小时双倍积分 DR=120; TZ=Asia/Tokyo; 0 22 * * 5好处很直接: 一条规则就是一个字符串. 数据库里一个 varchar 就存下了, 不用为了新规则改表结构; 走 JSON 也是一个字符串, 这个库实现了 MarshalJSON / UnmarshalJSON, 序列化的事情不用自己写.
时区那一段值得单独说一句. "每周五晚上十点"是谁的十点? 服务器所在时区, 还是门店所在时区? 这个问题在只有一个国家的时候可以糊弄过去, 有了第二个国家就必须回答. 表达式里带上时区, 等于强迫配置的人当场想清楚.
我们的用法是反过来的
这是我觉得最值得说的一点.
大部分 cron 库是为调度写的, 核心方法是"下一次是什么时候" —— 你注册一个任务, 库来负责在正确的时刻叫醒你. cronrange 里对应的是 NextOccurrences().
而我们要的是相反的方向: 给定一个时刻, 判断它落不落在某个时间段里. 一笔交易进来, 带着自己的时间戳, 要判断适用哪些规则. 对应的方法是 IsWithin().
cr, err := cronrange.ParseString("DR=120; TZ=Asia/Tokyo; 0 22 * * 5")
if cr.IsWithin(order.PaidAt) {
// 双倍积分
}注意这里传的是 order.PaidAt, 不是 time.Now(). 这一点很重要 —— 补单, 重算, 对账都要用历史时间. 如果判断依赖"现在", 那隔天重跑一遍得到的结果就和当时不一样, 在和钱有关的系统里这是不能接受的. 所以这类接口一定要能传入任意时刻, 而不是把 now 藏在里面.
同一个表达式, 调度器拿去往前推, 我们拿来往回查, 方向正好相反.
IsWithin 是怎么实现的
这里有个有意思的地方: cron.Schedule 只提供 Next(), 没有 Prev(). 你没法直接问"上一次是什么时候".
所以判断 t 在不在窗口里, 只能先往回退一个时长, 再从那里问"下一次是什么时候":
searchStart := t.Add(-(cr.duration + 1*time.Second - 1*time.Nanosecond))
rangeStart := cr.schedule.Next(searchStart)
rangeEnd := rangeStart.Add(cr.duration)
// 检查 rangeStart <= t <= rangeEnd
within = (rangeStart.Before(t) && rangeEnd.After(t)) || rangeStart.Equal(t) || rangeEnd.Equal(t)退一个 duration 是主体, 后面那个 + 1s - 1ns 是补偿: Next() 返回的是严格晚于给定时刻的下一次, 而且精度只到秒. 不多退这一点, 恰好卡在边界上的窗口会被漏掉.
另外要注意区间是闭的, 两端都算. 拿"每周五 22 点起两小时"实测一下:
| 时刻 | IsWithin |
|---|---|
| 周五 21:59:59 | false |
| 周五 22:00:00 | true |
| 周五 23:00:00 | true |
| 周六 00:00:00 | true |
| 周六 00:00:01 | false |
两端都命中, 意味着相邻的两个时间段在交界点上会同时成立. 如果配了"今天 0 点到 24 点"和"明天 0 点到 24 点"两条规则, 那么正好落在 00:00:00 的一笔交易两条都满足. 要么在业务层再判一次, 要么把时长设成差一秒.
一个必须提醒运营的坑
写完之后顺手把"每月 29 日"的未来几次打印出来看看:
[2022-01-29T00:00:00+09:00, 2022-01-30T00:00:00+09:00]
[2022-03-29T00:00:00+09:00, 2022-03-30T00:00:00+09:00]
[2022-04-29T00:00:00+09:00, 2022-04-30T00:00:00+09:00]
[2022-05-29T00:00:00+09:00, 2022-05-30T00:00:00+09:00]二月没有. 2022 年不是闰年, 没有 2 月 29 日, 所以这条规则在二月整月不生效.
这不是库的 bug, cron 本来就是这个语义. 但配规则的是运营, 他们说"每月 29 日"的时候, 默认每个月都有 29 日. 类似的还有 31 日, 一年里有四个月不会触发.
所以这里的做法是: 保存规则的时候, 把接下来 12 次的触发时间列出来给人确认一遍. 也就是说, NextOccurrences() 在我们的用法里不是用来调度的, 而是用来给人预览的. 一个本来给调度器用的方法, 换个场景成了配置界面上的校验工具.
加两件东西
用起来之后有两个地方不太够用, 于是在 fork 上改了改, 代码在 memwey/cronrange.
时长不该只有分钟
原来的构造函数是这样的:
func New(cronExpr, timeZone string, durationMin uint64) (*CronRange, error)"分钟"被写死在类型里了. 这带来两个问题: DR=90 到底是 90 分钟还是 90 秒, 只能翻文档才知道; 而且秒级的窗口 (比如秒杀) 根本表达不出来.
Go 里本来就有 time.Duration 这个类型, 单位是自带的, 没道理不用. 但 New 的签名不能改, 一改所有调用方都得跟着改. 所以新开一个:
func Create(cronExpr, timeZone string, duration time.Duration, cp cron.Parser) (*CronRange, error)cron 解析器要能换
robfig/cron 的解析行为是在构造 Parser 的时候定死的: 是五个字段还是六个字段 (带秒), 支不支持 @every 这类描述符. 而库里原本写死了一个包级的 parser.
不同项目对 cron 方言的要求并不一样, 尤其是需要秒级精度的时候. 与其在库里多加几个开关, 不如把 Parser 直接作为参数传进来, 让使用者自己决定.
不过光有 Create() 还不够. 这个库的用法前提是规则以字符串的形式存起来, 所以除了构造, 还得能读回来 —— 而 ParseString() 用的是写死的包级 parser. 只加 Create() 的话, 自定义 parser 就只能用一半: 造得出来, 存得进去, 读不回来.
String() = "DR=3s; TZ=Asia/Tokyo; */10 * * * * *"
ParseString(s) -> expected exactly 5 fields, found 6
ParseStringWithCronParser(s, sec) -> ok所以配套加了 ParseStringWithCronParser(), 把这个往返闭合上.
麻烦的是格式
真正花时间的是这一块.
表达式是要存起来的 —— 数据库里躺着一批, JSON 里传着一批. 加了 time.Duration 之后, DR=90 和 DR=2h 都必须能解析.
按说应该在格式里加个版本号, 但那样所有已经存下来的字符串都要迁移. 于是换个办法: 靠值本身的形状来区分. 纯数字是老格式 (分钟), 带单位是新格式.
if duration, err = time.ParseDuration(durStr); err == nil {
if duration > 0 {
version = Version2
}
}
if durMin, err = strconv.ParseUint(durStr, 10, 64); err != nil {
if version != Version2 {
break PL
}
err = nil
}先试新格式, 不行再退回老格式. 这两者正好不重叠: time.ParseDuration("90") 会因为缺单位而失败, strconv.ParseUint("1h30m") 也过不去, 不会有一个字符串两边都认.
反过来, String() 该输出哪一种? 用一个 version 字段记住这个实例当初是怎么来的:
New(..., 90).String() = "DR=90; TZ=Asia/Tokyo; 0 22 * * 5"
Create(..., 90*time.Minute).String() = "DR=1h30m0s; TZ=Asia/Tokyo; 0 22 * * 5"两者的 Duration() 完全相等, 只是打印出来不一样.
这么做是为了保证 round-trip: 从数据库读出来的老表达式, 解析之后再写回去, 还是原来的样子. 不会因为某天升级了一个库版本, 存量记录被悄悄改写了一遍 —— 那种事情在排查问题的时候非常难受, 因为你会怀疑是不是有人手动改过数据.
这么做的代价
内部状态泄漏到了输出行为上. 同样是 90 分钟, 只因为来源不同, String() 的结果就不一样. 对使用者来说这是个意外.
更干净的做法大概有两种: 解析的时候把原始文本记下来, String() 原样吐回去; 或者干脆规定 String() 永远输出新格式, 并接受"存量数据会被逐步改写"这件事. 但这两种都要动更多地方, 在一个 fork 上做改动, 我选了侵入最小的那个. 这个取舍现在看仍然成立, 只是应该在文档里写清楚, 而不是让人自己撞上.
顺带发现的一个问题
读解析代码的时候注意到, 未知的部分并不总是会报错:
"junk; DR=90; 0 0 1 1 *" -> 解析成功
"DR=90; junk; 0 0 1 1 *" -> 报错 unknown part: "junk"同样一个 junk, 放在 DR= 前面就被吃掉了, 放在后面才报错.
原因在上面那段代码里: DR= 这个分支把命名返回值 err 当中间变量用了. strconv.ParseUint 成功的时候会把 err 赋成 nil, 而前一轮循环里 default 分支设置的"未知部分"错误, 就这么被盖掉了.
// 有问题: err 是命名返回值, 这里被用来接临时结果
if durMin, err = strconv.ParseUint(durStr, 10, 64); err != nil {
// 应该用局部变量, 最后再决定 err 是什么
n, parseErr := strconv.ParseUint(durStr, 10, 64)这个问题在加 time.Duration 之前就有了, 只是现在多了一层试探, 更容易踩到. Go 的命名返回值本来就容易出这种事, 再加上 switch 各个分支共用同一个 err, 就更不容易看出来.
小结
crontab 的表达能力大概是被低估了. 它是一个已经存在几十年, 所有工程师都认识, 而且到处都有现成解析器的领域特定语言. 需要描述"周期性的时间"的时候, 先想想能不能用它, 通常比自己设计一套字段要好 —— 至少不用为了"每月最后一个周五"这种需求再改一次表.
它缺的那一块是时长, 补上就能表达时间段.
另外要留意方向. 大部分 cron 库是为调度写的, 关心的是"下一次"; 如果需求是"这个时刻算不算", 那要的是另一个方法, 而且必须能传入任意时刻, 不能把 now 藏在实现里.