iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Software Development

從 Laravel 到 Spring Boot:30 天打造縮網址服務系列 第 17 篇

Day17 - 排程任務 Spring Scheduler vs Laravel Scheduler(過期連結清理)

  • 分享至 

  • xImage
  •  

Laravel 的排程:靠系統 cron 叫醒

Laravel 11 之後,排程寫在 routes/console.php:

// routes/console.php
Schedule::call(function () {
    Link::where('expires_at', '<', now())->delete();
})->dailyAt('03:00');

但光寫這段不會有任何效果,還要在伺服器的 crontab 加上這一行:

* * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1

意思是「系統每分鐘執行一次 schedule:run」,schedule:run 再檢查「現在這一分鐘有沒有該執行的任務」,有就執行,執行完程序就結束。下一分鐘,系統 cron 再啟動一個新的 schedule:run。

會需要這樣設計,是因為 PHP 每個請求、每個指令都是跑完就結束的程序(Day02 提過),沒有一個一直活著的程序可以「記得時間到了要做什麼」,所以要借用作業系統的 cron 來叫醒它。

Spring Boot 的排程:跑在應用程式自己裡面

Spring Boot 是長駐程式,啟動之後就一直活著,自己就能記時間。所以排程不需要系統 cron,跟著應用程式一起啟動就好。

先在進入點加上 @EnableScheduling:

@SpringBootApplication
@ConfigurationPropertiesScan
@EnableCaching
@EnableScheduling
public class UrlShortenerApplication { ... }

然後在任何 Bean 的方法上加 @Scheduled:

@Component
class LinkMaintenanceTasks {

    @Scheduled(cron = "0 0 3 * * *")
    void cleanUpExpiredLinks() {
        ...
    }
}

這樣就完成了,不用碰 crontab,部署到 Raspberry Pi 時(Day28)也不用另外設定任何東西。@Scheduled 的方法必須回傳 void、而且不能有參數,因為是 Spring 定時呼叫它,沒有人會傳參數進來,也沒有人會拿它的回傳值。

Spring 的 cron 有 6 個欄位

Linux crontab 的 cron 是 5 個欄位,Spring 的是 6 個,多了最前面的「秒」:

Linux crontab:   分 時 日 月 星期
Spring:       秒 分 時 日 月 星期

所以「每天凌晨 3 點」:

寫法
Linux crontab 0 3 * * *
Spring 0 0 3 * * *

照 crontab 的習慣寫成 5 個欄位,應用程式會直接啟動失敗:

Cron expression must consist of 6 fields (found 5 in "0 3 * * *")

至少這個錯誤是啟動時就會發現的。Laravel 的 ->dailyAt('03:00') 這類方法比較好讀,Spring 也有 @daily、@hourly 這類簡寫可以用,但 @daily 只能表示「每天午夜」,要指定凌晨 3 點還是得寫完整的 cron。

不用 cron 的兩種寫法:fixedDelay 跟 fixedRate

如果只是想「每隔一段時間執行一次」,不需要 cron:

@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.MINUTES)   // 上一次「結束」後,等 1 分鐘再執行
@Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES)    // 每 1 分鐘執行一次,不管上一次執行多久

差別在任務執行時間比較長的時候:假設任務要跑 20 秒,fixedDelay 會是「跑 20 秒、等 60 秒、跑 20 秒⋯」,每次間隔 80 秒;fixedRate 則是固定每 60 秒開始一次。這個專案的任務都用 fixedDelay:寧願間隔久一點,也不要上一次還沒跑完就急著跑下一次。

任務一:清掉過期的短網址

短網址有 expires_at 欄位(Day08 建的)。Day15 的 RedirectTarget.isExpired() 已經會擋掉過期的短網址,所以過期的資料留在資料庫裡不會被錯誤導向,但會一直佔空間。每天凌晨清一次就好。

先在 LinkRepository 加一個批次刪除的方法:

public interface LinkRepository extends JpaRepository<Link, Long> {

    Optional<Link> findByCode(String code);
    boolean existsByCode(String code);

    @Modifying
    @Transactional
    @Query("DELETE FROM Link l WHERE l.expiresAt < :now")
    int deleteExpired(@Param("now") LocalDateTime now);
}

為什麼不用 Day07 學的方法名稱衍生查詢(例如 deleteByExpiresAtBefore)?因為 Spring Data JPA 的衍生刪除會先把符合條件的資料全部查出來,再一筆一筆刪除,過期的短網址有一萬筆,就是一次查詢加一萬次 DELETE。用 @Query 自己寫 JPQL,就是一句 DELETE ... WHERE,一次搞定。

@Modifying 告訴 Spring Data JPA 這是一個會修改資料的查詢(不是 SELECT);@Transactional 則是因為修改資料必須在交易裡進行:繼承來的 save()、deleteById() 這些方法 Spring Data JPA 已經幫忙加好交易了,但自己用 @Query 宣告的方法預設沒有交易,不加的話一執行就會丟出 TransactionRequiredException。

