前提
半年前(2020-06
)左右,疫情触底反弹,公司的业务量不断提升,运营部门为了方便短信、模板消息推送等渠道的投放,提出了一个把长链接压缩为短链接的功能需求。当时为了快速推广,使用了一些比较知名的第三方短链压缩平台,存在一些问题:
- 收费贵
- 一些情况下,短链域名在部分第三方平台例如微信会被封杀
- 回源数据没有办法定制处理方案,无法打通整个业务链路进行数据分析和跟踪
基于此类问题,决定自研一个(长链接压缩为)短链接服务,当时刚好同步进行微服务拆分,内部很多微服务需要重新命名,组内的一个妹子说不如就用Github
的吉祥物去命名octopus cat
(章鱼猫)去命名,但是考虑到版权问题,去掉了她最喜欢的猫,剩下章鱼,以octopus
命名:
(项目的描述还打错字了,应该是"短链接")因为实现的功能并不复杂,初版于2020-06
月底就发布。octopus
的实现参考了互联网中几篇关于"短链服务实现"浏览量比较高的文章,下面从实现原理、服务实现和部署架构等方面展开谈谈。
基本原理
短链服务的核心就是构建短链接和长链接的唯一映射关系,依赖到一个高性能、排列组合数量大而且破解难度大的映射标识生成算法。
构建唯一映射关系
上图是笔者收到的京东白条分期还款结果提醒短信,短信内容也包含了一个短链https://3.cn/j/xxxxxxx
,把它拷贝到浏览器中打开,发现客户端会重定向到长链https://jrmkt.jd.com/ptp/wl/vouchers.html?activityId=${activityId}&uep_p=${uep_p}&uep_template_id=${uep_template_id}&uep_timestamp=${uep_timestamp}
,然后跳入一个H5
的登录页,登录后再跳进一个白条攻略页面。这里其实一个长链其实可以压成多个短链,短链可以相同域名,也可以使用不同的域名:
访问https://3.cn/j/xxxxxxx
短链接具体的交互流程猜测如下:
jrmkt.jd.com和3.cn查证都是doge东的域名
构建唯一映射关系其实就是基于一个固定的长链接,映射到一个或者多个可以动态生成的短链接,这个唯一映射关系,要求生成的短链接满足:
- 不容易被破解(使用数字例如数据库的自增主键作为唯一映射标识容易被人遍历出来进行恶意调用)
- 不能重复(一个短链接只能对应一个长链接,当然一个长链接可以对应多个短链接)
- 长度尽可能短,这是因为第三方推送的报文内容一般有长度限制,如果短链过长,会导致不容易传输,还会令到推送内容字数受限(试想运营商短信投放内容最大长度为
30
个字符长度,短链已经占了20
个字符长度,剩下只有10
个字符长度让运营同事去发挥,显然不合理) - 如果链接过长,生成的二维码里面的"码点"会十分密集,不利于客户端识别和传输,刚好笔者公司运营有使用二维码的场景,所以必须尽可能缩短链接的长度
总的来说,这个唯一映射关系中的映射标识需要像Hash
算法生成的Hash
码那样具备高唯一性和低碰撞频率,同时具备短小易传输的特点,具体如何去生成映射唯一标识见下一节"压缩码生成算法"。
压缩码生成算法
这里的"压缩码"(compression_code
)是笔者杜撰出来的名词,在本文中它的含义是短链接URL
的路径部分(为了节省长度,除了协议和域名部分,短链的URL
只有第一段路径):
其中,协议部分基本是固定为https://
(从安全性来看不建议使用http://
),短链域名可以购买尽可能长度短的域名如t.cn
,不过有先见之明的资本家一般会把所有优质的短域名买下并且把价格提到很高,所以域名的长度基本也是很难控制的因素,剩下可控的就是压缩码部分。压缩码部分是可控的,但因为它是URL
的一部分,只要确保所使用的字符不会被URL
编码转义,那么长度是人为可控的。假设我们使用的是26
个字母的大小写,加上10
个数字,那么对于N
位压缩码可以表示的最大组合数量为:
N = 4
,组合数为62 ^ 4 = 14_776_336
,147
万接近148
万N = 5
,组合数为62 ^ 5 = 916_132_832
,9.16
亿左右N = 6
,组合数为62 ^ 6 = 56_800_235_584
,568
亿左右
一般来说,组合数越小破解的难度就越小,组合数越大,要求压缩码长度越大,所以常用的长度就是4
、5
和6
,而且后期可以对失效的长链进行压缩码回收或者禁用,这三个长度对于绝大对数生产短链的应用场景都能满足。octopus
在实现的时候选用的是6
位长度的压缩码,无他,因为有现成的成熟的参考方案:62
进制数刚好由字符0-9 a-z A-Z
组成,生成压缩码的时候,只需要生成一个唯一的10
进制数,然后再基于此10
进制数转换为62
进制数数即可。说到这里,看起来的方案如下:
虚线部分一般依赖一种高效而且低冲突的摘要算法,如MurmurHash
,而第(1)
步的实线部分就是生成一个全局唯一的10
进制序列,常用的手法有:
- 数据库自增序列(如自增主键)
Snowflake
算法- 自研的类似
UUID
算法生成全局唯一的序列值
考虑到之前笔者钻研过Snowflake
算法的原理,这里简单使用Snowflake
算法生成自增序列,使用了下面的流程进行压缩码生成和分配:
因为运营部门对短链生成的批量不大,而且短链域名只有一个,所以简单起见,一次压缩操作直接消耗掉一个压缩码,不考虑不同短链域名对同一个压缩码进行共享,也不考虑压缩码的回收问题。
服务实现
短链服务的主访问入口一般QPS
极高,因此需要想尽一切办法降低该入口的耗时,考虑可以用Redis
做缓存承载入口的流量,基础架构选型如下:
JDK1.8+
:生产部署使用JDK11
MVC
框架与容器:spring-boot-starter-webflux
或者spring-cloud-gateway
,主要是必须使用Netty
作为底层通讯容器- 内部
RPC
框架:Dubbo
- 服务注册与发现:
Nacos
- 可选
APM
工具:Pinpoint
中间件依赖(因为之前整个服务集群都上云了,低负载的服务共用了部分中间件):
MySQL8.x
Redis5.x
普通主从或者哨兵集群RabbitMQ3.8.x
集群,使用镜像队列
服务的设计图如下:
最新的版本考虑把黑白名单的拦截器去掉,替换成一个基于布隆过滤器现实的拦截器。服务使用了两个拦截器(虽然Filter
翻译是过滤器,但是出于习惯,下文称为拦截器)链,容器提供的拦截器组成的拦截器链主要是负责服务安全、调用链跟踪的功能,而服务内部自定义的拦截器链主要是实现请求参数解析、URL
转换、重定向和异步事件记录等功能。
模块划分:
- (ROOT) octopus - octopus-contract - octopus-server 复制代码
octopus-contract
模块必须脱离父POM
的管理,方便单独迭代更新。
数据库设计
一共使用了5
个表:
具体的初始化DDL
如下:
CREATE DATABASE `db_octopus` CHARSET 'utf8mb4' COLLATE 'utf8mb4_unicode_520_ci'; USE `db_octopus`; CREATE TABLE `url_map` ( `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主键', `short_url` VARCHAR(32) NOT NULL COMMENT '短链URL', `long_url` VARCHAR(768) NOT NULL COMMENT '长链URL', `short_url_digest` VARCHAR(128) NOT NULL COMMENT '短链摘要', `long_url_digest` VARCHAR(128) NOT NULL COMMENT '长链摘要', `compression_code` VARCHAR(16) NOT NULL COMMENT '压缩码', `description` VARCHAR(256) COMMENT '描述', `url_status` TINYINT NOT NULL DEFAULT 1 COMMENT 'URL状态,1:正常,2:已失效', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `creator` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '创建者', `editor` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '更新者', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标识', `version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本号', UNIQUE uniq_compression_code (`compression_code`), INDEX idx_short_url (`short_url`), INDEX idx_short_url_digest (`short_url_digest`), INDEX idx_long_url_digest (`long_url_digest`) ) COMMENT 'URL映射表'; CREATE TABLE `domain_conf` ( `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主键', `domain_value` VARCHAR(16) NOT NULL COMMENT '域名', `protocol` VARCHAR(8) NOT NULL DEFAULT 'https' COMMENT '协议,https或者http', `domain_status` TINYINT NOT NULL DEFAULT 1 COMMENT '域名状态,1:正常,2:已失效', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `creator` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '创建者', `editor` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '更新者', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标识', `version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本号', UNIQUE uniq_domain (`domain_value`) ) COMMENT '域名配置'; CREATE TABLE `compression_code` ( `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主键', `compression_code` VARCHAR(16) NOT NULL COMMENT '压缩码', `code_status` TINYINT NOT NULL DEFAULT 1 COMMENT '压缩码状态,1:未使用,2:已使用,3:已失效', `sequence_value` VARCHAR(64) NOT NULL COMMENT '序列(盐)', `strategy` VARCHAR(8) NOT NULL DEFAULT 'sequence' COMMENT '策略,sequence或者hash', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `creator` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '创建者', `editor` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '更新者', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标识', `version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本号', UNIQUE uniq_compression_code (`compression_code`) ) COMMENT '压缩码'; CREATE TABLE `visit_statistics` ( `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主键', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `creator` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '创建者', `editor` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '更新者', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标识', `version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本号', `statistics_date` DATE NOT NULL DEFAULT '1970-01-01' COMMENT '统计日期', `pv_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '页面流量数', `uv_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '独立访客数', `ip_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '独立IP数', `effective_redirection_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '有效跳转数', `ineffective_redirection_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '无效跳转数', `compression_code` VARCHAR(16) NOT NULL COMMENT '压缩码', `short_url_digest` VARCHAR(128) NOT NULL COMMENT '短链摘要', `long_url_digest` VARCHAR(128) NOT NULL COMMENT '长链摘要', UNIQUE uniq_date_code_digest (`statistics_date`, `compression_code`) ) COMMENT '访问数据统计'; CREATE TABLE `transform_event_record` ( `id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主键', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `creator` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '创建者', `editor` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '更新者', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标识', `version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本号', `unique_identity` VARCHAR(128) NOT NULL COMMENT '唯一身份标识,SHA-1(客户端IP-UA)', `client_ip` VARCHAR(64) NOT NULL COMMENT '客户端IP', `short_url` VARCHAR(32) NOT NULL COMMENT '短链URL', `long_url` VARCHAR(768) NOT NULL COMMENT '长链URL', `short_url_digest` VARCHAR(128) NOT NULL COMMENT '短链摘要', `long_url_digest` VARCHAR(128) NOT NULL COMMENT '长链摘要', `compression_code` VARCHAR(16) NOT NULL COMMENT '压缩码', `record_time` DATETIME NOT NULL COMMENT '记录时间戳', `user_agent` VARCHAR(2048) COMMENT 'UA', `cookie_value` VARCHAR(2048) COMMENT 'cookie', `query_param` VARCHAR(2048) COMMENT 'URL参数', `province` VARCHAR(32) COMMENT '省份', `city` VARCHAR(32) COMMENT '城市', `phone_type` VARCHAR(64) COMMENT '手机型号', `browser_type` VARCHAR(64) COMMENT '浏览器类型', `browser_version` VARCHAR(128) COMMENT '浏览器版本号', `os_type` VARCHAR(32) COMMENT '操作系统型号', `device_type` VARCHAR(32) COMMENT '设备型号', `os_version` VARCHAR(32) COMMENT '操作系统版本号', `transform_status` TINYINT NOT NULL DEFAULT 0 COMMENT '转换状态,1:转换成功,2:转换失败,3:重定向成功,4:重定向失败', INDEX idx_record_time (`record_time`), INDEX idx_compression_code (`compression_code`), INDEX idx_short_url_digest (`short_url_digest`), INDEX idx_long_url_digest (`long_url_digest`), INDEX idx_unique_identity (`unique_identity`) ) COMMENT '转换事件记录'; 复制代码
压缩码生成模块实现
压缩码生成的方法比较简单:
private final SequenceGenerator sequenceGenerator; # <------------- 雪花算法序列生成器 @Value("${compress.code.batch:100}") private Integer compressCodeBatch; ...... private void generateBatchCompressionCodes() { for (int i = 0; i < compressCodeBatch; i++) { long sequence = sequenceGenerator.generate(); CompressionCode compressionCode = new CompressionCode(); compressionCode.setSequenceValue(String.valueOf(sequence)); String code = ConversionUtils.X.encode62(sequence); # <-------------- 10进制转62进制 code = code.substring(code.length() - 6); compressionCode.setCompressionCode(code); compressionCodeDao.insertSelective(compressionCode); } } 复制代码
总是批量生成可用的压缩码,查询的时候只需要查出当前未被使用的第一个压缩码即可。