前言
前幾天面試遇到的,感覺比較有趣。第一次面試遇到考架構設計相關的題目,挺新奇的,開始向國外大廠靠攏了,比天天問八股文好太多了,工作5年左右的,問八股文,純純的不負責任偷懶行為。
感覺此問題比較有趣,這幾天簡單的實現了一版本,和大家分享一下具體的細節,也歡迎大家交流討論, 程式碼github連結 short-url。
短鏈生成的幾種方法
業界實現短鏈的方式大概是有兩種。
1. Hash演算法
由長url透過 hash 演算法,生成短的url,如果hash衝突,需要解決解決hash衝突。那麼這個雜湊函式該怎麼取呢,相信肯定有很多人說用 MD5,SHA 等演算法,其實這樣做有點殺雞用牛刀了,而且既然是加密就意味著效能上會有損失,我們其實不關心反向解密的難度,反而更關心的是雜湊的運算速度和衝突機率。
能夠滿足這樣的雜湊演算法有很多,這裡推薦 Google 出品的 MurmurHash 演算法,MurmurHash 是一種非加密型雜湊函式,適用於一般的雜湊檢索操作。與其它流行的雜湊函式相比,對於規律性較強的 key,MurmurHash 的隨機分佈特徵表現更良好。非加密意味著著相比 MD5,SHA 這些函式它的效能肯定更高(實際上效能是 MD5 等加密演算法的十倍以上),也正是由於它的這些優點,所以雖然它出現於 2008,但目前已經廣泛應用到 Redis、MemCache、Cassandra、HBase、Lucene 等眾多著名的軟體中。
1.1 如何縮短域名
MurmurHash32會生成32位的十進位制,MurmurHash64會生成64位的十進位制。那我們把它轉為 62 進位制可縮短它的長度,為什麼是62進位制,不是64呢?因為62進製表示 【a-z A-Z 0-9】字元之和。
1.2 如何解決hash衝突
在優秀的雜湊函式,都不可避免地會產生雜湊衝突(儘管機率很低),該怎麼解決呢。我們設計如下mysql表
CREATE TABLE `short_url` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`lurl` varchar(150) NOT NULL,
`surl` varchar(10) NOT NULL,
`gmt_create` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_surl` (`surl`),
KEY `idx_lurl` (`lurl`)
) ENGINE=InnoDB AUTO_INCREMENT=15536 DEFAULT CHARSET=utf8;
- 獲取長url,使用murmur64進行hash,並且使用Base62 encode一下,取前6位
- 根據短鏈去short_url表中查詢看是否存在相關記錄,如果不存在,將長鏈與短鏈對應關係插入資料庫中,儲存。
- 如果存在,則hash衝突了。此時在長串上拼接一個隨機欄位(注意這塊最佳化),再次hash即可,直到沒有衝突為止。
以上步驟顯然是要最佳化的,插入一條記錄居然要經過兩次 sql(根據短鏈查記錄,將長短鏈對應關係插入資料庫中),如果在高併發下,顯然會成為瓶頸。
- 我們需要給短鏈欄位 surl 加上唯一索引
- 我們hash之後插入資料庫,如果插入失敗,說明違反了唯一性索引,此時我們重新 hash 再插入即可,看起來在違反唯一性索引的情況下是多執行了步驟,但我們要知道 MurmurHash 發生衝突的機率是非常低的,基本上不太可能發生,所以這種方案是可以接受的。
- 如果同一個URL,頻繁請求,這種會衝突多次,對此我們引入了LRU Cache,進行判斷,如果在cache裡面,直接返回即可,不在生成之後,再加入到cache裡面
也就是整一個流程我們只和資料庫有一次互動,同時我們引入了LRU的快取,極大了提高了效能。
2. 發號器
維護一個自增id,比如 1,2,3 這樣的整數遞增 ID,當收到一個長鏈轉短鏈的請求時,ID 生成器為其分配一個 ID,再將其轉化為 62 進位制,拼接到短鏈域名後面就得到了最終的短網址。但此方法需要全域性維護一個自增id,同時同一個長的url會生成不同的短的url,並且短的url會有規律,比較容易猜測到。
常見的有以下幾種:uuid,redis計數,Snowflake雪花演算法,Mysql 自增主鍵。總和比較感覺雪花演算法以及redis計數比較靠譜,可以嘗試去使用。
Hash函式
本次選擇的hash對映方式,來生成短鏈。底層資料儲存選擇是mysql,透過mysql的分庫分表,讀寫分離,也可以有非常高效的效率。如果採用redis,快取會丟失資料,如果採用hbase,效率不可控,故最後選擇mysql作為底層儲存資料。
先說下hash函式測試的結論,比較有說服力, 可以直接看HashTest類
100W資料,murmur32演算法(產生一個32位的hash值),100W大概會有121個衝突
- i = 100000(10W), conflictSize = 1
- i = 200000(20W), conflictSize = 6
- i = 300000(30W), conflictSize = 12
- i = 400000(40W), conflictSize = 19
- i = 500000(50W), conflictSize = 32
- i = 600000(60W), conflictSize = 46
- i = 700000(70W), conflictSize = 54
- i = 800000(80W), conflictSize = 76
- i = 900000(90W), conflictSize = 94
- i = 1000000(100W), conflictSize = 121
修改為 murmur64演算法,100W 0衝突,500W 0衝突,建議使用murmur64演算法
演算法實現
- 生成url核心演算法(著重看下hash衝突解決方法 && LRU的cache也需要關注)
public String generateShortUrl(String longUrl) {
if (StringUtils.isEmpty(longUrl)) {
throw new RuntimeException("longUrl 不能為空");
}
String shortUrl = CacheUtils.get(MapConstants.longCache, longUrl);
if (StringUtils.isNotEmpty(shortUrl)) {
return shortUrl;
}
return getShortUrl(longUrl, getLongUrlRandom(longUrl));
}
private String getShortUrl(String rawUrl, String longUrl) {
long hash = HashUtil.murmur64(longUrl.getBytes());
String base62 = Base62.encode(hash + "");
log.info("longUrl = {}, hash = {}, base62 = {}", longUrl, hash, base62);
if (StringUtils.isEmpty(base62)) {
throw new RuntimeException("hash 演算法有誤");
}
String shortUrl = StringUtils.substring(base62, 6);
ShortUrl url = new ShortUrl(rawUrl, shortUrl);
try {
int insert = shortUrlDAO.insert(url); // 這裡進行分庫分表 提高效能
if (insert == 1) {
CacheUtils.put(MapConstants.longCache, rawUrl, shortUrl);
}
} catch (DuplicateKeyException e) {
// Hash衝突
log.warn("hash衝突 觸發唯一索引 rawUrl = {}, longUrl = {}, shortUrl = {}, e = {}", rawUrl, longUrl, shortUrl, e.getMessage(), e);
CacheUtils.put(MapConstants.hashFailMap, rawUrl, shortUrl);
return getShortUrl(rawUrl, getLongUrlRandom(shortUrl));
} catch (Exception e) {
log.error("未知錯誤 e = {}", e.getMessage(), e);
throw new RuntimeException("msg = " + e.getMessage());
}
return shortUrl;
}
private String getLongUrlRandom(String longUrl) {
return longUrl + RandomUtil.randomString(6); // 解決衝突多的問題,隨機字串
}
- 獲取url核心演算法
public String getLongUrl(String shortUrl) {
if (StringUtils.isEmpty(shortUrl)) {
throw new RuntimeException("shortUrl 不能為空");
}
String longUrl = CacheUtils.get(MapConstants.shortCache, shortUrl);
if (StringUtils.isNotEmpty(longUrl)) {
return longUrl;
}
LambdaQueryWrapper<ShortUrl> wrapper = new QueryWrapper<ShortUrl>().lambda().eq(ShortUrl::getSUrl, shortUrl);
ShortUrl url = shortUrlDAO.selectOne(wrapper);
CacheUtils.put(MapConstants.shortCache, shortUrl, url.getLUrl());
return url.getLUrl();
}
可以看到生成短鏈只需要訪問一次資料庫,獲取短鏈也只需要訪問一次資料庫,是非常的快的。
最佳化點(難點、亮點)
- 生成短鏈只需要訪問一次資料庫。而不是傳統的先查詢,在判斷插入,而是直接插入,用唯一索引來判斷是否hash衝突
- 利用LRUCache,將最近生成的幾千個kv放進map中,一段時間內,同一個長url會生成相同的短url
- hash衝突後,給hash衝突值 加一個隨機url,降低衝突機率
- 選擇比較優秀的murmur64 hash演算法
- get獲取常鏈的時候,利用LRU識別熱點資料,直接從map中讀取,防止打掛資料庫
最後
本文對短鏈設計方案作了詳細地剖析,旨在給大家提供幾種不同的短鏈設計思路,文中涉及到挺多的技術細節。比如murmur64 hash演算法,base62,LRU,以及為什麼選擇mysql,而不是redis等等。文中沒有展開講,建議大家回頭可以去再詳細瞭解一下,同時也希望大家有空,可以自己動手實現一套短鏈服務,一定會有不小的收穫。