如何實現一個短連結服務
短連結,通俗來說,就是將長的URL網址,通過程式計算等方式,轉換為簡短的網址字串。
大家經常會收到一些莫名的營銷簡訊,裡面有一個非常短的連結讓你跳轉。新浪微博因為限制字數,所以也會經常見到這種看著不像網址的網址。短鏈的興起應該就是微博限制字數激起了大家的創造力。
如果建立一個短鏈系統,我們應該做什麼呢?
- 將長連結變為短鏈;
- 使用者訪問短連結,會跳轉到正確的長連結上去。
查詢到對應的長網址,並跳轉到對應的頁面。
短鏈生成方法
短碼一般是由 [a - z, A - Z, 0 - 9]
這62 個字母或數字組成,短碼的長度也可以自定義,但一般不超過8位。比較常用的都是6位,6位的短碼已經能有568億種的組合:(26+26+10)^6 = 56800235584,已滿足絕大多數的使用場景。
目前比較流行的生成短碼方法有:自增id
、摘要演算法
、普通隨機數
。
自增id
該方法是一種無碰撞的方法,原理是,每新增一個短碼,就在上次新增的短碼id基礎上加1,然後將這個10進位制的id值,轉化成一個62進位制的字串。
一般利用資料表中的自增id來完成:每次先查詢資料表中的自增id最大值max,那麼需要插入的長網址對應自增id值就是 max+1,將max+1轉成62進位制即可得到短碼。
但是短碼 id 是從一位長度開始遞增,短碼的長度不固定,不過可以用 id 從指定的數字開始遞增的方式來處理,確保所有的短碼長度都一致。同時,生成的短碼是有序的,可能會有安全的問題,可以將生成的短碼id,結合長網址等其他關鍵字,進行md5運算生成最後的短碼。
摘要演算法
摘要演算法又稱雜湊演算法,它表示輸入任意長度的資料,輸出固定長度的資料。相同的輸入資料始終得到相同的輸出,不同的輸入資料儘量得到不同的輸出。
演算法過程:
- 將長網址md5生成32位簽名串,分為4段, 每段8個位元組;
- 對這四段迴圈處理, 取8個位元組, 將他看成16進位制串與0x3fffffff(30位1)與操作, 即超過30位的忽略處理;
- 這30位分成6段, 每5位的數字作為字母表的索引取得特定字元, 依次進行獲得6位字串;
- 總的md5串可以獲得4個6位串;取裡面的任意一個就可作為這個長url的短url地址;
這種演算法,雖然會生成4個,但是仍然存在重複機率。
雖然機率很小,但是該方法依然存在碰撞的可能性,解決衝突會比較麻煩。不過該方法生成的短碼位數是固定的,也不存在連續生成的短碼有序的情況。
普通隨機數
該方法是從62個字串中隨機取出一個6位短碼的組合,然後去資料庫中查詢該短碼是否已存在。如果已存在,就繼續迴圈該方法重新獲取短碼,否則就直接返回。
該方法是最簡單的一種實現,不過由於Math.round()
方法生成的隨機數屬於偽隨機數,碰撞的可能性也不小。在資料比較多的情況下,可能會迴圈很多次,才能生成一個不衝突的短碼。
演算法分析
以上演算法利弊我們一個一個來分析。
如果使用自增id演算法,會有一個問題就是不法分子是可以窮舉你的短鏈地址的。原理就是將10進位制數字轉為62進位制,那麼別人也可以使用相同的方式遍歷你的短鏈獲取對應的原始連結。打個比方說:http://tinyurl.com/a3300和 http://bit.ly/a3300,這兩個短鏈網站,分別從a3300 - a3399,能夠試出來多次返回正確的url。所以這種方式生成的短鏈對於使用者來說其實是不安全的。
摘要演算法,其實就是hash演算法吧,一說hash大家可能覺得很low,但是事實上hash可能是最優解。比如:http://www.sina.lt/ 和 http://mrw.so/ 連續生成的url發現並沒有規律,很有可能就是使用hash演算法來實現。
普通隨機數演算法,這種演算法生成的東西和摘要演算法一樣,但是碰撞的概率會大一些。因為摘要演算法畢竟是對url進行hash生成,隨機數演算法就是簡單的隨機生成,數量一旦上來必然會導致重複。
綜合以上,我選擇最low的演算法:摘要演算法。
實現
儲存方案
資料庫儲存方案
短網址基礎資料採用域名和字尾分開儲存的形式。另外域名需要區分 HTTP 和 HTTPS,hash方案針對整個連結進行hash而不是除了域名外的連結。域名單獨儲存可以用於分析當前域名下連結的使用情況。
增加當前連結有效期欄位,一般有短鏈需求的可能是相關活動或者熱點事件,這種短鏈在一段時間內會很活躍,過了一定時間熱潮會持續衰退。所以沒有必要將這種連結永久儲存增加每次查詢的負擔。
對於過期資料的處理,可以在新增短鏈的時候判斷當前短鏈的失效日期,將每天到達失效日期的資料在HBase單獨建一張表,有新增的時候判斷失效日期放到對應的HBase表中即可,每天只用處理當天HBase表中的失效資料。
資料庫基礎表如下:
base_url | suffix_url | shot_code | total_click_count | full_url | expiration_date |
---|---|---|---|---|---|
http://www.aichacha.com | /search/12345 | edfg3s | http://www.aichacha.com//search/12345 | ||
http://www.aichacha.com | /aiCheck/getResult/123 | Fe9dq | http://www.aichacha.com//aiCheck/getResult/123 | ||
http://www.baidu.com | /wenku/12354 | lcfr53 | http://www.baidu.com/wenku/12354 |
欄位釋義:
base_url:域名
suffix_url:連結除了域名外的字尾
full_url:完整連結
shot_code:當前 suffix_url 連結的短碼
expiration_date:失效日期
total_click_count:當前連結總點選次數
expiration_date:當前連結失效時間
快取方案
- 查詢需求
個人認為對於幾百個G的資料量都放在快取肯定是不合適的,所以有個折中的方案:將最近3個月內有查詢或者有新增的url放入快取,使用LRU演算法進行熱更新。這樣最近有使用的發概率會命中快取,就不用走庫。查不到的時候再走庫更新快取。
- 新增需求
對於新增的連結就先查快取是否存在,快取不存在再查庫,資料庫已經分表了,查詢的效率也不會很低。
- 快取的設計
查詢的需求是使用者拿著短鏈查詢對應的真實地址,那麼快取的key只能是短鏈,可以使用 KV的形式儲存。
番外
其實也可以考慮別的儲存方案,比如HBase,HBase作為NOSQL資料庫,效能上僅次於redis但是儲存成本比redis低很多個數量級,儲存基於HDFS,寫資料的時候會先先寫入記憶體中,只有記憶體滿了會將資料刷入到HFile。讀資料也會快,原因是因為它使用了LSM樹型結構,而不是B或B+樹。HBase會將最近讀取的資料使用LRU演算法放入快取中,如果想增強讀能力,可以調大blockCache。
其次,也可以使用ElasticSearch,合適的索引規則效果不輸快取方案。
是否有分庫分表的需要?
對於單條資料10b以內,一億條資料總容量大約為 953G,單表肯定無法撐住這麼大的量,所以有分表的需要,如果你對服務很有信心2年內能達到這個規模,那麼你可以從一開始設計就考慮分表的方案。
那麼如何定義分表的規則呢?
如果按照單表500萬條記錄來算,總計可以分為20張表,那麼單表容量就是47G,還是挺大,所以考慮分表的 key 和單表容量,如果分為100張表那麼單表容量就是10G,並且通過數字字尾路由到表中也比較容易。可以對short_code 做encoding編碼生成數字型別然後做路由。
如何轉跳
當我們在瀏覽器裡輸入 http://bit.ly/a3300 時
- DNS首先解析獲得 http://bit.ly的
IP
地址 - 當
DNS
獲得IP
地址以後(比如:12.34.5.32),會向這個地址傳送HTTP
GET
請求,查詢短碼a3300
- [http://bit.ly 伺服器會通過短碼
a3300
獲取對應的長 URL - 請求通過
HTTP
301
轉到對應的長 URL http://www.theaustralian.news.com.au/story/0,25197,26089617-5013871,00.html。
這裡有個小的知識點,為什麼要用 301 跳轉而不是 302 吶?
知識點:為什麼要使用302跳轉,而不是301跳轉呢?
301是永久重定向,302是臨時重定向。短地址一經生成就不會變化,所以用301是符合http語義的。但是如果用了301, Google,百度等搜尋引擎,搜尋的時候會直接展示真實地址,那我們就無法統計到短地址被點選的次數了,也無法收集使用者的Cookie, User Agent 等資訊,這些資訊可以用來做很多有意思的大資料分析,也是短網址服務商的主要盈利來源。
引自知乎-武林的回答,原文連結
附上兩個演算法:
摘要演算法:
import org.apache.commons.lang3.StringUtils;
import javax.xml.bind.DatatypeConverter;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.atomic.AtomicLong;
import static com.alibaba.fastjson.util.IOUtils.DIGITS;
/**
* @author rickiyang
* @date 2020-01-07
* @Desc TODO
*/
public class ShortUrlGenerator {
public static void main(String[] args) {
String sLongUrl = "http://www.baidu.com/121244/ddd";
for (String shortUrl : shortUrl(sLongUrl)) {
System.out.println(shortUrl);
}
}
public static String[] shortUrl(String url) {
// 可以自定義生成 MD5 加密字元傳前的混合 KEY
String key = "dwz";
// 要使用生成 URL 的字元
String[] chars = new String[]{"a", "b", "c", "d", "e", "f", "g", "h",
"i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
"u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5",
"6", "7", "8", "9", "A", "B", "C", "D", "E", "F", "G", "H",
"I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
"U", "V", "W", "X", "Y", "Z"
};
// 對傳入網址進行 MD5 加密
String sMD5EncryptResult = "";
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update((key + url).getBytes());
byte[] digest = md.digest();
sMD5EncryptResult = DatatypeConverter.printHexBinary(digest).toUpperCase();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
String[] resUrl = new String[4];
//得到 4組短連結字串
for (int i = 0; i < 4; i++) {
// 把加密字元按照 8 位一組 16 進位制與 0x3FFFFFFF 進行位與運算
String sTempSubString = sMD5EncryptResult.substring(i * 8, i * 8 + 8);
// 這裡需要使用 long 型來轉換,因為 Inteper .parseInt() 只能處理 31 位 , 首位為符號位 , 如果不用 long ,則會越界
long lHexLong = 0x3FFFFFFF & Long.parseLong(sTempSubString, 16);
String outChars = "";
//迴圈獲得每組6位的字串
for (int j = 0; j < 6; j++) {
// 把得到的值與 0x0000003D 進行位與運算,取得字元陣列 chars 索引(具體需要看chars陣列的長度 以防下標溢位,注意起點為0)
long index = 0x0000003D & lHexLong;
// 把取得的字元相加
outChars += chars[(int) index];
// 每次迴圈按位右移 5 位
lHexLong = lHexLong >> 5;
}
// 把字串存入對應索引的輸出陣列
resUrl[i] = outChars;
}
return resUrl;
}
}
數字轉為base62演算法:
/**
* @author rickiyang
* @date 2020-01-07
* @Desc TODO
* <p>
* 進位制轉換工具,最大支援十進位制和62進位制的轉換
* 1、將十進位制的數字轉換為指定進位制的字串;
* 2、將其它進位制的數字(字串形式)轉換為十進位制的數字
*/
public class NumericConvertUtils {
public static void main(String[] args) {
String str = toOtherNumberSystem(22, 62);
System.out.println(str);
}
/**
* 在進製表示中的字元集合,0-Z分別用於表示最大為62進位制的符號表示
*/
private static final char[] digits = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'};
/**
* 將十進位制的數字轉換為指定進位制的字串
*
* @param number 十進位制的數字
* @param seed 指定的進位制
* @return 指定進位制的字串
*/
public static String toOtherNumberSystem(long number, int seed) {
if (number < 0) {
number = ((long) 2 * 0x7fffffff) + number + 2;
}
char[] buf = new char[32];
int charPos = 32;
while ((number / seed) > 0) {
buf[--charPos] = digits[(int) (number % seed)];
number /= seed;
}
buf[--charPos] = digits[(int) (number % seed)];
return new String(buf, charPos, (32 - charPos));
}
/**
* 將其它進位制的數字(字串形式)轉換為十進位制的數字
*
* @param number 其它進位制的數字(字串形式)
* @param seed 指定的進位制,也就是引數str的原始進位制
* @return 十進位制的數字
*/
public static long toDecimalNumber(String number, int seed) {
char[] charBuf = number.toCharArray();
if (seed == 10) {
return Long.parseLong(number);
}
long result = 0, base = 1;
for (int i = charBuf.length - 1; i >= 0; i--) {
int index = 0;
for (int j = 0, length = digits.length; j < length; j++) {
//找到對應字元的下標,對應的下標才是具體的數值
if (digits[j] == charBuf[i]) {
index = j;
}
}
result += index * base;
base *= seed;
}
return result;
}
}