排程任務本身:

@Component
class LinkMaintenanceTasks {

    private static final Logger log = LoggerFactory.getLogger(LinkMaintenanceTasks.class);

    private final LinkRepository linkRepository;
    private final StringRedisTemplate redis;   // 任務二會用到

    LinkMaintenanceTasks(LinkRepository linkRepository, StringRedisTemplate redis) {
        this.linkRepository = linkRepository;
        this.redis = redis;
    }

    @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Taipei")
    void cleanUpExpiredLinks() {
        int deleted = linkRepository.deleteExpired(LocalDateTime.now());
        log.info("清除過期短網址 {} 筆", deleted);
    }
}

zone = "Asia/Taipei" 先照抄,原因在後面的雷點一細講。

快取需要跟著清嗎?

Day15 說過,刪除短網址時要用 @CacheEvict 清掉快取,不然會繼續導向。那批次刪除過期短網址時要不要清?

不太需要:被刪掉的都是已經過期的短網址,就算快取裡還留著,Controller 拿到之後也會被 isExpired() 擋下來回 404,跟刪掉的結果一樣。快取最多 10 分鐘後也會自己過期。

唯一的例外是:被刪掉的短碼空出來之後,Day14 的 CodeGenerator 剛好又隨機產生了同一個短碼,而舊的快取還沒過期,新的短網址就會在幾分鐘內被當成「已過期」。這需要「隨機碰撞」加上「剛好在那 10 分鐘內」兩個條件同時成立,機率低到可以接受,這裡就不額外處理。

任務二:把點擊數寫回資料庫(兌現 Day15)

Day15 把點擊數改成在 Redis 裡用 INCR 累加,key 是 clicks:{短碼},當時說「要定期寫回資料庫,留到 Day17」。現在來兌現。

流程是:

  1. 找出 Redis 裡所有 clicks:* 的 key
  2. 每個 key 讀出目前累積的次數,並且歸零
  3. 把次數加到資料庫的 click_count

步驟一:用 SCAN,不要用 KEYS

Redis 有一個 KEYS clicks:* 指令可以一次列出所有符合的 key,但在正式環境不要用它:Redis 是單執行緒的,KEYS 會一口氣掃完整個資料庫,key 很多的時候會卡住 Redis 好幾秒,這段時間所有快取、限流(Day16)的請求都要排隊等。

SCAN 則是分批掃描,每次只掃一小部分,中間其他指令可以插隊執行,不會卡住 Redis。

步驟二:讀取跟歸零要是同一個動作

直覺的寫法是先 GET 讀出次數、再 DEL 刪掉 key:

// ❌ 有漏洞
String count = redis.opsForValue().get(key);
redis.delete(key);

問題跟 Day14 講的 check-then-insert 是同一類:GET 跟 DEL 之間有一小段空檔,如果剛好有人點擊了短網址,Redis 執行了一次 INCR,這一次點擊就會被接下來的 DEL 一起刪掉,永遠不會被算到。

Redis 6.2 之後有 GETDEL 指令,讀取跟刪除是同一個原子操作,中間不會有其他指令插進來。刪掉之後如果又有新的點擊,INCR 會自動建立一個新的 key 從 1 開始算,下一輪再處理:

@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.MINUTES)
void flushClickCounts() {
    ScanOptions options = ScanOptions.scanOptions().match("clicks:*").count(100).build();

    try (Cursor<String> keys = redis.scan(options)) {
        while (keys.hasNext()) {
            String key = keys.next();
            String count = redis.opsForValue().getAndDelete(key);   // GETDEL
            if (count != null) {
                String code = key.substring("clicks:".length());
                linkRepository.addClicks(code, Long.parseLong(count));
            }
        }
    }
}

這個方法跟任務一放在同一個 LinkMaintenanceTasks 類別裡,redis 就是建構子注入的 StringRedisTemplate(Day15 用過)。Cursor 用 try-with-resources 包起來,是因為 SCAN 會佔用一個 Redis 連線,用完要關掉歸還。

步驟三:加到資料庫

@Modifying
@Transactional
@Query("UPDATE Link l SET l.clickCount = l.clickCount + :count WHERE l.code = :code")
int addClicks(@Param("code") String code, @Param("count") long count);

用 clickCount + :count 讓資料庫自己做加法,而不是先查出原本的值、在 Java 裡加完再存回去,理由跟前面一樣:讀跟寫之間不要留空檔。

如果短碼已經被任務一刪掉了,UPDATE 就是影響 0 筆,不會出錯,那些點擊數直接丟掉就好。

這個做法有一個取捨:如果 GETDEL 成功了、寫入資料庫卻失敗了(例如資料庫剛好斷線),這一批點擊數就遺失了。點擊數只是統計資料,偶爾少算幾次可以接受;如果是金額這類不能出錯的資料,就需要更嚴謹的做法,例如先把資料搬到另一個暫存的 key,確定寫入資料庫成功之後才刪除。

