前言
短鏈接的實現在生活中比較常見,比如我們接受到的廣告短信,短信會包含他們的活動鏈接。這個鏈接是進行壓縮過的,比較短。這樣既美觀也能滿足字數的限制,比如短信中某個字段需要在多少字符以內。
短鏈跳轉的基本原理
用戶訪問短鏈地址然后重定向到原來的地址。
在HTTP協議中,30X狀態代表的是重定向的狀態。其中可以是301 也可以是302。
301 代表永久重定向。對於GET請求, 301跳轉會默認被瀏覽器cache。也就是說,用戶第一次訪問某個短鏈接后,如果服務器返回301狀態碼,則這個用戶在后續多次訪問同一短鏈接地址,瀏覽器會直接請求跳轉地址,而不會再去短鏈接系統上取!
這么做優點很明顯,降低了服務器壓力,但是無法統計到短鏈接地址的點擊次數。
302代表臨時重定向。對於GET請求, 302跳轉默認不會被瀏覽器緩存,除非在HTTP響應中通過 Cache-Control 或 Expires 暗示瀏覽器緩存。因此,用戶每次訪問同一短鏈接地址,瀏覽器都會去短鏈接系統上取。
這么做的優點是,能夠統計到短地址被點擊的次數了。但是服務器的壓力變大了。
1. 生成策略
如果用 62 個字符 [A-Z, a-z, 0-9][A−Z,a−z,0−9] 來代表一位的話(62進制)。那么我們設計長度為 n 的短鏈接,則可以包含會有 62^n 個鏈接。當然也可以添加別的字符,讓進制數變得更大,要注意特殊符號。
我們可以將自增主鍵的值(十進制的ID)來計算得到短鏈字符(62進制的字符)。然后可以用一個全局發號器來提供自增主鍵,這樣編碼生成的短鏈字符做成key,提供的url做value。這樣域名解析系統通過key 在表中找到value。value 和key之間靠主鍵關聯,這樣的方式別人也可以很容易的推導出來你的url(根據相應短鏈進行反推) 是具有規律性的。
private static final String BASE = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
public static String toBase62(long num) {
StringBuilder sb = new StringBuilder();
do {
int i = (int) (num % 62);
sb.append(BASE.charAt(i));
num /= 62;
} while (num > 0);
return sb.reverse().toString();
}
那么我們如何打亂呢,打亂的方式可以將短鏈的字母打亂,也可以在固定位置插入隨機值。
2.全局發號器
其中全局發號器的自增主鍵可以涉及到分布式ID的生成。通過UUID的方式獲取字符型 ID 的話,數據庫占用空間大,索引效率比整型低。
分布式ID的生成:
- 利用數據庫的自增ID 64位long類型
- 數據庫自增ID的缺點是數據在插入前,無法獲得ID。數據在插入后,獲取的ID雖然是唯一的,但一定要等到事務提交后,ID才算是有效的。有些雙向引用的數據,不得不插入后再做一次更新,比較麻煩
- 采用一個集中式ID生成器,它可以是Redis,也可以是ZooKeeper,也可以利用數據庫的表記錄最后分配的ID。
- 這種方式最大的缺點是復雜性太高,需要嚴重依賴第三方服務,而 且代碼配置繁瑣。一般來說,越是復雜的方案,越不可靠,並且測試越痛苦。
- 類似Twitter的Snowflake算法,它給每台機器分配一個唯一標識,然后通過時間戳+標識+自增實現全局唯一ID。
- 這種方式好處在於ID生成算法完全是一個無狀態機,無網絡調用,高效可靠。缺點是如果唯一標識有重復,會造成ID沖突。
利用數據庫生成id一定要在用到的時候去生成 id 嗎,是否可以提前生成這些自增 id ?設計一個專門的發號表,每插入一條記錄,為短鏈 id 預留 (主鍵 id * 1000 - 999) 到 (主鍵 id * 1000) 的號段
發號表:url_sender_num
id | gmt_create | tmp_start_num | tmp_end_num
如圖示:tmp_start_num 代表短鏈的起始 id,tmp_end_num 代表短鏈的終止 id。
當長鏈轉短鏈的請求打到某台機器時,先看這台機器是否分配了短鏈號段,未分配就往發號表插入一條記錄,則這台機器將為短鏈分配范圍在 tmp_start_num 到 tmp_end_num 之間的 id。從 tmp_start_num 開始分配,一直分配到 tmp_end_num,如果發號 id 達到了 tmp_end_num,說明這個區間段的 id 已經分配完了,則再往發號表插入一條記錄就又獲取了一個發號 id 區間。
畫外音:思考一下這個自增短鏈 id 在機器上該怎么實現呢, 可以用 redis, 不過更簡單的方案是用 AtomicLong,單機上性能不錯,也保證了並發的安全性,當然如果並發量很大,AtomicLong 的表現就不太行了,可以考慮用 LongAdder,在高並發下表現更加優秀。流程如下圖所示

