[場景設計]短連線服務

Duancf發表於2024-09-09

100Wqps短鏈系統,怎麼設計?

這道題,看上去業務簡單,其實,覆蓋的知識點非常多:

高併發、高效能分散式 ID
Redis Bloom Filter 高併發、低記憶體損耗的 過濾元件知識
分庫、分表海量資料儲存
多級快取的知識
HTTP傳輸知識
二進位制、十六進位制、六十二進位制知識
總體來說,高併發、高效能系統的核心領域,都覆蓋了。所以,陳某分析下來,得到一個結論:是一個超級好的問題。

短URL系統的背景

短網址替代長URL,在網際網路網上傳播和引用。
例如QQ微博的url.cn,新郎的sinaurl.cn等。
在QQ、微博上釋出網址的時候,會自動判別網址,並將其轉換,例如:http://url.cn/2hytQx
為什麼要這樣做的,無外乎幾點:

  1. 縮短地址長度,留足更多空間的給有意義的內容
  2. URL是沒有意義的,有的原始URL很長,佔用有效的螢幕空間。
  3. 微博限制字數為140字一條,那麼如果這個連線非常的長,以至於將近要佔用我們內容的一半篇幅,這肯定是不能被允許的,連結變短,對於有長度限制的平臺發文,可編輯的文字就變多了,所以短網址應運而生了。
  4. 可以很好的對原始URL內容管控。
  5. 有一部分網址可以會涵蓋XX,暴力,廣告等資訊,這樣我們可以透過使用者的舉報,完全管理這個連線將不出現在我們的應用中,應為同樣的URL透過加密演算法之後,得到的地址是一樣的。
  6. 可以很好的對原始URL進行行為分析
  7. 我們可以對一系列的網址進行流量,點選等統計,挖掘出大多數使用者的關注點,這樣有利於我們對專案的後續工作更好的作出決策。
  8. 短網址和短ID相當於間接提高了頻寬的利用率、節約成本
  9. 連結太長在有些平臺上無法自動識別為超連結
  10. 短連結更加簡潔好看且安全,不暴露訪問引數。而且,能規避關鍵詞、域名遮蔽等手段

短URL系統的原理

短URL系統的核心:將長的 URL 轉化成短的 URL。

客戶端在訪問系統時,短URL的工作流程如下:

  • 先使用短地址A訪問 短鏈Java 服務
  • 短鏈Java 服務 進行 地址轉換和對映,將 短URL系統對映到對應的長地址URL
  • 短鏈Java 服務 返回302 重定向 給客戶端
  • 然後客戶端再重定向到原始服務

那麼,原始URL如何變短呢?簡單來說, 可以將原始的地址,使用編號進行替代

編號如何進一步變短呢?可以使用更大的進位制來表示

六十二進位制表示法

顧名思義短網址就是非常短的網址,比如http://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進位制是個權衡折中。

短 URL 系統的功能分析

假設短地址長度為8位,62的8次方足夠一般系統使用了

系統核心實現,包含三個大的功能

  • 發號
  • 儲存
  • 對映
    可以分為兩個模組:發號與儲存模組、對映模組

發號與儲存模組

發號:使用發號器發號 , 為每個長地址分配一個號碼ID,並且需要防止地址二義,也就是防止同一個長址多次請求得到的短址不一樣
儲存:將號碼與長地址存放在DB中,將號碼轉化成62進位制,用於表示最終的短地址,並返回給使用者

對映模組

使用者使用62進位制的短地址請求服務 ,

轉換:將62進位制的數轉化成10進位制,因為咱們系統內部是long 型別的10進位制的數字ID
對映:在DB中尋找對應的長地址
透過302重定向,將使用者請求重定向到對應的地址上

發號器的高併發架構

回顧一下發號器的功能:

為每個長地址分配一個號碼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年發表。關注公眾號:碼猿技術專欄,回覆關鍵詞:1111 獲取阿里內部Java效能調優手冊~

生成的數字在傳輸或者儲存之前計算出來並且附加到資料後面,然後接收方進行檢驗確定資料是否發生變化。

由於本函式易於用二進位制的電腦硬體使用、容易進行數學分析並且尤其善於檢測傳輸通道干擾引起的錯誤,因此獲得廣泛應用。

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的原始碼,綜合儲存方案設計解決。

資料儲存的高併發架構

這個資料,非常的結構化,可以使用結構化資料庫MYSQL儲存。

結構非常簡單,我們會有二列:

  1. ID,int, // 分散式雪花id;
  2. SURL,varchar, // 原始URL;

接下來,開始高併發、海量資料場景,需要進行 MYSQL儲存 的分庫分表架構。

陳某提示,這裡可以說說自己的分庫分表 操作經驗,操作案例。
然後進行 互動式作答。
也就是,首先是進行 輸入條件 詢問,並且進行確認。

然後按照分治模式,進行兩大維度的分析架構:

資料容量(儲存規模) 的 分治架構、
訪問流量 (吞吐量規模)的 分治架構。

這塊內容涉的方案,不同的專案,基本是相通的。

二義性檢查的高併發架構

所謂的地址二義性,就行同一個長址多次請求得到的短址不一樣。

在生產地址的時候,需要進行二義性檢查,防止每次都會重新為該長址生成一個短址,一個個長址多次請求得到的短址是不一樣。

透過二義性檢查,實現長短連結真正意義上的一對一。

怎麼進行 二義性檢查?

最簡單,最為粗暴的方案是:直接去資料庫中檢查。

但是,這就需要付出很大的效能代價。

要知道:

資料庫主鍵不是 原始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的對應關係,從而大大減少了空間上的消耗。

對映模組(/轉換模組)高併發架構

這裡,主要是介紹自己對 多級快取的 掌握和了解。

可以使用了快取,二級快取、三級快取,加快id 到 surl的轉換。

簡單的快取方案
將熱門的長連結(需要對長連結進來的次數進行計數)、最近的長連結(可以使用 Redis 儲存最近一個小時的資料)等等進行一個快取,如果請求的長URL命中了快取,那麼直接獲取對應的短URL進行返回,不需要再進行生成操作

補充服務間的重定向301 和 302 的不同

301永久重定向和 302 臨時重定向。

301永久重定向:第一次請求拿到長連結後,下次瀏覽器再去請求短鏈的話,不會向短網址伺服器請求了,而是直接從瀏覽器的快取裡拿,減少對伺服器的壓力。
302臨時重定向:每次去請求短鏈都會去請求短網址伺服器(除非響應中用 Cache-Control 或 Expired 暗示瀏覽器進行快取)
使用 301 雖然可以減少伺服器的壓力,但是無法在 server 層獲取到短網址的訪問次數了,如果連結剛好是某個活動的連結,就無法分析此活動的效果以及用於大資料分析了。

而 302 雖然會增加伺服器壓力,但便於在 server 層統計訪問數,所以如果對這些資料有需求,可以採用 302,因為這點代價是值得的,但是具體採用哪種跳轉方式,還是要結合實際情況進行選型。

相關文章