"abc123" 說起Day05 開始,每一篇都有一句「短碼先寫死,Day14 才會講怎麼真正產生」。Day07 的 LinkService 裡到現在還是:
link.setCode("abc123"); // Day14 才會講怎麼真正產生短碼,這裡先沿用假值
而且因為 Day08 的 migration 幫 code 欄位加了唯一索引,現在建立第二個短網址就會直接失敗。今天把這個債還掉。
短碼產生看起來只是「產生一個短字串」,實際上要同時滿足幾個條件:
base62 就是 0-9、A-Z、a-z 共 62 個字元。常見的 base64 多了 + 跟 / 兩個字元,這兩個在網址裡有特殊意義(/ 是路徑分隔、+ 在某些情境會被當成空白),放進短網址要跳脫,很麻煩。base64 還有一個 URL 安全版本,用 - 跟 _ 取代,但短碼裡出現符號看起來很怪,也不好口頭唸給別人聽。base62 只有英數字,到哪裡都不用跳脫。
長度跟可用組合數的關係:
| 長度 | 組合數 |
|---|---|
| 4 | 62⁴ ≈ 1,478 萬 |
| 6 | 62⁶ ≈ 568 億 |
| 8 | 62⁸ ≈ 218 兆 |
Day11 在 ShortenerProperties 裡定義的 code-length: 6,就是在這裡派上用場。
對原始網址算 MD5 或 SHA-256,轉成 base62 後取前 6 碼。好處是同一個網址永遠得到同一個短碼,天生去重。
但這個「好處」在我們的服務反而是問題:兩個人縮同一個網址,應該是兩筆各自獨立的紀錄(各自的點擊統計、各自的過期時間、各自的 ownerId)。而且把 256 位元的雜湊值截到 6 碼,碰撞一樣會發生,最後還是要處理重複,去重的優點也就不存在了。
資料庫的自增 id 本來就不會重複,把它轉成 base62 就是短碼:id = 125 轉成 21,id = 1000000 轉成 4C92。保證唯一、而且非常短,很多教學都用這個做法。
問題是完全猜得到:拿到自己的短碼 4C92,往前往後加減一,就能一路掃出別人的所有連結;看短碼還能推算出這個服務總共建立了多少網址。另外還有一個實作上的麻煩:要先 INSERT 拿到 id,才能算出短碼再 UPDATE 回去,一次建立要寫兩次資料庫。
Laravel 圈常用的 vinkla/hashids(新版叫 Sqids)就是在解決「看起來太好猜」的問題,把 ID 打散成 jR、k5 這種看起來隨機的字串。但它是可逆的編碼,不是加密,知道演算法跟設定的人就能算回原本的 ID,不能當成安全機制。
Twitter 提出的 Snowflake 演算法,用「時間戳記 + 機器編號 + 序號」組成一個 64 位元的整數,好處是多台伺服器可以各自產生 ID,完全不用問資料庫,也保證不會重複,適合每秒要產生大量 ID 的大型分散式系統。
對我們來說有三個問題:
知道有這個選項、知道它在解決什麼問題就好,現在用它是殺雞用牛刀。
直接隨機產生 6 個 base62 字元。568 億種組合,別人要猜中某個有效短碼的機率極低;也不需要先拿 id,產生完直接存。
唯一的問題是隨機就有可能重複。那重複的機率有多高?假設服務裡已經有 100 萬個短網址,新產生一個短碼剛好跟現有的撞到的機率是:
1,000,000 / 56,800,000,000 ≈ 0.0018%
大約每五萬多次建立才會撞到一次,而撞到的時候,重新產生一次就好。以一個自己用的短網址服務來說,這個機率低到可以放心使用。
CodeGeneratorLaravel 一行就結束:
$code = Str::random(6);
Str::random() 底層用的是 random_bytes(),是密碼學安全的亂數來源。Java 標準庫沒有對應的一行寫法,要自己組:
@Component
class CodeGenerator {
private static final String ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
private final SecureRandom random = new SecureRandom();
private final int length;
CodeGenerator(ShortenerProperties properties) {
this.length = properties.codeLength();
}
String generate() {
StringBuilder code = new StringBuilder(length);
for (int i = 0; i < length; i++) {
code.append(ALPHABET.charAt(random.nextInt(ALPHABET.length())));
}
return code.toString();
}
}
兩個重點:
SecureRandom 而不是 Random:java.util.Random 的演算法是公開的,只要觀察到幾個輸出,就能推算出之後會產生什麼,這樣「猜不到」的前提就不成立了。SecureRandom 是密碼學安全的亂數產生器,對應 PHP 的 random_int();Random 則對應 PHP 的 rand()
SecureRandom 放在成員變數沒問題嗎?Day12 才說過 Singleton Bean 不能存放請求相關的狀態。但 SecureRandom 不是「某個請求的狀態」,而是一個所有請求共用的工具,而且它本身是執行緒安全的,多個請求同時呼叫 generate() 不會互相干擾。判斷標準不是「有沒有成員變數」,而是「這個成員變數會不會因為不同請求而有不同的值」直覺的寫法是先檢查再存(Day07 的 LinkRepository 剛好有準備 existsByCode()):
// ❌ 看起來合理,但有漏洞
String code;
do {
code = codeGenerator.generate();
} while (linkRepository.existsByCode(code));
link.setCode(code);
linkRepository.save(link);
問題跟 Day12 講 Singleton 時是同一類:同時有兩個請求。兩個請求剛好產生同一個短碼,各自檢查時都發現「不存在」,然後各自存入,其中一個就會撞上唯一索引而失敗。「檢查」跟「寫入」之間有一段空檔,這種問題叫 check-then-act 競態(race condition)。機率很低,但不是零,而且每次建立都要多查一次資料庫。
更好的做法是不檢查,直接存,把「保證不重複」這件事交給 Day08 就建好的唯一索引。資料庫的唯一索引不管同時有多少請求都能保證唯一,撞到的時候拋例外,我們接住再重試:
@Service
class LinkService {
private static final Logger log = LoggerFactory.getLogger(LinkService.class);
private static final int MAX_ATTEMPTS = 3;
private final LinkRepository linkRepository;
private final CodeGenerator codeGenerator;
private final ShortenerProperties properties;
// 建構子省略
LinkResponse create(CreateLinkRequest request, Long ownerId) {
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
Link link = new Link();
link.setCode(codeGenerator.generate());
link.setOriginalUrl(request.url());
link.setOwnerId(ownerId);
link.setCreatedAt(LocalDateTime.now());
try {
return toResponse(linkRepository.save(link));
} catch (DataIntegrityViolationException e) {
log.warn("短碼碰撞,第 {} 次重試:{}", attempt, link.getCode());
}
}
throw new CodeGenerationException();
}
}
Laravel 的對應寫法:
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
return Link::create([...$data, 'code' => Str::random(6)]);
} catch (UniqueConstraintViolationException $e) {
Log::warning("短碼碰撞,第 {$attempt} 次重試");
}
}
throw new CodeGenerationException();
幾個細節:
new 一個新的 Link:失敗的那個物件已經跟 JPA 打過交道,狀態不一定乾淨,重新建立最保險create() 不能加 @Transactional:linkRepository.save() 本身就會開一個獨立的交易,失敗了只回滾那一次。如果外層的 create() 也包了交易,第一次撞到唯一索引時,整個外層交易就會被標記成「只能回滾」,之後的重試就算成功了也存不進去。交易的細節之後的篇章還會再遇到,這裡先記得「重試迴圈要在交易外面」CodeGenerationException 接到 Day10 的 GlobalExceptionHandler,回傳 503 跟統一錯誤格式,請呼叫端稍後再試@ExceptionHandler(CodeGenerationException.class)
ResponseEntity<ErrorResponse> handleCodeGeneration(CodeGenerationException ex) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse("短碼產生失敗,請稍後再試", List.of()));
}
邏輯寫完,本機測試全部正常。但有一個問題,要等資料量變大才會浮現:
base62 的 62 個字元裡,A-Z 跟 a-z 被當成不同的字元,abC 跟 ABc 是兩個不同的短碼。但 MySQL 8 預設的 collation utf8mb4_0900_ai_ci,結尾的 ci 是 case-insensitive(不分大小寫)的意思。對資料庫來說:
SELECT 'abC' = 'ABc'; -- 回傳 1,MySQL 認為兩者相等
這會造成兩個問題:
abC,要存 ABc 時會被唯一索引擋下來,當成碰撞。實際可用的組合數從 62⁶ 掉到大約 36⁶(約 21 億),碰撞機率變成原本的 26 倍abC,有人訪問 /ABc,findByCode("ABc") 也會查到 abC,導向別人的網址這個雷不是 Spring 特有的,Laravel 用 MySQL 一樣會踩到,只是以前寫 Laravel 專案時,很少遇到「大小寫不同就代表不同資料」的欄位。
修正方式是把 code 欄位改成二進位比對的 utf8mb4_bin,大小寫就會被視為不同。Day08 說過 Flyway 已經執行過的 migration 不能改,所以寫一個新的:
-- V2__make_link_code_case_sensitive.sql
ALTER TABLE links
MODIFY code VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;
順便把長度從 VARCHAR(255) 縮成 16,跟 Day11 ShortenerProperties 裡 @Max(16) 的上限對齊,設定檔允許的最大長度剛好就是欄位的最大長度。MODIFY 只改欄位定義,原本的唯一索引會保留。Entity 那邊也同步把 @Column 的長度改成一樣:
@Column(nullable = false, unique = true, length = 16)
private String code;
Laravel 的寫法是:
$table->string('code', 16)->collation('utf8mb4_bin')->change();
到這裡,縮網址服務終於能產生真正的短碼了。現在每次重定向都要查一次資料庫,熱門的短網址被大量點擊時,資料庫會承受不必要的壓力,明天 Day15 用 Redis 把熱門短網址快取起來。