這里有個需要注意的地方,我們可能需要防止多次相同的長鏈生成不同的短鏈 id 這種情況,這就需要每次先根據長鏈來查找 db 看是否存在相關記錄,一般的做法是給長鏈加索引,但這樣的話索引的空間會很大,所以我們可以對長鏈適當的壓縮,比如 md5,再對長鏈的 md5 字段做索引,索引就會小很多。這樣只要根據長鏈的 md5 去表里查是否存在相同的記錄即可。
如何讓各個機器分配的號段區間不重?
小結
以上做法為給要生成的鏈接分配一個分布式id,然后再生成62進制數。
以上為通過自增序列來區別各個斷鏈的生成。還可以通過hash的方法來生成id。
3. 哈希算法
通過hash將原來的長鏈hash成一個序列數,然后再進行62進制轉換。用到hash就要防止hash沖突,可通過數據庫主鍵避免沖突,或者通過布隆過濾器優化判斷是否存在沖突的邏輯。
數據庫避免沖突方式可先查找是否有再進行插入,2 次數據庫操作。對於這塊的優化可通過DUPLICATE語句 優化成一次。
推薦 Google 出品的 MurmurHash 算法,MurmurHash 是一種非加密型哈希函數,適用於一般的哈希檢索操作。與其它流行的哈希函數相比,對於規律性較強的 key,MurmurHash 的隨機分布特征表現更良好。非加密意味着着相比 MD5,SHA 這些函數它的性能肯定更高(實際上性能是 MD5 等加密算法的十倍以上),也正是由於它的這些優點,所以雖然它出現於 2008,但目前已經廣泛應用到 Redis、MemCache、Cassandra、HBase、Lucene 等眾多著名的軟件中。
MurmurHash 提供了兩種長度的哈希值,32 bit,128 bit,為了讓網址盡可通地短,我們選擇 32 bit 的哈希值,32 bit 能表示的最大值近 43 億,對於中小型公司的業務而言綽綽有余。比如對一個長鏈做 MurmurHash 計算,得到的哈希值為 3002604296,此時我們再將哈希值轉換為62進制數。
4. 更高性能設計
在電商公司,經常有很多活動,秒殺,搶紅包等等,在某個時間點的 QPS 會很高,考慮到這種情況,我們引入了 openResty,它是一個基於 Nginx 與 Lua 的高性能 Web 平台,由於 Nginx 的非阻塞 IO 模型,使用 openResty 可以輕松支持 100 w + 的並發數,一般情況下你只要部署一台即可,不過為了避免單點故障,兩台為宜,同時 openResty 也自帶了緩存機制,集成了 redis 這些緩存模塊,也可以直接連 mysql。不需要再通過業務層連這些中間件,性能自然會高不少

如圖示,使用 openResty 省去了業務層這一步,直達緩存層與數據庫層,也提升了不少性能。
最后
通常我們用分布式id + "62進制"就可以了,哈希的方法可作為拓展思路。
References
- 【原創】這可能是東半球最接地氣的短鏈接系統設計
- liaoxuefeng.com/article/1280526512029729
- 高性能短鏈設計
