博客 / 詳情

返回

在 Go 中使用 cron 執行定時任務

首發公眾號地址:https://mp.weixin.qq.com/s/bCgGnw9QYTTBuoEktTYZcw

如果你曾經在 Go 中實現過定時任務,可能會發現,原生的 time.Timertime.Ticker 雖然簡單易用,但在複雜的場景下(如多任務調度、時區處理、任務失敗重試等)往往顯得力不從心。這時,一個功能強大且靈活的定時任務庫就顯得尤為重要。

github.com/robfig/cron 正是為此而生。它不僅支持標準的 crontab 表達式,還提供了秒級精度、時區設置、任務鏈(Job Wrappers)等高級功能,能夠輕鬆應對各種複雜的定時任務場景。

本文就來帶大家見識下 cron 的用法。

快速開始

我們可以使用如下命令在 Go 項目中安裝 cron

$ go get github.com/robfig/cron/v3@master

接着我們來一起快速入門 cron 的使用,示例如下:

https://github.com/jianghushinian/blog-go-example/tree/main/cron/main.go
package main

import (
    "fmt"
    "log"
    "time"

    "github.com/robfig/cron/v3"
)

// NOTE: 基礎用法

func main() {
    // 創建一個新的 Cron 實例
    c := cron.New(cron.WithSeconds())

    // 添加一個每秒執行的任務
    _, err := c.AddFunc("* * * * * *", func() {
        fmt.Println("每秒執行的任務:", time.Now().Format("2006-01-02 15:04:05"))
    })
    if err != nil {
        log.Fatalf("添加任務失敗: %v", err)
    }

    // 添加一個每 5 秒執行的任務
    _, err = c.AddFunc("*/5 * * * * *", func() {
        fmt.Println("每 5 秒執行的任務:", time.Now().Format("2006-01-02 15:04:05"))
    })
    if err != nil {
        log.Fatalf("添加任務失敗: %v", err)
    }

    // 啓動 Cron
    c.Start()
    defer c.Stop() // 確保程序退出時停止 Cron

    // 主程序等待 10 秒,以便觀察任務執行
    time.Sleep(10 * time.Second)
    fmt.Println("主程序結束")
}

這個示例展示了 cron 的基礎用法,註冊了兩個定時任務到 cron 中,並在程序啓動 10 秒後退出。

使用 cron.New 方法可以創建一個 cron 對象,cron.WithSeconds() 參數可以擴展 crontab 表達式語法支持到秒級別,語法規則不變。

cron 對象的 AddFunc 方法可以添加一個任務到 cron 中,它接收兩個參數,第一個字符串類型的參數用來定義 crontab 表達式,* * * * * * 表示每秒執行一次,第二個參數 func() 就是我們要添加的任務。這裏為 cron 對象添加了兩個任務。

cron 對象的 Start 方法內部會創建一個新的 goroutine 並啓動 cron 調度器,調度器可以控制所有註冊進來的任務的執行,在任務的執行計劃到期時運行任務。

cron 對象的 Stop 方法可以停止調度器。

執行上述示例,得到輸出如下:

$ go run main.go
每秒執行的任務: 2025-02-23 16:02:41
每秒執行的任務: 2025-02-23 16:02:42
每秒執行的任務: 2025-02-23 16:02:43
每秒執行的任務: 2025-02-23 16:02:44
每秒執行的任務: 2025-02-23 16:02:45
每 5 秒執行的任務: 2025-02-23 16:02:45
每秒執行的任務: 2025-02-23 16:02:46
每秒執行的任務: 2025-02-23 16:02:47
每秒執行的任務: 2025-02-23 16:02:48
每秒執行的任務: 2025-02-23 16:02:49
每秒執行的任務: 2025-02-23 16:02:50
每 5 秒執行的任務: 2025-02-23 16:02:50
主程序結束

可以發現,我們註冊到 cron 中的兩個任務都生效了,第一個任務每秒執行一次,第二個任務每 5 秒執行一次,10 秒後程序結束並退出。

進階用法

上面示例中,我們快速入門了 cron 的用法,下面我再將 cron 的常用功能進行進一步講解。

執行計劃時間格式

cron 包支持標準的 crontab 表達式,並且可以使用 cron.WithSeconds() 將其精度擴展到秒級別。

並且,cron 還支持另一種表達式:

@every <duration>

這是一種固定時間間隔的表達式,@every 是固定寫法,中間一個空格,然後是 <duration><duration> 可以是任意 time.ParseDuration 可解析的字符串。比如 @every 1h30m10s 表示任務每隔 1 小時 30 分 10 秒執行一次,@every 5s 表示任務每隔 5 秒執行一次。

