短连接,通俗来讲,就是将长的 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; } }