iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Software Development

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

Day15 - Redis 快取整合 vs Laravel Cache(熱門短網址快取)

  • 分享至 

  • xImage
  •  

先補一個欠了很久的 API:重定向

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();
    }
}

兩個細節:

  1. 路徑加上正規表示式:{code:[0-9A-Za-z]{4,16}} 限定只有 base62 字元、長度 4 到 16 的路徑才會進來,跟 Day14 的 base62 字元集、Day11 ShortenerProperties 的 @Min(4) @Max(16) 對齊。/favicon.ico 這種瀏覽器自動發出的請求有 .,不會被當成短碼拿去查資料庫。效果跟 Laravel 的 ->where('code', ...) 一樣
  2. 查不到就丟 Day10 的 LinkNotFoundException:交給 GlobalExceptionHandler 回統一格式的 404

先暫時直接用 LinkRepository,等一下加快取的時候會搬進 Service。

為什麼是 302,不是 301

HTTP 的重定向狀態碼有兩個常見的選擇:

  • 301 Moved Permanently:永久搬家。瀏覽器會記住這個結果,下次再點同一個短網址,直接跳到目標網址,不會再問我們的伺服器
  • 302 Found:暫時轉址。瀏覽器每次都會先問伺服器

301 看起來比較省伺服器資源,但對縮網址服務來說有兩個問題:

  1. 點擊數統計會失真:瀏覽器記住之後就不會再來,同一個人第二次點擊我們完全不知道
  2. 沒辦法反悔:短網址過期或被刪除後,已經記住的瀏覽器還是會繼續跳到舊的目標網址,而且沒有辦法叫它忘記

所以這裡用 302。至於「每次都要問伺服器」造成的壓力,正是今天要用快取解決的問題。Laravel 的 redirect()->away() 預設也是 302。

Laravel 的快取: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 都只是不同的實作。

Spring 的快取抽象層:@Cacheable

Spring 的設計思路一模一樣:有一層快取抽象(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

把查詢搬進 Service,加上 @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 的方法只負責「查」,不要有任何副作用。點擊數要另外處理。

點擊數:交給 Redis 的 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 呼叫才會生效。

Raspberry Pi 上的記憶體

Redis 把所有資料都放在記憶體裡,而這個服務最後會跑在記憶體有限的 Raspberry Pi 上(Day28)。快取設定 10 分鐘的 TTL,冷門的短網址沒人訪問,10 分鐘後就會自動從 Redis 消失;熱門的短網址則會一直被重新快取。這就是標題說的「熱門短網址快取」:不需要自己判斷哪些短網址熱門,TTL 會自然地只留下常被訪問的那些。

部署的時候還可以再幫 Redis 設一個記憶體上限(maxmemory),搭配 allkeys-lru 淘汰策略:記憶體滿了就先淘汰最久沒被使用的 key。這個留到 Day28 寫 Docker Compose 的時候一起設定。

明天 Day16 講 Rate Limiting:限制同一個來源的請求頻率,擋掉暴力產生短網址的請求,前面提到的快取穿透問題也能用同一套機制處理。


上一篇
Day 14 - 短網址雜湊策略與唯一性設計(base62 / 分散式 ID)
下一篇
Day16 - Rate Limiting 實作(防暴力產生短網址)
系列文
從 Laravel 到 Spring Boot:30 天打造縮網址服務 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言