這段時間,在整理知識星球中面試專欄時看到這麼一個字節跳動的二面真題:100Wqps短鏈系統,怎麼設計?
這道題,看上去業務簡單,其實,覆蓋的知識點非常多:
- 高併發、高性能分佈式 ID
- Redis Bloom Filter 高併發、低內存損耗的 過濾組件知識
- 分庫、分表海量數據存儲
- 多級緩存的知識
- HTTP傳輸知識
- 二進制、十六進制、六十二進制知識
總體來說,高併發、高性能系統的核心領域,都覆蓋了。所以,陳某分析下來,得到一個結論:是一個超級好的問題。
1、短URL系統的背景
短網址替代長URL,在互聯網網上傳播和引用。
例如QQ微博的url.cn,新郎的sinaurl.cn等。
在QQ、微博上發佈網址的時候,會自動判別網址,並將其轉換,例如:url.cn/2hytQx
爲什麼要這樣做的,無外乎幾點:
-
縮短地址長度,留足更多空間的給 有意義的內容
URL是沒有意義的,有的原始URL很長,佔用有效的屏幕空間。
微博限制字數爲140字一條,那麼如果這個連接非常的長,以至於將近要佔用我們內容的一半篇幅,這肯定是不能被允許的,鏈接變短,對於有長度限制的平臺發文,可編輯的文字就變多了, 所以短網址應運而生了。
-
可以很好的對原始URL內容管控。
有一部分網址可以會涵蓋XX,暴力,廣告等信息,這樣我們可以通過用戶的舉報,完全管理這個連接將不出現在我們的應用中,應爲同樣的URL通過加密算法之後,得到的地址是一樣的。
-
可以很好的對原始URL進行行爲分析
我們可以對一系列的網址進行流量,點擊等統計,挖掘出大多數用戶的關注點,這樣有利於我們對項目的後續工作更好的作出決策。
短網址和短ID相當於間接提高了帶寬的利用率、節約成本
鏈接太長在有些平臺上無法自動識別爲超鏈接
短鏈接更加簡潔好看且安全,不暴露訪問參數。而且,能規避關鍵詞、域名屏蔽等手段
2、短URL系統的原理
短URL系統的核心:將長的 URL 轉化成短的 URL。
客戶端在訪問系統時,短URL的工作流程如下:
- 先使用短地址A訪問 短鏈Java 服務
- 短鏈Java 服務 進行 地址轉換和映射,將 短URL系統映射到對應的長地址URL
- 短鏈Java 服務 返回302 重定向 給客戶端
- 然後客戶端再重定向到原始服務
如下圖所示:
那麼,原始URL如何變短呢?簡單來說, 可以將原始的地址,使用編號進行替代
編號如何進一步變短呢? 可以使用更大的進制來表示
六十二進制表示法
顧名思義短網址就是非常短的網址,比如xxx.cn/EYyCO9T,其中核… EYyCO9T 只有7位長度。
其實這裏的7位長度是使用62進制來表示的,就是常用的0-9、a-z、A-Z,也就是10個數字+26個小寫+26個大寫=62位。
那麼7位長度62進制可以表示多大範圍呢?
62^7 = 3,521,614,606,208 (合計3.5萬億),
說明:
10進制 最大隻能生成 10 ^ 6 - 1 =999999個
16進制 最大隻能生成 16 ^ 6 - 1 =16777215個
16進制裏面已經包含了 A B C D E F 這幾個字母
62進制 最大竟能生成 62 ^ 6 - 1 =56800235583個 基本上夠了。
A-Z a-z 0-9 剛好等於62位
注意:
int(4個字節) ,存儲的範圍是-21億到21億
long(8個字節),存儲的範圍是-900萬萬億 到 900萬萬億
至於短網址的長度,可以根據自己需要來調整,如果需要更多,可以增加位數,
即使6位長度62^6也能達到568億的範圍,
這樣的話只要算法得當,可以覆蓋很大的數據範圍。
在編碼的過程中,可以按照自己的需求來調整62進制各位代表的含義。
一個典型的場景是, 在編碼的過程中,如果不想讓人明確知道轉換前是什麼,可以進行弱加密,
比如A站點將字母c表示32、B站點將字母c表示60,就相當於密碼本了。
128進製表示法
標準ASCII 碼也叫基礎ASCII碼,使用7 位二進制數(剩下的1位二進制爲0),包含128個字符,
看到這裏你或許會說,使用128進制(如果有的話)豈不是網址更短,
是的,
7 位二進制數(剩下的1位二進制爲0)表示所有的大寫和小寫字母,數字0 到9、標點符號,以及在美式英語中使用的特殊控制字符 [1] 。
注意:
128個進制就可能會出現大量的不常用字符
比如 # % & * 這些,
這樣的話,對於短鏈接而言,通用性和記憶性就變差了,
所以,62進制是個權衡折中。
3、短 URL 系統的功能分析
假設短地址長度爲8位,62的8次方足夠一般系統使用了
系統核心實現,包含三個大的功能
- 發號
- 存儲
- 映射
可以分爲兩個模塊:發號與存儲模塊、映射模塊
發號與存儲模塊
- 發號:使用發號器發號 , 爲每個長地址分配一個號碼ID,並且需要防止地址二義,也就是防止同一個長址多次請求得到的短址不一樣
- 存儲:將號碼與長地址存放在DB中,將號碼轉化成62進制,用於表示最終的短地址,並返回給用戶
映射模塊
用戶使用62進制的短地址請求服務 ,
- 轉換:將62進制的數轉化成10進制,因爲咱們系統內部是long 類型的10進制的數字ID
- 映射:在DB中尋找對應的長地址
- 通過302重定向,將用戶請求重定向到對應的地址上
4、發號器的高併發架構
回顧一下發號器的功能:
- 爲每個長地址分配一個號碼ID
- 並且需要防止地址歧義
以下對目前流行的分佈式ID方案做簡單介紹
方案1:使用地址的hash 編碼作爲ID
可以通過 原始Url的 hash編碼,得到一個 整數,作爲 短鏈的ID
哈希算法簡單來說就是將一個元素映射成另一個元素,
哈希算法可以簡單分類兩類,
- 加密哈希,如MD5,SHA256等,
- 非加密哈希,如MurMurHash,CRC32,DJB等。
MD5算法
MD5消息摘要算法(MD5 Message-Digest Algorithm),一種被廣泛使用的密碼散列函數,
可以產生出一個128位(16字節)的散列值(hash value),
MD5算法將數據(如一段文字)運算變爲另一固定長度值,是散列算法的基礎原理。
由美國密碼學家 Ronald Linn Rivest設計,於1992年公開並在 RFC 1321 中被加以規範。
CRC算法
循環冗餘校驗(Cyclic Redundancy Check)是一種根據網絡數據包或電腦文件等數據,
產生簡短固定位數校驗碼的一種散列函數,由 W. Wesley Peterson 於1961年發表。
生成的數字在傳輸或者存儲之前計算出來並且附加到數據後面,然後接收方進行檢驗確定數據是否發生變化。
由於本函數易於用二進制的電腦硬件使用、容易進行數學分析並且尤其善於檢測傳輸通道干擾引起的錯誤,因此獲得廣泛應用。
MurmurHash
MurmurHash 是一種非加密型哈希函數,適用於一般的哈希檢索操作。
由 Austin Appleby 在2008年發明,並出現了多個變種,與其它流行的哈希函數相比,對於規律性較強的鍵,MurmurHash的隨機分佈特徵表現更良好。
這個算法已經被很多開源項目使用,比如libstdc++ (4.6版)、Perl、nginx (不早於1.0.1版)、Rubinius、 libmemcached、maatkit、Hadoop、Redis,Memcached,Cassandra,HBase,Lucene等。
MurmurHash 計算可以是 128位、64位、32位,位數越多,碰撞概率越少。
所以,可以把長鏈做 MurmurHash 計算,可以得到的一個整數哈希值 ,
所得到的短鏈,類似於下面的形式
固定短鏈域名+哈希值 = www.weibo.com/888888888
如何縮短域名?傳輸的時候,可以把 MurmurHash之後的數字爲10進制,可以把數字轉成62進制
www.weibo.com/abcdef
那麼,使用地址的hash 編碼作爲ID的問題是啥呢?
會出現碰撞,所以這種方案不適合。
方案2:數據庫自增長ID
屬於完全依賴數據源的方式,所有的ID存儲在數據庫裏,是最常用的ID生成辦法,在單體應用時期得到了最廣泛的使用,建立數據表時利用數據庫自帶的auto_increment作主鍵,或是使用序列完成其他場景的一些自增長ID的需求。
但是這種方式存在在高併發情況下性能問題,要解決該問題,可以通過批量發號來解決,
提前爲每臺機器發放一個ID區間 [low,high],然後由機器在自己內存中使用 AtomicLong 原子類去保證自增,減少對DB的依賴,
每臺機器,等到自己的區間即將滿了,再向 DB 請求下一個區段的號碼,
爲了實現寫入的高併發,可以引入 隊列緩衝+批量寫入架構,
等區間滿了,再一次性將記錄保存到DB中,並且異步進行獲取和寫入操作, 保證服務的持續高併發。
比如可以每次從數據庫獲取10000個號碼,然後在內存中進行發放,當剩餘的號碼不足1000時,重新向MySQL請求下10000個號碼,在上一批號碼發放完了之後,批量進行寫入數據庫。
但是這種方案,更適合於單體的 DB 場景,在分佈式DB場景下, 使用 MySQL的自增主鍵, 會存在不同DB庫之間的ID衝突,又要使用各種辦法去解決,
總結一下, MySQL的自增主鍵生成ID的優缺點和使用場景:
-
優點:
非常簡單,有序遞增,方便分頁和排序。
-
缺點:
分庫分表後,同一數據表的自增ID容易重複,無法直接使用(可以設置步長,但侷限性很明顯);
性能吞吐量整個較低,如果設計一個單獨的數據庫來實現 分佈式應用的數據唯一性,
即使使用預生成方案,也會因爲事務鎖的問題,高併發場景容易出現單點瓶頸。
-
適用場景:
單數據庫實例的表ID(包含主從同步場景),部分按天計數的流水號等;
分庫分表場景、全系統唯一性ID場景不適用。
所以,高併發場景, MySQL的自增主鍵,很少用。
方案3:分佈式、高性能的中間件生成ID
Mysql 不行,可以考慮分佈式、高性能的中間件完成。
比如 Redis、MongoDB 的自增主鍵,或者其他 分佈式存儲的自增主鍵,但是這就會引入額外的中間組件。
假如使用Redis,則通過Redis的INCR/INCRBY自增原子操作命令,能保證生成的ID肯定是唯一有序的,本質上實現方式與數據庫一致。
但是,超高併發場景,分佈式自增主鍵的生產性能,沒有本地生產ID的性能高。
總結一下,分佈式、高性能的中間件生成ID的優缺點和使用場景:
-
優點:
整體吞吐量比數據庫要高。
-
缺點:
Redis實例或集羣宕機後,找回最新的ID值有點困難。
-
適用場景:
比較適合計數場景,如用戶訪問量,訂單流水號(日期+流水號)等。
方案4:UUID、GUID生成ID
UUID:
按照OSF制定的標準計算,用到了以太網卡地址、納秒級時間、芯片ID碼和許多可能的數字。由以下幾部分的組合:當前日期和時間(UUID的第一個部分與時間有關,如果你在生成一個UUID之後,過幾秒又生成一個UUID,則第一個部分不同,其餘相同),時鐘序列,全局唯一的IEEE機器識別號(如果有網卡,從網卡獲得,沒有網卡以其他方式獲得)
GUID:
微軟對UUID這個標準的實現。UUID還有其它各種實現,不止GUID一種,不一一列舉了。
這兩種屬於不依賴數據源方式,真正的全球唯一性ID
總結一下,UUID、GUID生成ID的優缺點和使用場景:
-
優點:
不依賴任何數據源,自行計算,沒有網絡ID,速度超快,並且全球唯一。
-
缺點:
沒有順序性,並且比較長(128bit),作爲數據庫主鍵、索引會導致索引效率下降,空間佔用較多。
-
適用場景:
只要對存儲空間沒有苛刻要求的都能夠適用,比如各種鏈路追蹤、日誌存儲等。
方式5:snowflake算法(雪花算法)生成ID
snowflake ID 嚴格來說,屬於 本地生產 ID,這點和 Redis ID、MongoDB ID不同, 後者屬於遠程生產的ID。
本地生產ID性能高,遠程生產的ID性能低。
snowflake ID原理是使用Long類型(64位),按照一定的規則進行分段填充:時間(毫秒級)+集羣ID+機器ID+序列號,每段佔用的位數可以根據實際需要分配,其中集羣ID和機器ID這兩部分,在實際應用場景中要依賴外部參數配置或數據庫記錄。
總結一下,snowflake ID 的優缺點和使用場景:
-
優點:
高性能、低延遲、去中心化、按時間總體有序
-
缺點:
要求機器時鐘同步(到秒級即可),需要解決 時鐘回撥問題
如果某臺機器的系統時鐘回撥,有可能造成 ID 衝突,或者 ID 亂序。
-
適用場景:
分佈式應用環境的數據主鍵
高併發ID的技術選型
這裏,不用地址的hash 編碼作爲ID
這裏,不用數據庫的自增長ID
這裏,不用redis、mongdb的分佈式ID
最終,
這裏,從發號性能、整體有序(B+樹索引結構更加友好)的角度出發,最終選擇的snowflake算法
snowflake算法的吞吐量在 100W ops +
但是 snowflake算法 問題是啥呢?需要解決時鐘回撥的問題。
如何解決時鐘回撥的問題,可以參考 推特官方的 代碼、 百度ID的代碼、Shardingjdbc ID的源碼,綜合存儲方案設計解決。
5、數據存儲的高併發架構
這個數據,非常的結構化,可以使用結構化數據庫MYSQL存儲。
結構非常簡單,我們會有二列:
1. ID,int, // 分佈式雪花id;
2. SURL,varchar, // 原始URL;
接下來,開始高併發、海量數據場景,需要進行 MYSQL存儲 的分庫分表架構。
陳某提示,這裏可以說說自己的分庫分表 操作經驗,操作案例。
然後進行 互動式作答。
也就是,首先是進行 輸入條件 詢問,並且進行確認。
然後按照分治模式,進行兩大維度的分析架構:
- 數據容量(存儲規模) 的 分治架構、
- 訪問流量 (吞吐量規模)的 分治架構。
這塊內容涉的方案,不同的項目,基本是相通的。
6、二義性檢查的高併發架構
所謂的地址二義性,就行同一個長址多次請求得到的短址不一樣。
在生產地址的時候,需要進行二義性檢查,防止每次都會重新爲該長址生成一個短址,一個個長址多次請求得到的短址是不一樣。
通過二義性檢查,實現長短鏈接真正意義上的一對一。
怎麼進行 二義性檢查?
最簡單,最爲粗暴的方案是:直接去數據庫中檢查。
但是,這就需要付出很大的性能代價。
要知道:
數據庫主鍵不是 原始url,而是 短鏈url 。
如果根據 原始url 去進行存在性檢查,還需要額外建立索引。
問題的關鍵是,數據庫性能特低,沒有辦法支撐超高併發 二義性檢查
所以,這裏肯定不能每次用數據庫去檢查。
這裏很多同學可能會想到另一種方案,就是 redis 的布隆過濾, 把已經生成過了的 原始url,
大致的方案是,可以把已經生成過的 原始url ,在 redis 布隆過濾器中進行記錄。
每次進行二義性檢查,走redis 布隆過濾器。
布隆過濾器就是bitset+多次hash的架構,宏觀上是空間換時間,不對所有的 surl (原始url)進行內容存儲,只對surl進行存在性存儲,這樣就節省大家大量的內存空間。
在數據量比較大的情況下,既滿足時間要求,又滿足空間的要求。
布隆過濾器的巨大用處就是,能夠迅速判斷一個元素是否在一個集合中。
布隆過濾器的常用使用場景如下:
- 黑名單 : 反垃圾郵件,從數十億個垃圾郵件列表中判斷某郵箱是否垃圾郵箱(同理,垃圾短信)
- URL去重 : 網頁爬蟲對 URL 的去重,避免爬取相同的 URL 地址
- 單詞拼寫檢查
- Key-Value 緩存系統的 Key 校驗 (緩存穿透) : 緩存穿透,將所有可能存在的數據緩存放到布隆過濾器中,當黑客訪問不存在的緩存時迅速返回避免緩存及 DB 掛掉。
- ID 校驗,比如訂單系統查詢某個訂單 ID 是否存在,如果不存在就直接返回。
Bloom Filter 專門用來解決我們上面所說的去重問題的,使用 Bloom Filter 不會像使用緩存那麼浪費空間。
當然,他也存在一個小小問題,就是不太精確。
規則是:存在不一定存在,說不存在一定不存在
Bloom Filter 相當於是一個不太精確的 set 集合,我們可以利用它裏邊的 contains 方法去判斷某一個對象是否存在,但是需要注意,這個判斷不是特別精確。
一般來說,通過 contains 判斷某個值不存在,那就一定不存在,但是判斷某個值存在的話,則他可能不存在。
那麼對於 surl,處理的方案是:
- 如果 redis bloom filter 不存在,直接生成
- 否則,如果 redis bloom filter 判斷爲存在,可能是誤判,還需要進行db的檢查。
但是, redis bloom filter誤判的概率很低,合理優化之後,也就在1%以下。
可能有小夥伴說,如果100Wqps,1%也是10W1ps,DB還是扛不住,怎麼辦?
可以使用緩存架構,甚至多級緩存架構
具體來說,可以使用 Redis 緩存進行 熱門url的緩存,實現部分地址的一對一緩存
比如將最近/最熱門的對應關係存儲在K-V數據庫中,比如在本地緩存 Caffeine中存儲最近生成的長對短的對應關係,並採用過期機制實現 LRU 淘汰,從而保證頻繁使用的 URL 的總是對應同一個短址的,但是不保證不頻繁使用的URL的對應關係,從而大大減少了空間上的消耗。
7、映射模塊(/轉換模塊)高併發架構
這裏,主要是介紹自己對 多級緩存的 掌握和了解。
可以使用了緩存,二級緩存、三級緩存,加快id 到 surl的轉換。
簡單的緩存方案
將熱門的長鏈接(需要對長鏈接進來的次數進行計數)、最近的長鏈接(可以使用 Redis 保存最近一個小時的數據)等等進行一個緩存,如果請求的長URL命中了緩存,那麼直接獲取對應的短URL進行返回,不需要再進行生成操作
補充服務間的重定向301 和 302 的不同
301永久重定向和 302 臨時重定向。
- 301永久重定向:第一次請求拿到長鏈接後,下次瀏覽器再去請求短鏈的話,不會向短網址服務器請求了,而是直接從瀏覽器的緩存裏拿,減少對服務器的壓力。
- 302臨時重定向:每次去請求短鏈都會去請求短網址服務器(除非響應中用 Cache-Control 或 Expired 暗示瀏覽器進行緩存)
使用 301 雖然可以減少服務器的壓力,但是無法在 server 層獲取到短網址的訪問次數了,如果鏈接剛好是某個活動的鏈接,就無法分析此活動的效果以及用於大數據分析了。
而 302 雖然會增加服務器壓力,但便於在 server 層統計訪問數,所以如果對這些數據有需求,可以採用 302,因爲這點代價是值得的,但是具體採用哪種跳轉方式,還是要結合實際情況進行選型。
8、架構的魅力
架構魅力,在於沒有最好的方案,只有更好的方案,大家如果有疑問,或者更好的方案,可以多多交流。