短連接,通俗來講,就是將長的 URL 網址,經過程序計算等方式,轉換爲簡短的網址字符串。html
你們常常會收到一些莫名的營銷短信,裏面有一個很是短的連接讓你跳轉。新浪微博由於限制字數,因此也會常常見到這種看着不像網址的網址。短鏈的興起應該就是微博限制字數激起了你們的創造力。java
若是建立一個短鏈系統,咱們應該作什麼呢?git
將長連接變爲短鏈;redis
用戶訪問短連接,會跳轉到正確的長連接上去。算法
查找到對應的長網址,並跳轉到對應的頁面。數據庫
短碼通常是由[a - z, A - Z, 0 - 9]
這 62 個字母或數字組成,短碼的長度也能夠自定義,但通常不超過 8 位。比較經常使用的都是 6 位,6 位的短碼已經能有 568 億種的組合:(26+26+10)^6 = 56800235584,已知足絕大多數的使用場景。apache
目前比較流行的生成短碼方法有:自增id
、摘要算法
、普通隨機數
。分佈式ID生成器的解決方案總結,這篇也參考看下。json
該方法是一種無碰撞的方法,原理是,每新增一個短碼,就在上次添加的短碼 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:連接除了域名外的後綴
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。
關注微信公衆號:Java技術棧,在後臺回覆:redis,能夠獲取我整理的 N 篇最新Redis教程,都是乾貨。
讀數據也會快,緣由是由於它使用了 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進制的轉換 * 一、將十進制的數字轉換爲指定進制的字符串; * 二、將其它進制的數字(字符串形式)轉換爲十進制的數字 */ 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; } }