一、为什么要用短链
- 节省空间,适用于字符数量有限的场合,比如短信
- 美化链接,复杂多参数链接看着就恶心
- 访问数据采集,集中采集、分析访问数据
- 隐藏原始链接,某些需要屏蔽 URL 采集的场景下有效
- 可以实现虚拟固定连接,用于永久保存(比如二维码),背后的长链可调整
短链的组成:
(短链的形式并不固定,根据公司自身情况指定即可,常见的形式如上)
二、短链的工作流程
主要分为两部分:
- 长链查找,通过路径参数(如:xx.cn/a 中的 a)查找对应的长链
- 长链重定向
三、长链变短链的算法实现
长链转短链就是将长链转换为短链里的路径参数,即 xx.cn/a 中的 “a”,实际映射关系为:a -> www.a.com/a/b/c/d.htm…
如何将 www.abc.com/a/b.html 转换… “a” ?
Hash算法
将一个字符串压缩转换为另一个字符串,很容易想到的是哈希算法,下面列举一些常见的哈希算法转换结果:
| 算法 | 转换结果 |
|---|---|
| MD2 | 6f5bfb9b0275436944acc73124d04f13 |
| MD5 | 7c9529777cb1d4e3fbf5b8b873167706 |
| SHA-1 | 6884e316d0af702e6ea7d7f3f937647071c13cd8 |
| SHA-256 | 54fd60579bd63b273d1423f4d8a14cd8d23ba222907c999c1bd126f708aa8c3b |
| SHA-512 | e9fe0601c5743817548afe1c31d5d85ace98514fc4314ef89a546f726caa696a3b4f7e9994313acf9ebf8695fed43674acb1158f2628daf03c14d8c7973c24e1 |
| MurmurHash3 | 2583b14b |
结合上面的转换结果分析来看,存在几个问题:
- 转换后的字符长度并不短
- 哈希算法存在冲突概率,转换结果越短,冲突概率越高
因此,哈希算法并不适用于以上场景。
自增ID
另一个很容易想到的方案是利用数据库表自增主键来做长短链映射的key,例如 1 -> www.a.com/a/b/c/d.htm… 由于短长链映射关系需要存储到数据库中,我们天然就能得到自增id。如是短链变为:
xx.cn/1
xx.cn/2
...
xx.cn/1000000000
能不能更短 ?
当自增ID膨胀到一定数量级时(比如10亿),我们生成的短链则变成 xx.cn/1000000000 ,看起来还不够短,也不够优雅。这时我们可以通过 进制转换 来进一步压缩短链长度。不同进制转化后的效果如下:
| 进制 | 转换后的值 |
|---|---|
| 十进制 | 1000000000(10位) |
| 十六进制 | 3b9aca00(8位 |
| 三十二进制 | tplig0(6位) |
| 自定义进制(六十六进制) | QKkra(5位) |
自定义进制实现:理论上,URL中支持的无需转义字符(去除保留字符)都可以参与到自定义进制算法中。可用字符如下:
- 字母(大小写):a-z, A-Z
- 数字:0-9
- 特殊字符:一些特殊字符也是允许的,如:
-
- 连字符 (-)
- 下划线 (_)
- 句号 (.)
- 波浪线 (~)
可用字符加起来共有66个字符,因此可以实现六十六进制,使用ChatGTP可以帮助我们实现一个六十六进制 -> 十进制的转换算法:
private static final String BASE_66_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-._~";
public static String convert(long number) {
if (number == 0) {
return String.valueOf(BASE_66_CHARS.charAt(0));
}
List<Character> base66Chars = new ArrayList<>();
while (number > 0) {
int remainder = (int) (number % 66);
base66Chars.add(BASE_66_CHARS.charAt(remainder));
number /= 66;
}
StringBuilder result = new StringBuilder();
for (int i = base66Chars.size() - 1; i >= 0; i--) {
result.append(base66Chars.get(i));
}
return result.toString();
}
public static long convert(String base66Value) {
Map<Character, Integer> base66Map = new HashMap<>();
for (int i = 0; i < BASE_66_CHARS.length(); i++) {
base66Map.put(BASE_66_CHARS.charAt(i), i);
}
long result = 0;
int power = 0;
for (int i = base66Value.length() - 1; i >= 0; i--) {
char c = base66Value.charAt(i);
result += base66Map.get(c) * Math.pow(62, power);
power++;
}
return result;
}
四、短链服务的技术架构
短链服务的核心能力主要为:
- 短链转发
- 短链注册(生成)
- 访问数据采集
且都比较简单,对于一个短链能力诉求较低(没啥并发场景、短链规模较小)的公司来说,一般常见的服务端基础技术架构就可支撑。作为研发,基于公司现有的成熟技术栈选择一个落地即可,比如 Java+Redis+MySQL:
(图进行了简化,剔除了LB、网关等)
其中,利用内存缓存+LRU淘汰算法可以应对热点短链问题,Redis可以作为二级缓存可以缓存更多的数据以及实现更灵活的更新同步机制。最后附加弹性扩缩容机制,可以轻松应对流量变化需求。
但这是否是最优解 ?
显然不是,短链服务作为一个公司的基建能力,首要的目标是高性能、高可用、低成本,我们将围绕上述三个目标进行架构上的改造与探索。
从上图可以发现,我们将短链服务的不同能力做了模块分割。使用新的技术栈 OpenResty 来承载短链转发能力,旧的 Java 服务只用来承载短链注册和访问数据采集能力。同时引入了消息队列来作为不同模块之间的解耦媒介。新的架构可能会给大家带来一些疑惑,下面我们来一起讨论以下几个问题:
为什么要做模块分离 ?
-
风险隔离,短链转发作为短链服务最核心的能力,其稳定性要求远高于偏后台能力的短链注册、短链访问数据查询等功能,通过分离设计可以避免后台低性能操作对核心能力带来影响
-
性能优化,可以单独针对核心能力做深度性能优化,单一职责的核心模块也更容易技术选型调整
为什么用 OpenResty ?
OpenResty 就是在 Nginx 的基础上,进行二次开发,嵌入了 Lua 语言,提供接口可以让用户编写 Lua 代码处理用户请求,使得 OpenResty 不仅具有 Nginx 架构的高性能,又兼具了动态性、可编程性。可以通过 Lua 代码使用OpenResty 封装好的接口(cosocket)进行同步编程,构建出可以处理10k甚至100k以上并发请求的 web 应用。
核心原因是快!下面看一组官方性能对比:
So on my laptop, for a single nginx worker, we've got 20k+ r/s. For comparison, HelloWorld servers using nginx + php-fpm 5.2.8 gives 4k r/s, Erlang R14B2 raw gen_tcp server gives 8k r/s, and node.js v0.4.8 yields 5.7k r/s.
从官方的性能测试结论中可以看出,同一个输出字符 HelloWorld 的请求,OpenResty(1 nginx worker)的性能是 nginx+php 的5倍,erlang 的2.5倍,nodejs 的3.5倍。
其次是应用范围广,社区活跃,稳定性高!知名互联网公司几乎都在使用。
OpenResty 为什么快 ?
- 利用了 Nginx 的异步事件模型,可以极大提高并发处理能力
- 集成了 LuaJIT 作为 Lua 语言及时编译器,很大程度的提高了 Lua 脚本的执行效率
- Lua 支持协程,拥有比线程更低的维护成本和切换开销
- cosocket(coroutine + socket)对比传统 socket 编程,在资源占用、执行效率、并发能力上都更为出色
OpenResty架构
其中,ngx_lua、stream_lua 是核心组件集成在 Nginx 内部,分别用于处理 HTTP 协议、TCP/UDP 协议。Lua 组件封装了常见的 Json、Cache 处理实现,以及 Redis、Mysql 等客户端实现。
与 Java 性能对比如何 ?
## 测试环境操作系统:Red Hat 4.8.5
硬件规格:1核2G
Java版本:1.8.0_211
OpenResty版本:1.25.3.1
50 QPS压力下,压测300秒
对比总结:Java服务最高负载只有 30qps 左右,OpenResty 吃满 50qps 的压力无任何压力,CPU、内存使用几乎无变化。
500 QPS压力下,压测300秒(Java 已经没啥意义了,这里只看 OpenResty)
总结:整个流量完全吃到了,CPU略有提升。
10k QPS压力下跑300秒
总结:整个流量完全吃到了,但 CPU 负载已经提升了很多。整个性能对比并没有那么科学严谨,对比结果也只供参考,但也能明显看出 OpenResty 的性能优势。
使用MQ解决什么问题 ?
主要是进一步降低 OpenResty 的逻辑复杂度,提升短链转发性能和稳定性,将访问数据采集动作通过MQ转移到 Java 服务处理。也可以通过 Nginx 日志 + 日志采集 Agent 达到同样目的。
通过上述优化,改进后短链服务部署架构如下:
OpenResty 服务和 Java 服务可以接入弹性扩缩容,DB也可进行读写分离。Redis 和 MQ 也可根据整个服务的负载进行配置升降(建议做一定的冗余,极致的成本控制自身就会成为影响稳定性的风险因子)。但这是最优解吗?显然不是。技术架构服务于业务诉求,随着业务的增长以及个性化诉求的涌现,我们仍需不断调整,找到最适合自身的最优解。
五、引入新的技术架构我们需要考虑什么
个人经验总结,需要从以下几个维度考虑:
- 新技术的成熟度
- 新技术的学习曲线
- 公司内是否存在相应专家
- 公司生态(尤其是 DevOps、监控体系)的支撑
- ROI
并不是最好最新的就是最合适的,我们还是需要基于自身的基本情况去综合考量,但技术创新的脚步永不停歇。
推荐阅读(OpenResty方向)
- 官方资源:openresty.org/cn/
- Nginx 学习:www.w3cschool.cn/nginx/
- 图书:OpenResty完全开发指南 构建百万级别并发的Web应用 - 罗剑锋著
欢迎关注微信公众号了解最新文章:码农丁丁