0.24 短網址服務的實作:短碼生成、對照表儲存、轉址快取與點擊紀錄
自建短網址服務的實作取捨,以需求已經定案為前提:連結能不能改目的地、要不要統計點擊、誰可以建立連結、短碼會不會承載私密連結、連結保存多久、支不支援自訂網域、連結是一對多分享還是替每位收件人各產生一條。這幾個需求決定由 0.23 短網址服務的需求定義:自建或購買、保存期限、目的地修改與濫用處理的規格(下文稱需求定義篇)推導出來,本篇把它們當成輸入,逐項說明它們落到實作的哪一個位置。
建立路徑與轉址路徑的流量形狀與失敗代價
短網址服務對外只做兩件事。建立路徑接收一個長網址,產生一個短碼,把「短碼 → 長網址」寫進對照表,回傳短網址。轉址路徑接收瀏覽器對短網址的請求,用短碼查對照表,回一個 3xx 狀態碼並在 Location 標頭放長網址,瀏覽器收到後自己前往長網址。對照表就是這張記錄每個短碼對應到哪一個長網址的資料表,兩條路徑都以它為中心。
兩條路徑的流量形狀差很多。一條連結只建立一次,被點擊的次數由它被分享到哪裡決定:放在個人筆記裡的連結可能一輩子被點幾次,放進一則被大量轉發的貼文裡的連結可能在幾小時內被點擊數百萬次。所以這個服務讀遠多於寫(見 0.5 流量與資料量評估 的讀寫比例一節),而且點擊在各條連結之間呈長尾分佈:多數連結幾乎沒有流量,大部分的點擊集中在少數連結上。
這個形狀屬於一對多公開分享的連結。另一種流量形狀來自替每位收件人各產生一條的連結:簡訊或郵件平台在發送時替每位收件人各建一條短網址(目的地帶著收件人自己的識別碼),一條連結只會被一個人點一兩次。這種用法下建立量與點擊量是同一個量級,點擊在發送後的幾小時內集中出現、分散在剛建立的大量連結上,應用層快取幾乎不會命中,尖峰的大小由那一批發送的收件人數決定。服務要承接這類用途時,轉址的容量要按單次發送的批量估,對照表要能直接承受尖峰時的點查詢。
兩條路徑失敗的代價也不對稱。建立路徑失敗時,建立連結的人看到錯誤、稍後重試,損失是一次操作。轉址路徑失敗時,所有已經發出去的短網址同時失效——印在海報上的、寄出去的簡訊裡的、別人轉貼的——而持有這些連結的人手上只有短網址,沒有長網址可以改用。這個不對稱決定了快取、點擊紀錄與濫用防護的取捨方向:轉址路徑的可用性與延遲優先;建立是一個由使用者主動送出、失敗可以重試的獨立操作時,建立路徑可以為了唯一性檢查與惡意網址檢查付出延遲(建立嵌在另一條寫入路徑裡的情形,例如發文當下改寫連結、送出簡訊前才產生連結,見需求定義篇〈建立路徑與轉址路徑的可用性分級〉),點擊統計與濫用掃描這類附加功能要設計成失敗時不拖垮轉址。短碼生成與對照表的取捨另有驅動因素:短碼會不會承載私密連結,以及轉址路徑的存取形狀。
容量估算也照兩條路徑分開算。對照表的儲存量是「每日建立數 × 每筆大小 × 保存天數」,每筆紀錄包含短碼、長網址與幾個中繼欄位(擁有者、建立時間、到期時間、狀態),長網址本身的長度通常佔大宗;轉址路徑要估的是尖峰而不是平均:一對多分享的連結,尖峰由單一連結被大量轉發的那幾個小時決定,和整體建立量的關聯很弱;替每位收件人各產生一條的連結,尖峰由單次發送的批量決定。
短碼生成:可猜測性與協調成本的取捨
短碼通常用 base62 編碼,字元集是 0-9、a-z、A-Z 共 62 個字元,每個字元都能直接出現在網址路徑裡、不需要跳脫。碼空間指一種碼長能產生的短碼總數:6 碼是 62⁶,約 568 億個;7 碼是 62⁷,約 3.5 兆個。碼空間大小本身很少是瓶頸,生成方式要權衡的是另外兩件事:短碼能不能被猜到,以及多台伺服器同時生成時要不要彼此協調。
遞增計數器:碰撞、可列舉性與發號協調
遞增計數器把一個全域遞增的整數轉成 base62。同一個整數只會被發出一次,所以短碼不會重複,碼長也是各種生成方式裡最短的:前 568 億條連結都只需要 6 碼。
這個做法的代價是短碼可以被列舉。拿到任何一個短碼的人,把它解碼成整數、加一或減一再編碼回去,就得到相鄰時間建立的別人的連結;寫一支腳本把整數從 0 起逐一編碼並請求,就能取得服務裡的每一條連結。如果有使用者把雲端文件的分享連結、私人行事曆的訂閱網址拿來縮短,這些「知道網址就能存取」的連結會全部外洩。短碼可被列舉這個性質還洩漏營運數據:在兩個時間點各建立一條連結,兩個整數的差就是這段期間全站的建立量。
多台伺服器同時發號時,計數器需要協調。常見的做法是號段分配:一個中央協調者(一張資料庫表或一個協調服務)每次發給伺服器一整段號碼,例如一次一千個,伺服器在記憶體裡從這一段依序發號,用完再去領下一段。協調的頻率因此降到每一千次建立一次;代價是伺服器重啟時沒發完的號碼作廢,短碼序列會出現跳號,這對短網址沒有影響,因為沒有人依賴短碼連續。
想保留計數器、又不想讓相鄰的整數產生相鄰的短碼時,會在編碼前先做一次可逆的置換。乘上一個與碼空間互質的常數再取模是最簡單的置換,而它是線性的:攻擊者自己連續建立兩條連結,就拿到一對由相鄰整數產生的短碼,兩者解碼後相減再對碼空間取模,就得到那個常數,整個置換因此被還原。要讓短碼在知道演算法的人面前也無法預測,置換必須帶金鑰,常見的選擇是保留格式的加密(format-preserving encryption):密文和明文落在同一個數值範圍,加密後的結果仍是碼空間裡的一個碼,碼長不變;一般的區塊加密輸出固定長度的位元串,會讓短碼變長。做不到帶金鑰的置換時,就放棄計數器、改用隨機產生。一般資料表主鍵面對的是同一組取捨(序號、UUID v4、UUID v7 各自可不可預測),見 1.2 Schema Design 與資料建模 的〈ID 類型選型矩陣〉。
不靠中央協調者的時間序 ID(例如 Snowflake 的格式)把時間、機器編號與序號組成一個 64 位元整數,base62 編碼後約 11 碼,而且短碼本身洩漏建立時間;對短網址來說,碼長與資訊洩漏兩項代價都偏高。
隨機產生:不可預測性與碰撞處理
隨機產生直接從碼空間裡均勻抽一個碼。它不需要伺服器之間協調,短碼之間也沒有任何順序關係,列舉只剩暴力猜測一條路,而猜中的機率是「已用的碼數 ÷ 碼空間」。
代價是碰撞。每一次新抽的碼撞到既有碼的機率同樣是「已用的碼數 ÷ 碼空間」:7 碼的空間裡已經有十億條連結時,這個機率約是萬分之三,單次建立幾乎不會撞到。但服務的總建立次數很大,依生日悖論(在 N 個可能值裡隨機抽取,抽到約 √N 個時就很可能有兩個相同),碼空間 N 裡抽到 √N 這個量級的碼數時(7 碼約一兩百萬個),碰撞就很可能已經發生。所以服務規模一大,碰撞就很可能已經發生;規模小時機率雖低,碰撞沒處理的後果是新連結蓋掉別人的連結,設計仍要決定由哪一層偵測並處理碰撞。
處理碰撞的做法是讓資料庫的唯一約束當最後的判定者:直接嘗試寫入,違反唯一約束就重新抽碼再寫。「先查詢這個碼存不存在、不存在再寫入」這種兩步做法在併發下會讓兩個請求同時查到不存在、同時寫入,正確性要靠唯一約束而不是應用程式的判斷。已用比例升高、重試次數開始明顯增加時,把碼長加一碼,碼空間就乘上 62。
預先產生的短碼池:離線處理碰撞與補貨
預先產生的短碼池在離線時就隨機產生一批短碼、去掉重複後存起來,建立路徑只要從池子取一個並標記為已用,不需要重試,也不需要伺服器之間協調計數器。碰撞仍然會發生,只是改由產生池子的批次作業處理。代價是多一個要維運的元件:池子要在用完之前補貨,每台伺服器一次取走一批時,重啟會讓沒用完的那一批作廢。短碼的不可預測性和隨機產生相同。開放自訂短碼時,使用者自訂的碼可能與池中還沒發出的碼相同,所以寫入仍要經過唯一約束,撞到就從池子再取一個;或在接受自訂短碼時,同時把池中同值的碼標記為已用。
長網址雜湊截斷:去重性質與適用情境
雜湊截斷對長網址做雜湊,取雜湊值的前幾碼當短碼(取幾碼由碼空間要多大決定)。同一個長網址永遠得到同一個短碼,所以兩個人縮短同一個網址會拿到同一條短網址。
這個去重性質在多數商業模型下和需求衝突。兩個使用者縮短同一個網址,各自要看自己那一條的點擊統計、各自要能刪除自己那一條;共用一條短網址時,誰的點擊是誰的、誰有權刪除,都沒有答案。把使用者 ID 加進雜湊的輸入可以讓每個人拿到不同的短碼,剩下的性質就接近隨機產生,只保留了同一個使用者對同一個網址的去重。截斷後的雜湊同樣會碰撞,碰撞處理和隨機產生一樣要靠唯一約束與重試。
這個做法適合的情境是沒有擁有者概念、也不統計點擊的服務,例如內部文件系統把過長的網址改寫成固定的短碼,目的只是讓網址變短,同一個長網址共用一條紀錄反而省儲存。需要逐則貼文點擊數的平台不屬於這一類,同一個外部網址共用一條紀錄時,點擊就分不出來自哪一則貼文。
自訂短碼:共用的命名空間與保留字
開放使用者自訂短碼時(例如把活動連結設成 /summer-sale),自訂短碼與系統生成的短碼查的是同一張對照表,所以兩者走同一個唯一約束。另外要維護一份保留字清單:服務自己的路由(api、login、admin、static)不能被註冊成短碼,否則轉址路徑會攔下本來要送到那些路由的請求。品牌詞與他人商標的搶註屬於使用政策,本篇與需求定義篇都不處理。
對照表:儲存選型、欄位與分片
轉址路徑對對照表只做一種查詢:給一個短碼,取回一列。這種存取形狀適合任何以主鍵點查詢為主的儲存——關聯式資料庫的主鍵索引、key-value store、document store 都能承擔,選型的考量落在團隊已經熟悉哪一種,以及資料量大到要分片時怎麼切。分片時用短碼當分片鍵;只有系統隨機生成的短碼時,短碼本身就均勻分佈。遞增計數器直接編碼的短碼依序遞增,按範圍切的分片會讓最新的分片同時承擔所有寫入與最熱的讀取,同樣要先對短碼做雜湊。開放自訂短碼之後,人取的短碼集中在常見字首與常見單字,按範圍切的分片(range sharding)會讓少數分片承擔大部分的鍵,所以分片前要先對短碼做雜湊。
base62 的碼空間依賴大小寫被當成不同字元,所以短碼欄位要用區分大小寫的比對規則。部分關聯式資料庫的預設 collation 不分大小寫(例如 MySQL 的 utf8mb4_0900_ai_ci),沿用預設會讓 aB3x 與 Ab3X 撞上同一個唯一約束,可用的碼空間退回每位 36 個字元,碼空間與碰撞機率的估算全部失準。開放自訂短碼時,自訂短碼通常希望不分大小寫(使用者會自己打字),兩者的正規化規則要在寫入時就決定。
對照表的一列至少包含:短碼、長網址、擁有者、建立時間、到期時間、狀態,刪除時另記刪除時間。狀態欄位的值包含「正常」「已停用」「已刪除」,轉址路徑每次查詢都要讀它,並依狀態決定回應:正常時轉址;已停用時顯示的頁面(常見的是一頁警告)由需求定義篇〈建立者的範圍與濫用處理〉一節的決定給出;已刪除或已過到期時間時回一頁說明或 404/410,回哪一種由需求定義篇〈連結的保存期限與保存承諾〉一節的決定給出。
幾個需求會改變這張表的鍵與欄位。支援自訂網域時,同一個短碼在不同網域底下是兩條不同的連結,主鍵變成「網域 + 短碼」;服務還要為每一個客戶網域驗證所有權、自動簽發並更新 TLS 憑證(見 ACME Automation)。允許修改目的地時,長網址欄位會被更新,同一個短碼在修改前後指向不同的網址,快取的設計因此跟著改變(見〈各層快取與撤回所需的時間〉);需求定義篇要求保留修改紀錄時,另外存一張以「短碼 + 修改時間」為鍵的修改歷史表。承諾客戶能匯出對照表時,匯出內容至少要有網域、短碼、長網址與狀態,客戶才能在另一個服務上重建同一批連結。
「列出我建立的所有連結」這類查詢以擁有者為條件,和轉址路徑的主鍵查詢是不同的存取形狀。資料量小時加一個擁有者的次要索引就夠;分片之後,次要索引要查所有分片,這時改成另外維護一份以擁有者為鍵的讀取模型。
被刪除的短碼不重新發給別人。舊的短網址可能還留在某封信、某張海報上,把同一個短碼發給新的連結,持有舊連結的人點下去會被送到一個與原本無關的網址,而新連結的擁有者可以因此取得原本屬於別人的流量。刪除因此做成軟刪除(Soft Delete):保留這一列、只把狀態改成「已刪除」,讓這個短碼永遠被佔住、不被重用。佔位需要的只有主鍵(短碼,支援自訂網域時加上網域)、狀態與刪除時間;長網址可能帶著個人資料(例如帶預約編號的就診提醒連結),刪除或到期時把長網址欄位清空,修改歷史裡的舊長網址一併清空。
轉址回應的快取標頭與瀏覽器保存回應的時間
撤回指讓一條已經發出去的短網址不再轉往原本的目的地,包含停用、刪除、到期失效,以及修改目的地。這幾種操作要生效,都需要點擊的人的請求回到短網址服務,所以撤回能不能做到、多快做到,取決於瀏覽器把轉址結果快取多久。
控制這件事的是回應上的 Cache-Control 標頭;轉址狀態碼只決定沒有帶這個標頭時的預設行為。HTTP 規範(RFC 9110)定義的四個轉址狀態碼在預設行為上不同,其中「允許啟發式快取」指的是:回應沒帶快取標頭時,規範允許快取自行估一個保存時間(見 Heuristic Freshness 卡)。
| 狀態碼 | 語意 | 未帶快取標頭時規範是否允許快取 | 重送請求時方法是否保留 |
|---|---|---|---|
| 301 | 永久移動 | 允許(啟發式快取,Chromium 會保存且永不過期) | 歷史上可能把 POST 改成 GET |
| 308 | 永久移動 | 允許(啟發式快取,Chromium 會保存且永不過期) | 保留 |
| 302 | 暫時移動 | 不允許 | 歷史上可能把 POST 改成 GET |
| 307 | 暫時移動 | 不允許 | 保留 |
點擊短網址送出的都是 GET,所以方法保留與否在這裡沒有作用,差別只在「永久」與「暫時」兩種語意,以及沒帶快取標頭時的預設行為。
先看沒帶快取標頭時的預設。各瀏覽器對沒帶快取標頭的 301 估算的期限不同(各實作的規則見 12.2 新鮮度期限的計算:max-age、s-maxage、Expires 與啟發式期限 的〈各實作的啟發式期限〉),以 Chromium 為例:回 301 時,瀏覽器會把這筆轉址存進快取,之後同一個瀏覽器再點同一條短網址,直接在本機轉往長網址,請求不會送到短網址服務。這有兩個後果:短網址服務看不到這些重複點擊,點擊統計只會記到每個瀏覽器的第一次;之後撤回這條連結,已經快取過的瀏覽器不會知道,會繼續被送往舊的目的地。回 302 時,瀏覽器每一次點擊都會回來問短網址服務,所以每一次點擊都被記到,撤回也對下一次點擊立即生效;代價是重複點擊帶來的負載全部由服務承擔。
明確的快取標頭會蓋過這個預設,轉址回應選標頭的一般規則見 12.8 快取設定的應用:轉址、需登入的 API 回應與個人化頁面 的〈轉址回應的狀態碼與快取標頭〉;這裡只寫短網址用到的幾個。回應帶上 Cache-Control: private 時,CDN 與代理伺服器這類共用快取不保存它;帶上 max-age=<秒數> 時,瀏覽器只在這段秒數內直接使用快取的轉址;帶上 no-store 時,瀏覽器完全不保存。所以 max-age 的值就是瀏覽器快取這一層撤回延遲的上限,也是同一個瀏覽器的重複點擊不會被計入的時間窗。
2026-10-02 對 bit.ly 與 t.co 的短網址各送一次 GET 請求,回應都是 301 並帶 private 與很短的 max-age,而不是 302。保留 301 的常見解釋落在狀態碼的語意上:搜尋引擎把永久轉址當成目的地應取代原網址的較強訊號,暫時轉址則是較弱的訊號;而從建立者的角度看,點擊的人該抵達的網頁正是目的地那一頁。業者自己沒有公開說明這個選擇,這是由回應推出的解釋。
這個選擇的依據來自需求。允許修改目的地、連結會到期或可被刪除、要以點擊分析收費、開放任何人建立連結,這幾個需求任何一個成立,轉址回應就不能讓瀏覽器長期快取:用 302,或在 301 上附 private 與很短的 max-age。max-age 要多短,由兩個要求各自算出一個上限,取比較短的那一個:撤回最慢多久要生效,以及可以接受同一個瀏覽器多久之內的重複點擊不被計入。
不帶快取上限的 301 能把重複點擊的負載整個交給瀏覽器,而它只在下列條件全部成立時沒有代價:
- 連結建立後就不再變動,不會到期也不會被刪除
- 不統計點擊
- 建立者全是可信的內部使用者
- 能接受目的地網站日後被入侵時,已經快取的瀏覽器撤不回
- 預期以上條件日後也不會改變:瀏覽器已經存下的永久轉址不會因為服務改了回應而失效,日後開放建立或改成可修改時,舊回應在那些瀏覽器上撤不回
任何一條不成立,就附上 max-age 為撤回設上限。
各層快取與撤回所需的時間
轉址路徑的一次查詢可能在幾個位置得到答案:瀏覽器的快取、CDN 或邊緣節點、每台應用伺服器記憶體裡的程序內快取、共用的應用層快取(例如 Redis),最後才是對照表本身。每多一層快取,轉址延遲與對照表的負載就少一些,撤回時要清的位置也多一處。
CDN 這一層一旦保存轉址回應,被 CDN 直接回應的點擊不會送到應用伺服器,〈點擊紀錄:非同步事件、非人為請求與個人資料〉描述的點擊事件就不會發出。要統計點擊又要讓 CDN 保存轉址,點擊事件要改由 CDN 的存取紀錄或邊緣函式產生;點擊分析是付費功能的服務多半讓 CDN 不保存轉址回應(這就是 Cache-Control: private 的作用),CDN 只負責 TLS 終結與擋掉異常流量。部分 CDN 在回應沒帶快取標頭時會自行替 301 與 302 套一個預設保存時間,所以轉址回應一律明確帶上快取標頭;CDN 端若設定了依狀態碼覆寫保存時間的規則,會蓋過 origin 送出的標頭,CDN 設定也要確認轉址路徑沿用 origin 的快取標頭。
應用層快取用 cache-aside:查快取,沒有就查對照表並寫回快取。一對多分享的連結呈長尾分佈,命中率偏高;替每位收件人各產生一條的連結每條只被點一兩次,命中率接近零,這類流量的轉址容量要直接落在對照表上(見〈建立路徑與轉址路徑的流量形狀與失敗代價〉)。一條爆紅的連結會成為 hot key,所有請求打在同一個快取鍵、同一個快取節點上;這時在每台應用伺服器的記憶體裡再放一層短 TTL 的程序內快取,把同一個鍵的讀取分散到所有伺服器。這一層分散在每台伺服器上,通常無法從中央清除,所以它的 TTL 也計入撤回延遲。爆紅時負載在各層怎麼分布、爆紅的是惡意連結時保存時間與撤回怎麼折衷,以及建立路徑被機器人大量建立連結時的處置,見 0.25 短網址服務的瞬時大流量:爆紅連結、建立路徑的機器人濫用與轉址路徑的攻擊。
不存在的短碼是另一種流量。掃描程式會大量請求隨機產生的短碼,嘗試撞到別人的連結,這些請求在快取裡永遠查不到,每一次都穿過快取打到對照表,形成 cache penetration。對策是把「這個短碼不存在」也寫進快取(negative cache),TTL 設短,避免新建立的短碼在 TTL 期間被誤判為不存在(對照表有跨區域讀副本時,剛建立的短碼可能在副本上還查不到;副本查無時先回主節點確認,主節點也查無才寫入 negative cache,建立連結時同時刪除這個短碼的 negative cache 鍵);同時,單一來源在一段時間內收到的 404 超過門檻時,限制這個來源的請求速率,單一來源大量的 404 本身就是掃描行為的可觀察訊號。
撤回一條連結時,共用的應用層快取與 CDN 可以主動清除(見 cache invalidation),瀏覽器快取與程序內快取只能等各自的到期時間。所以從送出撤回到所有點擊都看到結果,最長要經過的時間由各層的快取時間決定,而且不只是其中最長的那一個:過期之後允許延用舊回應的窗口(HTTP 的 stale-while-revalidate、stale-if-error 指令,以及本節後面應用層的 stale-if-error 做法:對照表查不到時延用過期的快取)會再延長一段,上游不送 Age 又把 Date 換成送出當下時間的層,會讓各層的期限沿路相加,算法見 12.5 已保存副本的更新方式:失效、purge、版本化網址與等待到期 的〈等待到期與改版全面生效的時間〉。這個數字是工程實作對需求「被檢舉的連結多久之內要失效」的回答:需求要求的時限比某一層的快取時間短,那一層的快取時間就要調短,或者那一層不能放這條連結。
對照表的連結內容不再變動時(不允許修改目的地),快取裡的資料只有「存在且正確」與「已被撤回(停用、刪除或到期)」兩種狀態,快取不一致造成的後果只剩撤回晚一點生效。這讓轉址路徑可以採用 stale-if-error:對照表暫時查不到時,繼續用快取裡過期的那一筆轉址,對照表的故障因此不會讓轉址跟著失敗。允許修改目的地之後,過期的快取可能把人送到舊的目的地,這個做法的代價跟著變高。
點擊紀錄:非同步事件、非人為請求與個人資料
每次轉址都要記錄一筆點擊時,最直接的做法是在回應之前把這筆點擊寫進資料庫。這樣做會讓轉址延遲包含一次寫入延遲,資料庫的寫入量等於全站點擊量,而且資料庫寫入變慢或失敗時,轉址也跟著變慢或失敗——附加功能的故障傳到了最需要可用性的路徑上。
比較常見的做法是轉址時只發出一筆點擊事件:寫進本機的緩衝或送到訊息佇列,然後立即回應轉址。背景的消費者讀取這些事件,按小時彙總(rollup),同時按連結、國家、裝置這幾個維度分組計數,報表查的是彙總後的結果,不是原始事件。重送的事件怎麼不被算兩次、彙總怎麼寫才能重跑,見 1.18 高頻計數的寫入與彙總:熱點列的鎖、只新增的事件、可以重跑的彙總與重送的事件。
事件遺失可以容忍到什麼程度,取決於點擊資料在商業模型裡的位置。點擊分析就是付費功能時,少算的點擊等於少交付了客戶付費購買的功能,事件要寫進 durable queue;點擊統計只是平台自己參考用時,伺服器當機時遺失緩衝區裡還沒送出的點擊事件不影響任何承諾,甚至只取樣記錄也可以。
預覽抓取與安全掃描等非人為請求的標記
把連結貼進聊天軟體時,聊天軟體會先抓取那個網址來產生預覽,抓取的可能是聊天軟體的伺服器,也可能是傳送者自己的裝置;企業郵件的安全閘道會預先開啟信件裡的連結來掃描內容;搜尋引擎與各種爬蟲也會跟著連結走。這些非人為的請求和真人點擊一樣經過轉址路徑。處理方式是依 User-Agent(預覽抓取多半自報身分,例如聊天軟體的連結展開爬蟲)、請求方法(HEAD 請求不算點擊)與來源網段把這類請求標記出來、分開計數,而不是在轉址當下丟棄,因為辨識規則會改,原始事件留著才能重算。
點擊事件裡的個人資料與保存期限
點擊事件裡的 IP 位址與裝置資訊屬於 PII。報表需要的通常只是由 IP 推出的國家或城市,所以彙總時只保留推導出來的欄位,原始事件設定明確的保存期限(見 資料生命週期),到期刪除,隱私責任隨保存的範圍縮小。
濫用防護:建立時的檢查、建立後的重查與轉址時的比對
短網址把真正的目的地藏在一個看不出內容的網址後面,這正是釣魚與惡意軟體散布者需要的性質。濫用對短網址服務的傷害有兩層:直接的那一層是使用者被騙;間接的那一層是服務網域的名聲受損。郵件過濾器、瀏覽器的安全警告與社群平台判斷一個網址可不可信時,常以網域為單位,短網址服務的網域被大量用來散布釣魚連結之後,整個網域可能被列入封鎖清單,所有客戶的正常連結同時失效。這種封鎖發生在郵件過濾器與瀏覽器那一端,服務自己的監控看到的轉址仍然全部成功,最先察覺的往往是客戶回報信件被擋;匿名建立的服務沒有客戶可以回報,所以要靠外部監測主動發現:定期用公開的網址信譽查詢服務查自己的轉址網域,並監看服務自己寄出的通知信與客戶轉交的退信裡的封鎖原因。濫用防護因此同時是可用性問題。這類「合法功能被轉用成攻擊工具」的情境,在設計階段可以用 abuse case 的方式逐條列出來。
建立時的檢查包括:把長網址送去查惡意網址黑名單、限制單一帳號與單一來源的建立速率、要求登入或驗證後才能建立。這些檢查只能判斷建立當下的狀態,而長網址指向的頁面內容由別人控制,可以在檢查通過之後才換成釣魚頁。所以檢查要在建立之後持續進行:定期對既有連結重新查詢黑名單,並提供檢舉管道。
檢查也可以放進轉址路徑本身:轉址時拿長網址比對一份危險網址清單,命中就改為顯示警告頁。這份清單要放在轉址路徑的本機記憶體或快取裡,由背景程序定期更新,不在每次轉址時呼叫外部服務,否則外部服務的延遲與故障會直接傳到轉址路徑上。清單更新失敗時,轉址繼續用記憶體裡最後一份成功取得的清單,並把更新失敗設成告警;連舊清單都沒有時(例如程序剛啟動),才要在放行(fail-open)與阻擋(fail-closed)之間選一個。放行保住轉址可用性,代價是這段時間的惡意連結照常轉址;阻擋保住安全,代價是所有連結暫時失效。只限內部使用者建立時,依轉址路徑可用性優先的取捨方向選放行;開放匿名建立時惡意連結的比例高,可以只對最近建立、還沒通過背景重查的連結阻擋,其餘放行;開放註冊或經由 API 代建時介於兩者之間,可以只對最近建立、還沒通過背景重查、且建立者帳號或金鑰尚無紀錄的連結阻擋。
確認是惡意連結之後,把對照表的狀態改成「已停用」,轉址路徑讀到這個狀態時改為顯示一頁警告,不再轉往原目的地。這個停用要多快對所有點擊生效,就是〈各層快取與撤回所需的時間〉那一節處理的問題,濫用防護與快取標頭、各層快取時間的選擇在這裡是同一個決定。
登入、帳號設定這類頁面放在另一個可註冊網域(不是同一網域底下的兩個子網域),短網址的網域只用來做轉址,理由有兩個。短網址網域被封鎖時,使用者仍然登得進管理介面。轉址網域上仍會有服務自己產生的頁面(警告頁、說明頁、預覽頁),其中一頁若被注入腳本,而兩者分屬不同的 registrable domain,那段腳本讀不到、也改不了管理介面的登入 cookie。機器人大量建立連結、把檢查配額用完時這幾層檢查怎麼運作,見 0.25 短網址服務的瞬時大流量:爆紅連結、建立路徑的機器人濫用與轉址路徑的攻擊 的〈建立路徑被機器人大量建立連結〉。
服務結束時的轉址延續
短網址服務的全部價值在外部引用上:短網址寫在別人的文件、印刷品與貼文裡,持有者幾乎都是聯絡不到的第三方(外部引用與持有者的分類見 10.4 服務終止的範圍:外部引用的種類與持有者)。服務結束或失去出資時,讓這些連結繼續運作所需的東西是對照表與短網址所在的網域;支援自訂網域時,還有每個客戶網域指向服務的那筆 DNS 紀錄。實作上要預留的,是讓這些在沒有原本的應用程式與團隊時仍能運作。
對照表匯出成靜態轉址規則。轉址路徑只做一件事:用短碼查長網址、回一個 3xx。這件事不需要資料庫與應用伺服器,可以把對照表輸出成物件儲存或 CDN 能直接回應的轉址規則(例如每個短碼一個帶轉址設定的物件,或 CDN 的轉址規則清單),讓服務退到只剩靜態檔的狀態。已停用與已刪除的連結在匯出時改成指向說明頁或回 404 的紀錄(依需求定義篇對刪除後顯示什麼的決定),日後有人接手、恢復發號時,這些短碼也不會被發給新的連結(名稱佔位的做法見 10.6 命名空間與網域的處置:續約、名稱保留與回收、懸空 DNS 的〈名稱的保留與回收:套件名稱、雲端資源名稱與短碼〉)。匯出的靜態規則要沿用〈轉址回應的快取標頭與瀏覽器保存回應的時間〉決定的狀態碼與 Cache-Control;物件儲存或 CDN 的轉址功能預設回什麼狀態碼、帶不帶快取標頭,要查所用服務的文件,預設回 301 且不帶快取標頭時,已經點過的瀏覽器收不到之後的單筆改寫。退到這個狀態之後,成本是網域續約、儲存與流量,加上用單筆改寫處理濫用回報,以及〈自訂網域的連結〉一段的 TLS 憑證更新與綁定保留,各階段怎麼安排見 10.7 分階段退場:停止新建、唯讀、靜態封存、關閉與公告期。要讓這一步在結束時只是執行一次匯出,對照表的格式與匯出程式在服務運作期間就要存在。靜態規則也要能單筆改寫:〈濫用防護:建立時的檢查、建立後的重查與轉址時的比對〉一節在轉址時比對危險網址清單的那一層,退到靜態檔之後就不存在了,所以封存之後收到的濫用回報、以及目的地網域到期被別人註冊的連結,要靠逐筆把規則改成說明頁或 404 處理。
網域的去向。靜態轉址規則只在網域還指向它時有用;網域到期被別人註冊,舊連結全部落入新註冊者的控制。退場計畫要寫明網域由誰繼續續約,或在服務運作期間就和封存機構約定,關站時把網域的技術控制交給對方,見 10.6 命名空間與網域的處置:續約、名稱保留與回收、懸空 DNS 與 10.8 移交:資料匯出、開源與交給社群或封存機構。
自訂網域的連結。客戶網域底下的連結用的是客戶自己的網域,由客戶設定的 CNAME 指向服務。服務退場時,對照表要交給網域所屬的客戶,並通知客戶在指定日期前改指或刪除那筆 CNAME;客戶確認之前,提供者保留那個主機名或 CDN 上的網域綁定,否則那筆紀錄會懸空、可能被別人接管,見 10.6 命名空間與網域的處置:續約、名稱保留與回收、懸空 DNS 的〈懸空 DNS 與子網域接管〉。客戶一直沒有回應時,提供者可以定期查詢客戶主機名的公開 DNS 紀錄,確認它不再指向服務才釋出綁定;客戶始終沒改,綁定就跟著服務網域一起依 10.6 續約不回應的做法延續,不在網域放手時一起釋出。靜態封存期間,這些網域的 TLS 憑證也要繼續更新。
依點擊紀錄決定保留範圍。〈點擊紀錄:非同步事件、非人為請求與個人資料〉一節的資料,在退場時用來回答哪些連結還有人在點:依最後一次點擊的時間把連結分成要保留轉址的與可以停用的,停用的連結先顯示一段時間的中間頁,讓點擊的人知道連結即將失效。goo.gl 從 2024 年 8 月起先對部分連結顯示中間頁;Google 2025 年 8 月修改停用計畫時說明,2024 年底沒有活動的連結在九個月前已經改導到告知即將停用的訊息頁,修改後只停用這一批,案例見 10.7 分階段退場:停止新建、唯讀、靜態封存、關閉與公告期 的〈依使用紀錄決定保留範圍〉。只為這個用途保存點擊資料時,保存每條連結的最後點擊時間就夠了,不必保存個別點擊的原始紀錄。判定要用一個固定的時點:goo.gl 以 2024 年底有沒有活動為準,被導到停用訊息的連結就是要停用的那一批,所以中間頁上「即將失效」的告知是確定的;若讓點擊中間頁的請求繼續更新最後點擊時間,每一條被告知的連結都會因此重新算成有人在用。點擊紀錄也要先排除爬蟲、連結預覽與掃描短碼空間的請求(〈點擊紀錄:非同步事件、非人為請求與個人資料〉一節),否則每條連結看起來都還有人在用;退到靜態檔之後若要繼續累積紀錄,來源改成 CDN 或物件儲存的存取紀錄,同樣先排除這些請求。
各項需求決定對應的實作位置
本篇的實作建立在需求定義篇推導出的需求決定上,其中任何一個換掉,對應的實作位置就要重新選:
- 要不要統計點擊:從「不統計」改成「統計且是付費功能」,轉址回應要從可被瀏覽器長期快取,改成讓每一次點擊(或只隔很短時間窗的重複點擊)都回到服務:302,或 301 加
private與短 max-age。之前發出的回應沒帶快取上限時,已快取那批連結的瀏覽器不在新規則的作用範圍內,它們的重複點擊仍然記不到。點擊事件要從可取樣改成寫進 durable queue。事件消費者的去重與重放安全在 3.4 consumer 設計與去重 的〈checkpoint、offset 與 idempotency〉與〈replay safety〉兩節展開。 - 連結能不能修改目的地:從「不能」改成「能」,快取不能再把對照表當成不變的資料,stale-if-error 的代價變高,每一層快取都要能被主動清除或縮短快取時間,對照表另存修改歷史。之前發出的回應沒帶快取上限時,已快取的瀏覽器撤不回,這批連結要另外評估;短碼由長網址雜湊截斷產生、而且同一個長網址共用一列時,要先拆成每位擁有者各自一列才能開放修改,否則改一條就改到所有共用這個短碼的人。失效策略在 2.2 cache aside 與失效策略 的〈失效策略〉一節,快取時間怎麼訂在 2.3 TTL 與 eviction。
- 誰可以建立連結:從內部使用者改成開放註冊或匿名,濫用防護從可有可無變成可用性的前提,轉址回應同時被撤回需求鎖定在 302 或短 max-age。濫用情境怎麼在設計階段列出來,見 abuse case 卡。
- 短碼會不會承載私密連結:使用者會縮短「知道網址就能存取」的連結時,遞增計數器只有在編碼前經過帶金鑰的置換(例如保留格式的加密)時才可用,否則短碼生成改成隨機產生。
- 連結保存多久:決定對照表的儲存量、已到期與已刪除連結的回應,以及點擊原始事件的保存期限;服務本身結束時的匯出與網域去向見〈服務結束時的轉址延續〉;資料量增長到需要分片時,分片鍵怎麼設計在 1.10 KV / Document DB 容量規劃 的〈Partition key 設計:容量的命脈〉一節。
- 連結是一對多分享還是替每位收件人各產生一條:改成後者時,轉址容量按單次發送批量估,應用層快取不再分擔讀取,建立嵌在發送流程裡時建立路徑的可用性跟著發送路徑訂。
- 支不支援自訂網域:從不支援改成支援,對照表主鍵從短碼改成「網域 + 短碼」,分片鍵跟著改,並要加上網域所有權驗證與 TLS 憑證自動化。
其中短碼會不會承載私密連結,由使用情境決定、與收入模型無關;點擊統計、保存承諾、建立者範圍與自訂網域的商業依據,在需求定義篇的〈點擊資料的地位:付費功能或內部參考,以及對應的規格〉〈連結的保存期限與保存承諾〉〈建立者的範圍與濫用處理〉〈自訂網域與網域名聲的隔離〉四節。這些依據背後的出資方式,見 短網址的商業模型:訂閱、自架、平台內建與廣告插頁各由誰出資維持轉址。