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 是長駐程式,啟動之後就一直活著,自己就能記時間。所以排程不需要系統 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 定時呼叫它,沒有人會傳參數進來,也沒有人會拿它的回傳值。
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。
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 把點擊數改成在 Redis 裡用 INCR 累加,key 是 clicks:{短碼},當時說「要定期寫回資料庫,留到 Day17」。現在來兌現。
流程是:
clicks:* 的 keyclick_count
SCAN,不要用 KEYSRedis 有一個 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 會有錯誤訊息),而是「執行了,但時間不對」:沒有錯誤、沒有警告,任務照樣完成,只是不在你以為的時間。下面兩個雷點都是這一類。
如果 @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。