Posted on ::

从一个促销需求说起

业务上要支持这样的规则:

  • 每月 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:59false
周五 22:00:00true
周五 23:00:00true
周六 00:00:00true
周六 00:00:01false

两端都命中, 意味着相邻的两个时间段在交界点上会同时成立. 如果配了"今天 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=90DR=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 藏在实现里.

Table of Contents