排程是在「沒人看著的時候」執行的

到這裡,兩個排程任務都寫好了,本機測試時把 cron 暫時改成每分鐘執行一次,log 都有正常印出來。

但排程任務跟 API 有一個很大的不同:API 出問題,呼叫的人馬上就會發現;排程任務則是在沒有人看著的時候執行的,凌晨 3 點的清理有沒有跑、跑在什麼時候、是不是準時,沒有人會知道,除非去翻 log。

所以排程最危險的問題,不是「執行失敗」(至少 log 會有錯誤訊息),而是「執行了,但時間不對」:沒有錯誤、沒有警告,任務照樣完成,只是不在你以為的時間。下面兩個雷點都是這一類。

雷點一:部署到 Docker 之後,凌晨 3 點變成早上 11 點

如果 @Scheduled 沒有加 zone:

@Scheduled(cron = "0 0 3 * * *")

cron 的時間是依照 JVM 的預設時區計算的。在台灣的筆電上開發,JVM 時區是 Asia/Taipei,凌晨 3 點準時執行,一切正常。

但部署到 Raspberry Pi 時,應用程式是跑在 Docker 容器裡的(Day27、28),而大部分的 Java Docker 映像檔預設時區是 UTC。UTC 的凌晨 3 點,是台灣時間的早上 11 點。

結果就是:本來特意排在半夜、沒人使用時才執行的清理任務,變成在白天執行,而且整個過程沒有任何錯誤訊息,log 裡的「清除過期短網址 N 筆」照樣每天出現,只是時間不對。如果是比較吃資源的任務(例如之後的報表統計),就會在白天影響正常使用的速度。

解法是在 @Scheduled 明確指定時區:

@Scheduled(cron = "0 0 3 * * *", zone = "Asia/Taipei")

這樣不管 JVM 的時區是什麼,都會照台灣時間的凌晨 3 點執行。Laravel 也有一樣的問題:排程預設照 config/app.php 的 timezone 計算,而它的預設值是 UTC,要用 ->timezone('Asia/Taipei') 或修改設定檔指定。

順帶一提,LocalDateTime.now() 也是依照 JVM 的預設時區計算的。任務一用 LocalDateTime.now() 判斷「現在」有沒有超過 expires_at,如果 JVM 跟寫入資料時的時區不一致,過期判斷就會差上 8 小時。Day28 部署的時候,會把容器的時區統一設定好。

雷點二:一個慢任務,拖住所有排程

現在有兩個排程任務:每天凌晨的清理,跟每分鐘的點擊數回寫。假設過期的短網址累積了很多,某天凌晨的清理花了 5 分鐘。這 5 分鐘裡,點擊數回寫會發生什麼事?

答案是:完全不會執行。

原因是 Spring Boot 預設只用一條執行緒來跑所有的 @Scheduled 任務。一條執行緒同一時間只能做一件事,清理任務還在跑,點擊數回寫就只能排隊,等清理跑完才輪得到它。整個過程同樣沒有任何錯誤訊息,只是點擊數晚了 5 分鐘才更新。

Laravel 的情況不太一樣:每分鐘系統 cron 都會啟動一個新的 schedule:run 程序,就算上一分鐘的任務還在跑,下一分鐘的任務也會在新的程序裡照常執行,不會被卡住。(同一個 schedule:run 裡的多個任務也是一個接一個執行的,所以 Laravel 也有 ->runInBackground() 讓任務在背景執行。)

Spring Boot 的解法是把排程的執行緒池調大:

# application.yml
spring:
  task:
    scheduling:
      pool:
        size: 2

有兩條執行緒,清理跟點擊數回寫就能同時進行,互不影響。

調大之後,同一個任務會不會自己跟自己重疊?不會。用 fixedDelay 的任務要等上一次結束才開始計算下一次;cron 任務在上一次還沒結束時,同一個任務也不會同時再跑一份。執行緒池調大只會讓不同的任務可以同時執行,這也是前面選擇 fixedDelay 的原因之一。

補充:多台伺服器的時候

最後補充一個這個專案暫時不會遇到的問題:如果應用程式同時跑了兩個實例(例如為了分散流量),兩個實例都有 @Scheduled,凌晨 3 點就會兩邊各執行一次。Laravel 用 ->onOneServer() 解決,Spring 沒有內建對應的功能,常見做法是用 ShedLock 這類函式庫,透過資料庫或 Redis 加鎖,確保同一時間只有一個實例執行。這個專案只會跑在一台 Raspberry Pi 上,先知道有這個問題就好。

明天 Day18 講非同步處理:@Async 跟訊息佇列的概念,對照 Laravel 的 Queue。


上一篇
Day16 - Rate Limiting 實作(防暴力產生短網址)
系列文
從 Laravel 到 Spring Boot:30 天打造縮網址服務 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言