任務對象

cron 包不僅支持註冊函數類型的任務,其實我們可以註冊任意自定義類型的任務,只需要實現 cron.Job 接口即可:

type Job interface {
    Run()
}

比如我們自定義了 Job 類型作為一個任務對象:

// Job 作業對象
type Job struct {
    name  string
    count int
}

func (j *Job) Run() {
    j.count++
    if j.count == 2 {
        panic("第 2 次執行觸發 panic")
    }
    if j.count == 4 {
        time.Sleep(6 * time.Second)
        log.Println("第 4 次執行耗時 6s")
    }
    fmt.Printf("%s: 每 5 秒執行的任務, count: %d\n", j.name, j.count)
}

可以使用如下方式,將其註冊到 cron 中:

var (
    spec = "@every 5s"
    job  = &Job{name: "江湖十年"}
)

c := cron.New(cron.WithSeconds())
c.AddJob(spec, job)

cron 對象的調度器會每隔 5 秒執行一次 job.Run 方法。

自定義日誌

cron 支持註冊自定義的日誌對象,這樣 cron 運行期間產生的日誌都會輸出到我們自定義的日誌中。

自定義日誌之需要實現 cron.Logger 接口即可:

type Logger interface {
    // Info logs routine messages about cron's operation.
    Info(msg string, keysAndValues ...interface{})
    // Error logs an error condition.
    Error(err error, msg string, keysAndValues ...interface{})
}

如下是我們自定義的日誌對象:

// 自定義 logger
type cronLogger struct{}

func newCronLogger() *cronLogger {
    return &cronLogger{}
}

// Info implements the cron.Logger interface.
func (l *cronLogger) Info(msg string, keysAndValues ...any) {
    slog.Info(msg, keysAndValues...)
}

// Error implements the cron.Logger interface.
func (l *cronLogger) Error(err error, msg string, keysAndValues ...any) {
    slog.Error(msg, append(keysAndValues, "err", err)...)
}

這裏為了演示,僅將日誌轉發給 slog 進行輸出,你可以定製更多高級功能。

可以這樣使用日誌對象:

// 創建自定義日誌對象
logger := &cronLogger{}

// 創建一個新的 Cron 實例
c := cron.New(
    cron.WithSeconds(),      // 增加秒解析
    cron.WithLogger(logger), // 自定義日誌
)

任務裝飾器

我們可以在初始化 cron 時,為任務定義一系列的裝飾器,這樣當有新的任務註冊進來,就能自動為其附加裝飾器的所有功能。

cron 為我們提供了幾個自帶的裝飾器:

  • cron.Recover:恢復任務執行過程中產生的 panic,不要讓 cron 調度器退出。
  • cron.DelayIfStillRunning:如果上一次任務還未完成,那麼延遲此次任務的執行時間,只有上一次任務執行完成後,才會執行下一次任務。
  • cron.SkipIfStillRunning:如果上一次任務還未完成,那麼此次此次任務的執行。

我們可以像這樣使用任務裝飾器:

// 創建自定義日誌對象
logger := &cronLogger{}

// 創建一個新的 Cron 實例
c := cron.New(
    cron.WithSeconds(),      // 增加秒解析
    cron.WithLogger(logger), // 自定義日誌
    cron.WithChain( // chain 是順序敏感的
        cron.SkipIfStillRunning(logger), // 如果作業仍在運行,則跳過此次運行
        cron.Recover(logger),            // 恢復 panic
    ),
)

cron.WithChain 會將所有裝飾器串聯起來,使其成為一條任務鏈,比如 cron.WithChain(m1, m2),那麼最終任務執行時會這樣調用:m1(m2(job))

我們也可以實現自定義的裝飾器,其函數定義格式為:func(cron.Job) cron.Job,你可以試着自己實現一個。

進階示例

學習了 cron 的進階使用,我們可以編寫一個示例,體驗下這些功能的用法:

https://github.com/jianghushinian/blog-go-example/tree/main/cron/main.go
// 自定義 logger
type cronLogger struct{}

func newCronLogger() *cronLogger {
    return &cronLogger{}
}

// Info implements the cron.Logger interface.
func (l *cronLogger) Info(msg string, keysAndValues ...any) {
    slog.Info(msg, keysAndValues...)
}

// Error implements the cron.Logger interface.
func (l *cronLogger) Error(err error, msg string, keysAndValues ...any) {
    slog.Error(msg, append(keysAndValues, "err", err)...)
}

// Job 作業對象
type Job struct {
    name  string
    count int
}

