面試官:你講下如何設計支援千萬級別的短鏈?

程序员博博發表於2024-06-19

前言

前幾天面試遇到的,感覺比較有趣。第一次面試遇到考架構設計相關的題目,挺新奇的,開始向國外大廠靠攏了,比天天問八股文好太多了,工作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;
  1. 獲取長url,使用murmur64進行hash,並且使用Base62 encode一下,取前6位
  2. 根據短鏈去short_url表中查詢看是否存在相關記錄,如果不存在,將長鏈與短鏈對應關係插入資料庫中,儲存。
  3. 如果存在,則hash衝突了。此時在長串上拼接一個隨機欄位(注意這塊最佳化),再次hash即可,直到沒有衝突為止。

以上步驟顯然是要最佳化的,插入一條記錄居然要經過兩次 sql(根據短鏈查記錄,將長短鏈對應關係插入資料庫中),如果在高併發下,顯然會成為瓶頸。

  1. 我們需要給短鏈欄位 surl 加上唯一索引
  2. 我們hash之後插入資料庫,如果插入失敗,說明違反了唯一性索引,此時我們重新 hash 再插入即可,看起來在違反唯一性索引的情況下是多執行了步驟,但我們要知道 MurmurHash 發生衝突的機率是非常低的,基本上不太可能發生,所以這種方案是可以接受的。
  3. 如果同一個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演算法

演算法實現

  1. 生成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);  // 解決衝突多的問題,隨機字串
}
  1. 獲取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();
}

可以看到生成短鏈只需要訪問一次資料庫,獲取短鏈也只需要訪問一次資料庫,是非常的快的。

最佳化點(難點、亮點)

  1. 生成短鏈只需要訪問一次資料庫。而不是傳統的先查詢,在判斷插入,而是直接插入,用唯一索引來判斷是否hash衝突
  2. 利用LRUCache,將最近生成的幾千個kv放進map中,一段時間內,同一個長url會生成相同的短url
  3. hash衝突後,給hash衝突值 加一個隨機url,降低衝突機率
  4. 選擇比較優秀的murmur64 hash演算法
  5. get獲取常鏈的時候,利用LRU識別熱點資料,直接從map中讀取,防止打掛資料庫

最後

本文對短鏈設計方案作了詳細地剖析,旨在給大家提供幾種不同的短鏈設計思路,文中涉及到挺多的技術細節。比如murmur64 hash演算法,base62,LRU,以及為什麼選擇mysql,而不是redis等等。文中沒有展開講,建議大家回頭可以去再詳細瞭解一下,同時也希望大家有空,可以自己動手實現一套短鏈服務,一定會有不小的收穫。

相關文章