一种流行的短地址生成方案的原理、Bug及扩展
2021 Mar 23
See all posts
最近接手了一个新任务——扩展目前的短地址服务,目前的短地址长度是8位的,如
https://some_short_domain/h1fSpPka
。业务方嫌这个地址太长——往往就因为这个短地址太长,营销短信被拆成两条了成本翻倍,
换更短的垃圾短域名又要花钱换不起,只能往短地址码榨点油水,本着灵活的原则,答应了业务方,也别改成6位了,1-8位爱用几位用几位,都支持。
原理
仔细看了一下目前正在运行的短地址方案,发现前人设计得相当简洁实用:
数据库设计是MySQL单表,虽然是单表,但是目前够用,才几千万条记录,也没有复杂字段,暂时无压力,数据库关键字段如下:
ID
是 PRIMARY KEY 类型bigint(20)
自增长,无实际业务含义没毛病;
SHORT_URL
是短地址码(如上面的例子 h1fSpPka
)是表的unique key,
用数据库来约束短地址不重复,从源头上避免了脏数据的可能性;
LONG_URL
是原始长地址 varchar(1024) NOT
NULL也还可以;
短地址生成算法是这样的:
(0). 短地址的字符串取值范围为一个62个字符的表[a-zA-Z0-9];
(1).
对原始长地址进行MD5摘要算法得到一个32个字符的串,分成4段,每段8个字符;
(2). 对每个8字符的字符串,把它看作一个16进制数;
为什么可以看作16进制数?
因为MD5结果是128bit,也就是length=16的byte数组,每个byte是0x00 ~
0xff,当然就是16进制数,于是MD5就是32个字符,每8个字符可以看作0x00000000
~ 0xffffffff
对这个数,接下来要开始生成短地址字符了——方法是:把这个数 &
62(0x0000003D),也就是把它和62进行按位"与"操作,得到一个index,显然
\(0 \leq index \leq
61\),于是可以用index在62个字符表里取到一个字符;
(3).
把上一步的这个数右移4位,得到一个新的数,还是按照上面的方法再得到一个字符,这样重复8次,就得到了8个字符,短地址生成完毕;
(4).
每段都可以生成一个短地址,一个MD5值一共生成4个短地址作为候选,哪个没被占用用哪个;
总之,对于短地址的生成,就是找到一个快速的随机字符生成器,哪个没有被占用用哪个,占用后就把这个短地址-长地址映射持久化到数据库。
具体算法代码如下:
public static List<String> encode(String longUrl, Charset charset) throws NoSuchAlgorithmException {
String md5 = buildMD5(longUrl, charset);
return buildShortCode(md5);
}
private static List<String> buildShortCode(String md5) {
Objects.requireNonNull(md5, "md5 must not be null.");
List<String> codes = new LinkedList<>();
//将32个字符的md5码分成4段处理,每段8个字符
for (int i = 0; i < 4; i++) {
int offset = i * 8;
String sub = md5.substring(offset, offset + 8);
long sub16 = Long.parseLong(sub, 16); //将sub当作一个16进制的数,转成long
// & 0X3FFFFFFF,去掉最前面的2位,只留下30位
// sub16 &= 0X3FFFFFFF;
StringBuilder sb = new StringBuilder();
//32位分8段处理,每段4位
for (int j = 0; j < 8; j++) {
//得到一个 <= 61的数字
long t = sub16 & 0x0000003D;
sb.append(chars[(int) t]);
sub16 >>= 4; //将sub16右移4位
}
codes.add(sb.toString());
}
return codes;
这个技术方案还有很多亮点,比如:
设计清晰:
Java后端只负责生成、解析短地址码,并对业务方后端提供REST接口,
也就是只负责生成 https://some_short_domain/h1fSpPka 中的
h1fSpPka。
至于域名部分,交给Nginx,这样有什么好处?好处太大了,如果营销太甚整个域名被运营商或微信封了,没关系,马上换个域名继续,不影响下一刻的营销~~,后端Java短地址服务完全无感,话说现在已经换了好多域名了,而Java后端程序一直稳如老狗,顶多改改配置文件,真是机智啊。
代码简洁:
因为功能单一,服务器直接用EmbeddedJettyServer,开了3个handler,一个encode
hanlder来处理长地址->短地址,一个decode
handler来处理短地址->长地址、异步记录log等,还良心加了一个web
handler来方便调试,简洁实用。
性能佳:
数据库索引,DAO层缓存都有,server也可以多开,起两份service能7*24
hold住所有请求
功能全:
该有的都有,权限控制,域名白名单控制都有
Bug
1. 短地址生成算法的bug
如果仔细观察上面的代码,就会发现一个错误,long t = sub16 &
0x0000003D
这样确实能得到一个<=62的数,但是这样做就是对的吗?
显然不是,正确的做法应该是 long t = sub16 %
62, 0x3D=0b00111101 和它 &
会丢掉很多字符,其中为0的bit位都会永远位0,如果字符表是:
public static String[] chars = {"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" }
它会这样造成如cdghklopstwx014589CDGHKLOPSTWX
永远不出现,62位的字符表瞬间缩水一半,再来个8次方,值域空间只达到了设计的\((1/2)^8=0.0039\),不到1%,悲剧,大大增加了碰撞概率。
为什么会犯这样的错误?我觉得可能是太想当然了,虽然&比%快得多,但是只有当\(m = 2^n-1\) 时,& m 才等价于 % m。
62位的字符表再加个1字符如-
就没有bug了。
2. 可用性bug
上面为每个长地址生成了4个短地址,如果没有被占用当然皆大欢喜,如果都被占用了怎么办?原来的代码是直接返回null罢工,这肯定是业务方无法接受的。
解决方式也很简单,如果4个候选短地址都被占用了,就为长地址加一点随机字符串如当前时间戳(当然最后数据库保存长短地址映射关系的时候还是原始的长地址),然后再算一次,多算几次肯定没有问题,毕竟短地址的值域空间大小是
\(62^8=218340105584896\)。
扩展
1. 短地址位数扩展
没有什么好说的,直接上代码,length长度必须[1,
8]在最外面就校验了,略去不表:
private static List<String> buildShortCode(String md5, Integer length) {
Objects.requireNonNull(md5, "md5 must not be null.");
List<String> codes = new LinkedList<>();
for (int i = 0; i < 4; i++) {
int offset = i * 8;
String sub = md5.substring(offset, offset + 8);
long sub16 = Long.parseLong(sub, 16); //将sub当作一个16进制的数,转成long
StringBuilder sb = new StringBuilder();
for (int j = 0; j < length; j++) {
long t = sub16 % 62;
sb.append(chars[(int) t]);
sub16 >>= 32 / length;
}
codes.add(sb.toString());
}
return codes;
}
2. 分布式扩展
(1).
目前是单表,如果营销活动暴增,一年内生成的短地址轻松上亿,MySQL单表可能有点坑了,分表吧。
方案如下,按照短地址码第一个字符分62张表,如SHORT_URL_REF_0 ~
SHORT_URL_REF_61,62亿个短地址很长时间都够用了;如果到时候再不够,再裂变一下,按照前两个字符分,62
* 62 =
3600亿短地址,全地球人都可以用到天荒地老,而且逻辑简单,可以平滑升级。
分表代码:
@NotNull
@Override
protected String calculateTableName(@NotNull String template, @NotNull ShortUrlRef document) {
Assertions.notNull(document);
String shortUrl = document.getShortUrl();
Assertions.notNull(shortUrl);
char c = shortUrl.charAt(0);
int index = 0;
if (c >= '0' && c <= '9') {
index = c - '0';
} else if (c >= 'a' && c <= 'z') {
index = c - 'a' + 10;
} else if (c >= 'A' && c <= 'Z') {
index = c - 'A' + 36;
}
return StringUtils.formatMessage(template, index);
}
(2). 唯一性检查
之前判断短地址码的有没有被占用是通过unique
key来保证的,现在分表了,那就通过分布式 bloom
filter来搞吧,bm算法占用内存小效率又高——最后选取的是redis内置的bloom
filter,不再赘述。
总结
短地址服务功能相对独立,对上下游的依赖少,开发也好,后期扩展也好都相对容易,是系统设计中一个非常经典的设计场景,即便如此,也不要太掉以轻心,精益求精才能不断进步。
一种流行的短地址生成方案的原理、Bug及扩展
2021 Mar 23 See all posts最近接手了一个新任务——扩展目前的短地址服务,目前的短地址长度是8位的,如
https://some_short_domain/h1fSpPka
。业务方嫌这个地址太长——往往就因为这个短地址太长,营销短信被拆成两条了成本翻倍, 换更短的垃圾短域名又要花钱换不起,只能往短地址码榨点油水,本着灵活的原则,答应了业务方,也别改成6位了,1-8位爱用几位用几位,都支持。原理
仔细看了一下目前正在运行的短地址方案,发现前人设计得相当简洁实用:
数据库设计是MySQL单表,虽然是单表,但是目前够用,才几千万条记录,也没有复杂字段,暂时无压力,数据库关键字段如下:
ID
是 PRIMARY KEY 类型bigint(20) 自增长,无实际业务含义没毛病;SHORT_URL
是短地址码(如上面的例子 h1fSpPka )是表的unique key, 用数据库来约束短地址不重复,从源头上避免了脏数据的可能性;LONG_URL
是原始长地址 varchar(1024) NOT NULL也还可以;短地址生成算法是这样的:
总之,对于短地址的生成,就是找到一个快速的随机字符生成器,哪个没有被占用用哪个,占用后就把这个短地址-长地址映射持久化到数据库。
具体算法代码如下:
这个技术方案还有很多亮点,比如:
设计清晰: Java后端只负责生成、解析短地址码,并对业务方后端提供REST接口, 也就是只负责生成 https://some_short_domain/h1fSpPka 中的 h1fSpPka。 至于域名部分,交给Nginx,这样有什么好处?好处太大了,如果营销太甚整个域名被运营商或微信封了,没关系,马上换个域名继续,不影响下一刻的营销~~,后端Java短地址服务完全无感,话说现在已经换了好多域名了,而Java后端程序一直稳如老狗,顶多改改配置文件,真是机智啊。
代码简洁: 因为功能单一,服务器直接用EmbeddedJettyServer,开了3个handler,一个encode hanlder来处理长地址->短地址,一个decode handler来处理短地址->长地址、异步记录log等,还良心加了一个web handler来方便调试,简洁实用。
性能佳: 数据库索引,DAO层缓存都有,server也可以多开,起两份service能7*24 hold住所有请求
功能全: 该有的都有,权限控制,域名白名单控制都有
Bug
1. 短地址生成算法的bug
如果仔细观察上面的代码,就会发现一个错误,long t = sub16 & 0x0000003D 这样确实能得到一个<=62的数,但是这样做就是对的吗?
显然不是,正确的做法应该是 long t = sub16 % 62, 0x3D=0b00111101 和它 & 会丢掉很多字符,其中为0的bit位都会永远位0,如果字符表是:
它会这样造成如
cdghklopstwx014589CDGHKLOPSTWX
永远不出现,62位的字符表瞬间缩水一半,再来个8次方,值域空间只达到了设计的\((1/2)^8=0.0039\),不到1%,悲剧,大大增加了碰撞概率。 为什么会犯这样的错误?我觉得可能是太想当然了,虽然&比%快得多,但是只有当\(m = 2^n-1\) 时,& m 才等价于 % m。 62位的字符表再加个1字符如-
就没有bug了。2. 可用性bug
上面为每个长地址生成了4个短地址,如果没有被占用当然皆大欢喜,如果都被占用了怎么办?原来的代码是直接返回null罢工,这肯定是业务方无法接受的。
解决方式也很简单,如果4个候选短地址都被占用了,就为长地址加一点随机字符串如当前时间戳(当然最后数据库保存长短地址映射关系的时候还是原始的长地址),然后再算一次,多算几次肯定没有问题,毕竟短地址的值域空间大小是 \(62^8=218340105584896\)。
扩展
1. 短地址位数扩展
没有什么好说的,直接上代码,length长度必须[1, 8]在最外面就校验了,略去不表:
2. 分布式扩展
(1). 目前是单表,如果营销活动暴增,一年内生成的短地址轻松上亿,MySQL单表可能有点坑了,分表吧。
方案如下,按照短地址码第一个字符分62张表,如SHORT_URL_REF_0 ~ SHORT_URL_REF_61,62亿个短地址很长时间都够用了;如果到时候再不够,再裂变一下,按照前两个字符分,62 * 62 = 3600亿短地址,全地球人都可以用到天荒地老,而且逻辑简单,可以平滑升级。
分表代码:
(2). 唯一性检查
之前判断短地址码的有没有被占用是通过unique key来保证的,现在分表了,那就通过分布式 bloom filter来搞吧,bm算法占用内存小效率又高——最后选取的是redis内置的bloom filter,不再赘述。
总结
短地址服务功能相对独立,对上下游的依赖少,开发也好,后期扩展也好都相对容易,是系统设计中一个非常经典的设计场景,即便如此,也不要太掉以轻心,精益求精才能不断进步。