func (j *Job) Run() {
    j.count++
    if j.count == 2 {
        panic("第 2 次執行觸發 panic")
    }
    if j.count == 4 {
        time.Sleep(6 * time.Second)
        log.Println("第 4 次執行耗時 6s")
    }
    fmt.Printf("%s: 每 5 秒執行的任務, count: %d\n", j.name, j.count)
}

func main() {
    log.Println("cron start")
    // 創建自定義日誌對象
    logger := &cronLogger{}

    // 創建一個新的 Cron 實例
    c := cron.New(
        cron.WithSeconds(),      // 增加秒解析
        cron.WithLogger(logger), // 自定義日誌
        cron.WithChain( // chain 是順序敏感的
            cron.SkipIfStillRunning(logger), // 如果作業仍在運行,則跳過此次運行
            cron.Recover(logger),            // 恢復 panic
        ),
    )

    var (
        spec = "@every 5s"
        job  = &Job{name: "江湖十年"}
    )

    // 添加一個每 5 秒執行的任務
    id, err := c.AddJob(spec, job)
    if err != nil {
        log.Fatalf("添加任務失敗: %v", err)
    }
    log.Println("任務 ID:", id)

    // 啓動 Cron
    c.Start()
    defer c.Stop() // 確保程序退出時停止 Cron

    time.Sleep(34 * time.Second) // 確保 job 能執行 6 次
    c.Remove(id)                 // 從調度器中移除 job
    time.Sleep(10 * time.Second) // job 不會再次執行
    log.Println("cron done")
}

這個示例完整的展示了進階用法的幾項功能。

執行上述示例,得到輸出如下:

$ go run main.go
2025/02/23 16:03:47 cron start
2025/02/23 16:03:47 任務 ID: 1
2025/02/23 16:03:47 INFO start
2025/02/23 16:03:47 INFO schedule now=2025-02-23T16:03:47.035+08:00 entry=1 next=2025-02-23T16:03:52.000+08:00
2025/02/23 16:03:52 INFO wake now=2025-02-23T16:03:52.000+08:00
2025/02/23 16:03:52 INFO run now=2025-02-23T16:03:52.000+08:00 entry=1 next=2025-02-23T16:03:57.000+08:00
江湖十年: 每 5 秒執行的任務, count: 1
2025/02/23 16:03:57 INFO wake now=2025-02-23T16:03:57.001+08:00
2025/02/23 16:03:57 INFO run now=2025-02-23T16:03:57.001+08:00 entry=1 next=2025-02-23T16:04:02.000+08:00
2025/02/23 16:03:57 ERROR panic stack="..." err="第 2 次執行觸發 panic"
2025/02/23 16:04:02 INFO wake now=2025-02-23T16:04:02.000+08:00
2025/02/23 16:04:02 INFO run now=2025-02-23T16:04:02.000+08:00 entry=1 next=2025-02-23T16:04:07.000+08:00
江湖十年: 每 5 秒執行的任務, count: 3
2025/02/23 16:04:07 INFO wake now=2025-02-23T16:04:07.000+08:00
2025/02/23 16:04:07 INFO run now=2025-02-23T16:04:07.000+08:00 entry=1 next=2025-02-23T16:04:12.000+08:00
2025/02/23 16:04:12 INFO wake now=2025-02-23T16:04:12.000+08:00
2025/02/23 16:04:12 INFO run now=2025-02-23T16:04:12.000+08:00 entry=1 next=2025-02-23T16:04:17.000+08:00
2025/02/23 16:04:12 INFO skip
2025/02/23 16:04:13 第 4 次執行耗時 6s
江湖十年: 每 5 秒執行的任務, count: 4
2025/02/23 16:04:17 INFO wake now=2025-02-23T16:04:17.001+08:00
江湖十年: 每 5 秒執行的任務, count: 5
2025/02/23 16:04:17 INFO run now=2025-02-23T16:04:17.001+08:00 entry=1 next=2025-02-23T16:04:22.000+08:00
2025/02/23 16:04:21 INFO removed entry=1
2025/02/23 16:04:31 cron done

這裏日誌比較多,不過如果你耐心梳理一下,還是比較清晰的。

可以看到日誌中輸出了 第 2 次執行觸發 panic(日誌行省略了 stack 信息),程序並沒有退出,説明我們使用的 cron.Recover(logger) 裝飾器生效了。

並且,在打印 第 4 次執行耗時 6s 之前,上一行日誌輸出了 INFO skip,表示跳過了一次任務執行,説明 cron.SkipIfStillRunning(logger) 裝飾器生效了。

我們使用 c.Remove(id) 從調度器中移除了註冊的任務後,任務沒再執行。

好了,cron 的使用示例就講解到這裏。

