Day12 說「重定向 GET /{code} 不在 /api/** 底下,所以不會被記錄」,Day13 說「重定向公開、管理 API 要認證」,Day14 結尾說「每次重定向都要查一次資料庫」。回頭一看才發現:這支 API 其實從來沒寫過。Day09 寫的 GET /api/links/{code} 是回傳短網址資訊的管理 API,不是真正把使用者導走的那一支。
縮網址服務最核心的功能就是重定向,今天要快取的也是它,所以先把它補上。
Laravel 的寫法:
// routes/web.php
Route::get('/{code}', function (string $code) {
$link = Link::where('code', $code)->firstOrFail();
return redirect()->away($link->original_url);
})->where('code', '[0-9A-Za-z]{4,16}');
Spring 的寫法:
@RestController
class RedirectController {
private final LinkRepository linkRepository;
RedirectController(LinkRepository linkRepository) {
this.linkRepository = linkRepository;
}
@GetMapping("/{code:[0-9A-Za-z]{4,16}}")
ResponseEntity<Void> redirect(@PathVariable String code) {
Link link = linkRepository.findByCode(code)
.orElseThrow(() -> new LinkNotFoundException(code));
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(link.getOriginalUrl()))
.build();
}
}
兩個細節:
{code:[0-9A-Za-z]{4,16}} 限定只有 base62 字元、長度 4 到 16 的路徑才會進來,跟 Day14 的 base62 字元集、Day11 ShortenerProperties 的 @Min(4) @Max(16) 對齊。/favicon.ico 這種瀏覽器自動發出的請求有 .,不會被當成短碼拿去查資料庫。效果跟 Laravel 的 ->where('code', ...) 一樣LinkNotFoundException:交給 GlobalExceptionHandler 回統一格式的 404先暫時直接用 LinkRepository,等一下加快取的時候會搬進 Service。
HTTP 的重定向狀態碼有兩個常見的選擇:
301 看起來比較省伺服器資源,但對縮網址服務來說有兩個問題:
所以這裡用 302。至於「每次都要問伺服器」造成的壓力,正是今天要用快取解決的問題。Laravel 的 redirect()->away() 預設也是 302。
Cache::remember()Laravel 加快取非常直覺:
$url = Cache::remember("link:{$code}", now()->addMinutes(10), function () use ($code) {
return Link::where('code', $code)->value('original_url');
});
意思是「link:abc123 這個 key 有快取就直接拿,沒有就執行閉包、把結果存 10 分鐘」。這種「先查快取,沒有再查資料庫、順便存進快取」的模式叫 Cache-Aside。
換成 Redis 也只要改 .env:
CACHE_STORE=redis
程式碼一行都不用動。Cache facade 背後是一層抽象,file、database、redis 都只是不同的實作。
@CacheableSpring 的設計思路一模一樣:有一層快取抽象(CacheManager),底層可以換成記憶體、Redis、Caffeine 等等實作。差別在使用方式:Laravel 是呼叫 Cache::remember() 包住一段閉包,Spring 是在方法上貼註解。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
# application.yml
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
cache:
type: redis
cache-names: links
redis:
time-to-live: 10m
spring.cache.type: redis 就是 Laravel 的 CACHE_STORE=redis。Redis 的連線資訊照 Day11 的慣例,用環境變數加預設值,部署到 Raspberry Pi 時(Day28)再由 Docker Compose 注入。
最後在進入點加上 @EnableCaching 開啟快取功能:
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableCaching
public class UrlShortenerApplication { ... }
本機開發用 Docker 起一個 Redis 就好:
docker run -d --name redis -p 6379:6379 redis:8
@Cacheable@Service
class RedirectService {
private final LinkRepository linkRepository;
RedirectService(LinkRepository linkRepository) {
this.linkRepository = linkRepository;
}
@Cacheable(cacheNames = "links", unless = "#result == null")
public Optional<RedirectTarget> resolve(String code) {
return linkRepository.findByCode(code)
.map(link -> new RedirectTarget(link.getOriginalUrl(), link.getExpiresAt()));
}
}
record RedirectTarget(String originalUrl, LocalDateTime expiresAt) implements Serializable {
boolean isExpired() {
return expiresAt != null && expiresAt.isBefore(LocalDateTime.now());
}
}
對照 Laravel:
Laravel Cache::remember() |
Spring @Cacheable |
|---|---|
key 自己組:"link:{$code}" |
預設用方法參數當 key,存進 Redis 是 links::abc123 |
| TTL 寫在呼叫的地方 | TTL 寫在設定檔(time-to-live: 10m) |
| 快取的範圍是閉包 | 快取的範圍是整個方法 |
為什麼快取的是 RedirectTarget,不是直接快取 Link Entity?跟 Day09「不要讓 Controller 直接回傳 Entity」是同一個道理:Entity 帶著 JPA 管理的狀態,不適合離開資料庫這一層到處跑。重定向只需要目標網址跟過期時間,就只存這兩個欄位,快取的內容越小越好。
過期時間也要一起存,因為過期檢查不能只靠快取的 TTL:一個 5 分鐘後就過期的短網址,如果剛好被快取了 10 分鐘,後面那 5 分鐘就會繼續導向。所以每次從快取拿到之後都要再檢查一次 isExpired()。
RedirectTarget 後面的 implements Serializable 也不能省。Redis 只能存字串或位元組,物件要先序列化才能存進去,而 Spring 的 Redis 快取預設用 Java 內建的序列化機制,只有實作了 Serializable 的類別才能被序列化,拿掉的話第一次訪問就會直接 500。Laravel 沒有這個問題,Cache::put() 底層用 PHP 的 serialize(),幾乎任何物件都能直接丟進去,不需要事先宣告。又是一個 Java「要先講清楚」、PHP「先做再說」的例子。
unless = "#result == null" 則是在說「查不到的結果不要快取」。resolve() 回傳的是 Optional,Spring 的快取會自動把它拆開,存裡面真正的值,#result 指的就是拆開後的值;而 Spring 的 Redis 快取預設連 null 也會存。如果把「查不到」也快取起來,有人剛好訪問了一個還不存在的短碼,下一秒 Day14 的 CodeGenerator 又剛好隨機產生了同一個短碼,接下來 10 分鐘所有人訪問這個新短網址都會拿到 404。機率很低,但沒必要冒這個險。
不快取「查不到」也有代價:有人故意一直訪問不存在的短碼,每次都會打到資料庫,快取完全擋不住,這種攻擊叫快取穿透。它的正確解法不是快取,而是限制請求頻率,明天會講到。
resolve() 刻意寫成 public:快取是靠 Spring 產生的代理物件攔截方法呼叫來運作的(等一下的雷點一會細講),用 public 最保險。
Controller 改成呼叫 RedirectService:
@GetMapping("/{code:[0-9A-Za-z]{4,16}}")
ResponseEntity<Void> redirect(@PathVariable String code) {
RedirectTarget target = redirectService.resolve(code)
.filter(t -> !t.isExpired())
.orElseThrow(() -> new LinkNotFoundException(code));
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(target.originalUrl()))
.build();
}
用 Day11 application-dev.yml 裡開啟的 show-sql,連續訪問同一個短網址兩次,只有第一次會印出 SQL,第二次直接從 Redis 拿。
到這裡,快取已經能正常運作了。從 Laravel 的角度看,@Cacheable 就是把 Cache::remember() 從程式碼裡搬到註解上,寫法更短、意思一樣。
但兩者的運作方式其實差很多:Cache::remember() 是一個明確寫在程式碼裡的函式呼叫,呼叫到哪裡、閉包包住哪幾行,看程式碼就知道;@Cacheable 則是 Spring 在背後幫你做掉的,程式碼裡看不到「檢查快取」這個動作。看不到的東西,就容易在不知不覺中失效,而且失效的時候不會有任何錯誤訊息,程式照樣跑,只是行為跟你以為的不一樣。
接下來兩個雷點,都是從這個「看不到」來的。
假設為了方便,在 RedirectService 裡再寫一個方法,內部呼叫 resolve():
@Service
class RedirectService {
@Cacheable(cacheNames = "links", unless = "#result == null")
public Optional<RedirectTarget> resolve(String code) { ... }
// ❌ 這樣呼叫,快取完全不會生效
public String resolveOrThrow(String code) {
return resolve(code)
.filter(t -> !t.isExpired())
.orElseThrow(() -> new LinkNotFoundException(code))
.originalUrl();
}
}
Controller 改呼叫 resolveOrThrow() 之後,每次訪問都會查資料庫,而且不會有任何錯誤訊息。
原因要從 @Cacheable 的運作方式講起。Spring 不會去改你寫的 RedirectService,而是在啟動時產生一個代理物件(proxy)包在外面,注入到 Controller 的其實是這個代理。Controller 呼叫 resolve() 時,先經過代理,代理檢查 Redis 有沒有快取,沒有才呼叫真正的方法:
Controller → 代理(檢查快取)→ RedirectService.resolve()
但 resolveOrThrow() 裡面的 resolve(code) 其實是 this.resolve(code),this 是 RedirectService 本身,不是代理,這個呼叫完全沒有經過代理,快取當然不會生效:
Controller → 代理 → RedirectService.resolveOrThrow() → this.resolve()(繞過代理)
Laravel 沒有這個問題,因為 Cache::remember() 是明確寫在程式碼裡的函式呼叫,在哪裡呼叫都一樣會執行。Spring 用註解換來了簡潔,代價就是要理解代理的存在。
Day14 說 create() 不能加 @Transactional 時提過「交易的細節之後還會再遇到」,這裡就先遇到了一次:@Transactional 也是靠同一套代理機制運作的,同一個類別內部呼叫加了 @Transactional 的方法,交易一樣不會生效。
解法很簡單:需要快取的方法,從別的 Bean 呼叫。這也是前面把 resolve() 放在獨立的 RedirectService、過期檢查寫在 Controller 的原因。
links 資料表有一個 click_count 欄位(Day08 建的),重定向的時候應該要 +1。直覺會寫在 resolve() 裡:
// ❌ 點擊數只會在快取沒命中時增加
@Cacheable(cacheNames = "links", unless = "#result == null")
public Optional<RedirectTarget> resolve(String code) {
linkRepository.incrementClickCount(code);
return linkRepository.findByCode(code).map(...);
}
測試時會發現點擊數只在第一次訪問時增加,之後怎麼點都不動,要等 10 分鐘後快取過期才會再加一次。因為快取命中的時候,Spring 根本不會執行這個方法,方法裡的每一行都被跳過,直接回傳快取裡的結果。
Laravel 的 Cache::remember() 其實也是一樣的行為,閉包裡的程式碼在快取命中時同樣不會執行。差別在於 Laravel 的閉包通常只有一兩行,很清楚哪些程式碼在快取範圍內;@Cacheable 包的是整個方法,方法一長就很容易忘記裡面還有別的副作用。
原則是:加了 @Cacheable 的方法只負責「查」,不要有任何副作用。點擊數要另外處理。
INCR就算把點擊數移出 resolve(),每次重定向都執行一次 UPDATE links SET click_count = click_count + 1,資料庫還是每個請求都要寫一次,快取省下來的只有讀取。
既然已經有 Redis 了,點擊數可以先在 Redis 累加。Redis 的 INCR 指令是原子操作,兩個請求同時 +1,結果一定是 +2,不會互相蓋掉,不用擔心 Day12 提過的併發問題:
@Service
class ClickCounter {
private final StringRedisTemplate redis;
ClickCounter(StringRedisTemplate redis) {
this.redis = redis;
}
void record(String code) {
redis.opsForValue().increment("clicks:" + code);
}
}
Laravel 的寫法:
Redis::incr("clicks:{$code}");
StringRedisTemplate 是 Spring 直接操作 Redis 的工具,引入 spring-boot-starter-data-redis 就會自動建立,不經過快取抽象層,key 跟 value 都是純字串,用 redis-cli 看得一清二楚:
redis-cli GET clicks:aB3xK9
# "42"
Controller 在確認短網址有效之後、回傳 302 之前記一筆:
@GetMapping("/{code:[0-9A-Za-z]{4,16}}")
ResponseEntity<Void> redirect(@PathVariable String code) {
RedirectTarget target = redirectService.resolve(code)
.filter(t -> !t.isExpired())
.orElseThrow(() -> new LinkNotFoundException(code));
clickCounter.record(code);
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(target.originalUrl()))
.build();
}
這樣一來,熱門短網址的重定向完全不碰資料庫:讀取走快取,點擊數寫進 Redis。Redis 裡的點擊數要定期寫回資料庫的 click_count,才不會在 Redis 重啟時遺失,這個「定期」的工作留到 Day17 的排程任務一起處理。
短網址被刪除後,快取裡可能還留著,要主動清掉,不然會繼續導向到 10 分鐘後快取過期為止。Laravel 用 Cache::forget(),Spring 用 @CacheEvict:
@CacheEvict(cacheNames = "links", key = "#code")
public void delete(String code, Long ownerId) {
...
}
key = "#code" 指定要清掉哪一筆,對應的就是 resolve(code) 存進去的那一筆。要注意 @CacheEvict 也是靠代理運作的,雷點一的規則一樣適用:從別的 Bean 呼叫才會生效。
Redis 把所有資料都放在記憶體裡,而這個服務最後會跑在記憶體有限的 Raspberry Pi 上(Day28)。快取設定 10 分鐘的 TTL,冷門的短網址沒人訪問,10 分鐘後就會自動從 Redis 消失;熱門的短網址則會一直被重新快取。這就是標題說的「熱門短網址快取」:不需要自己判斷哪些短網址熱門,TTL 會自然地只留下常被訪問的那些。
部署的時候還可以再幫 Redis 設一個記憶體上限(maxmemory),搭配 allkeys-lru 淘汰策略:記憶體滿了就先淘汰最久沒被使用的 key。這個留到 Day28 寫 Docker Compose 的時候一起設定。
明天 Day16 講 Rate Limiting:限制同一個來源的請求頻率,擋掉暴力產生短網址的請求,前面提到的快取穿透問題也能用同一套機制處理。