我需要額外強調的一點是,其實 cron 項目存在一個 Bug,在作者的最後一次 commit 中被修復。不過作者並沒有為其打上新的 Tag,所以這也是為什麼安裝時我們需要指定 @master 來下載最新版本。

這個 Bug 修復如下:

image.png

如果你在安裝 cron 時沒有指定 @master,你可以嘗試修改下 cron.SkipIfStillRunningcron.Recover 的順序,來看看程序執行效果。

原理剖析

cron 的常見用法我們都講解完成了,如果你想更深入的瞭解 cron,那麼我這裏為你簡單梳理下 cron 的整體設計,方便你進一步學習。

首先,我為你畫了一張思維導圖,展示了 cron 對象提供的所有 exported 方法:

image.png

我們最需要關注的就是創建、啓動、添加任務(圖中寫為“作業”)和停止功能。

其中 Cron 是一個結構體,用於管理和調度註冊進來的任務,其定義如下:

// Cron 核心結構體,用於調度註冊進來的作業
// 記錄作業列表(entries),可以啓動、停止,並且檢查運行中的作業狀態
type Cron struct {
    entries   []*Entry          // 作業對象列表
    chain     Chain             // 裝飾器鏈
    stop      chan struct{}     // 停止信號
    add       chan *Entry       // Cron 運行時,增加作業的 channel
    remove    chan EntryID      // 移除指定 ID 作業的 channel
    snapshot  chan chan []Entry // 獲取當前作業列表快照的 channel
    running   bool              // 標識 Cron 是否正在運行
    logger    Logger            // 日誌對象,Cron 會將運行的日誌內容輸出到 logger
    runningMu sync.Mutex        // 當 Cron 運行時,保護併發操作的鎖
    location  *time.Location    // 本地時區,Cron 根據此時區計算任務執行計劃
    parser    ScheduleParser    // 任務執行計劃解析器
    nextID    EntryID           // 下一個要執行的作業 ID
    jobWaiter sync.WaitGroup    // 使用 wg 等待作業完成
}

所有添加到 Cron 中的任務都會保存在 entries 列表中。

Entry 結構體表示一個任務對象,定義如下:

// EntryID 標識 Cron 實例中的一個作業
type EntryID int

// Entry 作業實體對象,表示一個被註冊進 Cron 執行器中的作業
type Entry struct {
    // ID 是作業的唯一 ID,可用於查找快照或將其刪除
    ID EntryID
    // Schedule 作業的執行計劃,應該按照此計劃來執行作業
    Schedule Schedule
    // Next 下次運行作業的時間,如果 Cron 尚未啓動或無法滿足此作業的執行計劃,則為 zero time
    Next time.Time
    // Prev 是此作業的最後一次運行時間,如果從未運行,則為 zero time
    Prev time.Time
    // WrappedJob 作業裝飾器,為作業增加新的功能,會在 Schedule 被激活時運行
    WrappedJob Job
    // Job 提交到 Cron 中的作業
    Job Job
}

EntryID 就是我們調用 c.Remove(id) 時使用的 id

Cron 最核心的邏輯當然是調度邏輯,其功能主要由 for...select 實現:

for {
    select {
    case now = <-timer.C:
        // 運行下一次執行時間小於當前時間的所有作業
    case newEntry := <-c.add:
        // 有新的作業加入進來,加入調度
    case replyChan := <-c.snapshot:
        // 獲取當前作業列表
    case <-c.stop:
        // 停止調度
    case id := <-c.remove:
        // 移除指定 ID 的作業
}

這裏是調度器主要邏輯,for...select 能夠實現高效調度,並且結合 exported 方法中的互斥鎖,cron 能夠支持併發操作。

以上,就是 cron 的設計原理。當然其具體內部實現還需要你自行研究,你可以參考我寫好了中文註釋的源碼 https://github.com/jianghushinian/blog-go-example/tree/main/cron/sourcecode/cron。

總結

github.com/robfig/cron 解決了複雜場景下在 Go 中執行定時任務的需求。

cron 包非常強大,它擴展了 crontab 表達式,支持秒級精度。任務鏈的功能可以為任務附加功能,這個設計跟 Gin 框架的中間件非常相似,你可以類比學習。此外我認為 cron 包設計比較好的一點是支持自定義日誌包,這樣我們可以按照自己的方式收集 cron 的執行日誌。想要更深入的學習 cron 包,可以參考我畫的思維導圖進一步閲讀其源碼。

本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。

希望此文能對你有所啓發。

聯繫我

  • 公眾號:Go編程世界
  • 微信:jianghushinian
  • 郵箱:jianghushinian007@outlook.com
  • 博客:https://jianghushinian.cn
  • GitHub:https://github.com/jianghushinian
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.