前提

半年前(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_336147万接近148
  • N = 5,组合数为62 ^ 5 = 916_132_8329.16亿左右
  • N = 6,组合数为62 ^ 6 = 56_800_235_584568亿左右

一般来说,组合数越小破解的难度就越小,组合数越大,要求压缩码长度越大,所以常用的长度就是456,而且后期可以对失效的长链进行压缩码回收或者禁用,这三个长度对于绝大对数生产短链的应用场景都能满足。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);
}
}

总是批量生成可用的压缩码,查询的时候只需要查出当前未被使用的第一个压缩码即可。

容器拦截器链实现

容器的拦截器需要实现org.springframework.web.server.WebFilterWebFluxFilter接口),主要有四个实现(顺序如下):

  • MappedDiagnosticContextFilter:引入transmittable-thread-local通过MDCTraceId的请求上下文绑定,WebFlux的线程模型和常见的Servlet容器的线程模型不一样,这里不能直接使用ThreadLocal或者Slf4j中原有的MDC实现
  • BlockIpFilter:判断客户端请求IP是否命中黑名单
  • AccessDomainFilter:判断域名是否命中短链域名白名单(可选的,因为外部已经通过NGINX做了一次拦截,这个实现是可有可无的)
  • ExcludeUriFilter:判断当前请求的URI是否命中了URI黑名单

这里简单展示一下MappedDiagnosticContextFilter的实现:

@Order(value = Integer.MIN_VALUE)
@Component
public class MappedDiagnosticContextFilter implements WebFilter { @Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String uuid = UUID.randomUUID().toString();
MDC.put("TRACE_ID", uuid);
return chain.filter(exchange).then(Mono.fromRunnable(() -> MDC.remove("TRACE_ID")));
}
}

上面的TRACE_ID是配合项目的logback.xml中的pattern使用。另外需要参考https://github.com/alibaba/transmittable-thread-local/blob/master/docs/requirement-scenario.mdlogbacktransmittable-thread-local做集成的场景:

这里为了方便管理和升级版本,笔者直接把logback-mdc-ttl的源码实现改造好后放到项目中。

服务内部拦截器链实现

服务内部的拦截器链主要负责请求参数解析、URL映射转换、重定向和访问转换结果记录,顶层接口设计如下:

public interface TransformFilter {

    default int order() {
return 1;
} default void init(TransformContext context) { } void doFilter(TransformFilterChain chain,
TransformContext context);
}

TransformContext是一个属性承载类,本质是一个普通的JavaBean,设计如下:

目前内置了4个拦截器实现,包括:

  • ExtractRequestHeaderTransformFilter:请求头解析
  • UrlTransformFilterURL转换
  • RedirectionTransformFilter:重定向处理
  • TransformEventProcessTransformFilter:转换事件记录

UrlTransformFilter为例子,源码如下:

@Slf4j
@Scope(scopeName = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Component
public class UrlTransformFilter implements TransformFilter { @Autowired
private UrlMapCacheManager urlMapCacheManager; @Override
public int order() {
return 2;
} @Override
public void init(TransformContext context) { } @Override
public void doFilter(TransformFilterChain chain,
TransformContext context) {
String compressionCode = context.getCompressionCode();
UrlMap urlMap = urlMapCacheManager.loadUrlMapCacheByCompressCode(compressionCode);
context.setTransformStatus(TransformStatus.TRANSFORM_FAIL);
if (Objects.nonNull(urlMap)) {
context.setTransformStatus(TransformStatus.TRANSFORM_SUCCESS);
context.setParam(TransformContext.PARAM_LONG_URL_KEY, urlMap.getLongUrl());
context.setParam(TransformContext.PARAM_SHORT_URL_KEY, urlMap.getShortUrl());
chain.doFilter(context);
} else {
log.warn("压缩码[{}]不存在或异常,终止TransformFilterChain执行,并且重定向到404页面......", compressionCode);
throw new RedirectToErrorPageException(String.format("[c:%s]", compressionCode));
}
}
}

所有的服务内拦截器的scope都是prototype,意味着每次初始化拦截器链都会重新创建对应的Bean

主控制器实现

因为octopus只做短链访问的入口,后台管理的功能交给另外的服务实现,此服务只有一个控制器,控制器里面只有一个方法:

@RequiredArgsConstructor
@RestController
public class OctopusController { private final UrlMapService urlMapService; @GetMapping(path = "/{compressionCode}")
@ResponseStatus(HttpStatus.FOUND)
public Mono<Void> dispatch(@PathVariable(name = "compressionCode") String compressionCode, ServerWebExchange exchange) {
ServerHttpRequest request = exchange.getRequest();
TransformContext context = new TransformContext();
context.setCompressionCode(compressionCode);
context.setParam(TransformContext.PARAM_SERVER_WEB_EXCHANGE_KEY, exchange);
if (Objects.nonNull(request.getRemoteAddress())) {
context.setParam(TransformContext.PARAM_REMOTE_HOST_NAME_KEY, request.getRemoteAddress().getHostName());
}
HttpHeaders httpHeaders = request.getHeaders();
Set<String> headerNames = httpHeaders.keySet();
if (!CollectionUtils.isEmpty(headerNames)) {
headerNames.forEach(headerName -> {
String headerValue = httpHeaders.getFirst(headerName);
context.setHeader(headerName, headerValue);
});
}
// 处理转换
urlMapService.processTransform(context);
// 这里有一个技巧,flush用到的线程和内部逻辑处理的线程不是同一个线程,所有要用到TTL -- 和Servlet容器不一样,所以目前写的比较别扭
return Mono.fromRunnable(context.getRedirectAction());
}
}

这个主控制的分发压缩码方法只负责封装参数调用服务内部拦截器链进行后续的处理。然后添加一个全局的异常处理器,把所有的异常或者非法操作引导到一个自定义的404页面(甚至可以在上面挂一点广告):

Dubbo契约实现

octopus-contract是一个完全独立的模块,甚至可以说它是一个完全独立的项目,主要作用是提供契约API,让其他服务引入,让octopus-server模块进行实现。契约接口定义如下:

public interface OctopusApi {

    Response<CreateUrlMapResponse> createUrlMap(CreateUrlMapRequest request);
}

基于Dubbo的实现如下:

@DubboService(retries = -1)
public class DefaultOctopusApi implements OctopusApi { @Autowired
private UrlMapService urlMapService; @Value("${default.octopus.domain}")
private String domain; @Override
public Response<CreateUrlMapResponse> createUrlMap(CreateUrlMapRequest request) {
UrlMap urlMap = new UrlMap();
urlMap.setUrlStatus(UrlMapStatus.AVAILABLE.getValue());
urlMap.setLongUrl(request.getLongUrl());
urlMap.setDescription(request.getDescription());
String shortUrl = urlMapService.createUrlMap(domain, urlMap);
return Response.succeed(new CreateUrlMapResponse(request.getRequestId(), shortUrl));
}
}

生产中契约模块做了比较多的特性定制,这里只举一个简单实现的例子。

部署架构

octopus服务集群单独部署,支持无限添加节点,部署架构的关键在于网络架构,内层的负载均衡使用了Nginx,最外层的负载均衡使用了云负载均衡,如阿里云的SLB或者UCloudULB。添加或者移除短链域名,关键在于修改Nginx的配置。基本的架构如下:

只要保证负载均衡池指向octopus集群即可,短链的域名可能动态增删,操作完之后只需要nginx -s -reload刷新一下Nginx的配置即可。

使用短链服务

先在domain_conf表写入一条本地域名和端口的数据:

编写一个集成测试类,创建一个短链映射:

@Slf4j
@SpringBootTest(classes = OctopusServerApplication.class, properties = "spring.profiles.active=local")
@RunWith(SpringRunner.class)
public class UrlMapServiceTest { @Autowired
private UrlMapService urlMapService; @Test
public void createUrlMap() {
String domain = "localhost:9099";
UrlMap urlMap = new UrlMap();
urlMap.setUrlStatus(UrlMapStatus.AVAILABLE.getValue());
urlMap.setLongUrl("https://throwx.cn/2020/08/24/canal-ha-cluster-guide");
urlMap.setDescription("测试短链");
String url = urlMapService.createUrlMap(domain, urlMap);
log.info("生成的短链:{}", url);
}
}
// 某次执行的结果如下:生成的短链:http://localhost:9099/Myt8qW

基于本地配置启动项目,然后访问http://localhost:9099/Myt8qW,效果如下:

日志如下:

[2020-12-27 19:29:22,285] [INFO] cn.throwx.octopus.server.application.consumer.TransformEventConsumer [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [1c603903-e8d8-4072-aa97-6abf614b9411] - 接收到URL转换事件,内容:{"clientIp":"192.168.211.113","compressionCode":"Myt8qW","userAgent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36","cookieValue":"Webstorm-734c3b68=9b8b3560-41f5-478a-93d0-b02128b1022f; __gads=ID=28121bd829638f67-2286c86e7fc400d3:T=1604132165:RT=1604132165:S=ALNI_MbsMQROv6swaC8kf4ux2suZm_GZXA; Hm_lvt_4df6907aebab752244c3ca1432b4ff57=1605930058,1607228133","timestamp":1609068562262,"shortUrlString":"http://localhost:9099/Myt8qW","longUrlString":"https://throwx.cn/2020/08/24/canal-ha-cluster-guide","transformStatusValue":3}......
[2020-12-27 19:29:22,353] [INFO] cn.throwx.octopus.server.application.consumer.TransformEventConsumer [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [1c603903-e8d8-4072-aa97-6abf614b9411] - 记录URL转换事件完成......

查看转换事件记录表的数据:

后续功能迭代

前期方案有一个安全隐患:没有做压缩码的白名单,容易被基于短链域名,伪造压缩码拼接短链接的方法进行攻击。解决方案是在容器的拦截器链添加或者替换一个基于布隆过滤器实现的压缩码(短链接)白名单拦截器,这样就能在前期拦截了绝大部分恶意伪造的压缩码,让极少量命中了错误率部分的恶意压缩码流到后面的处理逻辑中进行判断。另外,可以引入Caffeine配合Redis做两级缓存,毕竟本地缓存的速度更快。

小结

octopus初版是一个4小时紧急迭代出来的一个微型项目,到现在为止更新了很多次,生产上已经基本稳定。文中描述的版本是公司生产版本的移植版,精简了大量代码同时移除了一些业务耦合的设计,这里把源码开放出来,让一些有可能用到短链服务的场景提供一个可参考但尽可能不要复制的解决思路。源码仓库:

  • Giteehttps://gitee.com/throwableDoge/octopus
  • Githubhttps://github.com/zjcscut/octopus

代码都在main分支。

彩蛋

最近鸽了很长一段时间,原因是年底比较多业务功能迭代,内部的一个标签服务重构花了大量时间。笔者一直在摸索着通过"分片"、"异步"等等思想,在时间可控的前提下,对小数据量(百万和千万级别)前提下,通过常用的关系型数据库、缓存、消息队列等非大数据平台架构替代实现《用户画像方法论与工程化解决方案》里面提到的解决方案。

标签服务内部的代号是"千寻",取自于辛弃疾《青玉案元夕》中的"众里寻他千百度",项目名来自于宫崎骏的动漫《千与千寻》的女主千寻(千寻罗马音是chihiro):

待后面项目上线一段时间稳定后,应该会抽时间写一个系列谈谈怎么不用大数据那套体系,提供用户画像的工程化解决方案。

个人博客

(本文完 c-10-d e-a-20201227)

短链接服务Octopus的实现与源码开放的更多相关文章

  1. 用PHP和Python生成短链接服务的字符串ID

    假设你想做一个像微博短链接那样的短链接服务,短链接服务生成的URL都非常短例如: http://t.cn/E70Piib, 我们应该都能想到链接中的E70Piib对应的就是存储长链接地址的数据记录的I ...

  2. 百度 谷歌 Twitter,这么多短链接服务(Short Url)究竟哪家强?

    一.短链接是什么 url=HPqdQ5VR3vA39x7ZWoWyNzwWnsDhTbh66BTpdzsJLroBDzFRm4JV-G818Zc027uZrwe7zxtxnD4H2FUahftpUK& ...

  3. Java 网址短链接服务原理及解决方案

    一.背景 现在在各种圈的产品各种推广地址,由于URL地址过长,不美观.不方便收藏.发布.传播以及各种发文字数限制等问题,微信.微博都在使用短链接技术.最近由于使用的三方的生成.解析短链接服务开始限制使 ...

  4. cesium结合geoserver利用WFS服务实现图层删除(附源码下载)

    前言 cesium 官网的api文档介绍地址cesium官网api,里面详细的介绍 cesium 各个类的介绍,还有就是在线例子:cesium 官网在线例子,这个也是学习 cesium 的好素材. 内 ...

  5. SpringCloud微服务如何优雅停机及源码分析

    目录 方式一:kill -9 java进程id[不建议] 方式二:kill -15 java进程id 或 直接使用/shutdown 端点[不建议] kill 与/shutdown 的含义 Sprin ...

  6. cesium结合geoserver利用WFS服务实现图层编辑(附源码下载)

    前言 cesium 官网的api文档介绍地址cesium官网api,里面详细的介绍 cesium 各个类的介绍,还有就是在线例子:cesium 官网在线例子,这个也是学习 cesium 的好素材. 内 ...

  7. 使用plv8+hashids生成短链接服务

    有写过一个集成npm plv8 以及shortid生成短链接id服务,实际上我们可以集成触发器自动生成url对应的短链接地址,hashids也是一个不错的选择. 以下是一个别人写的一个博客实现可以参考 ...

  8. 从壹开始微服务 [ DDD ] 之十一 ║ 基于源码分析,命令分发的过程(二)

    缘起 哈喽小伙伴周三好,老张又来啦,DDD领域驱动设计的第二个D也快说完了,下一个系列我也在考虑之中,是 Id4 还是 Dockers 还没有想好,甚至昨天我还想,下一步是不是可以写一个简单的Angu ...

  9. Kafka服务端之网络连接源码分析

    #### 简介 上次我们通过分析KafkaProducer的源码了解了生产端的主要流程,今天学习下服务端的网络层主要做了什么,先看下 KafkaServer的整体架构图 ![file](https:/ ...

随机推荐

  1. 经典算法—BF算法(字符串匹配)

    前言 字符串的匹配算法也是很经典的一个算法,在面试的时候常常会遇到,而BF算法是字符串模式匹配中的一个简单的算法 1,什么是BF算法 BF算法,即暴力(Brute Force)算法,是普通的模式匹配算 ...

  2. JZOJ2020年8月10日提高组T3 玩诈欺的小杉

    JZOJ2020年8月10日提高组T3 玩诈欺的小杉 题目 Description 是这样的,在小杉的面前有一个N行M列的棋盘,棋盘上有\(N*M\)个有黑白棋的棋子(一面为黑,一面为白),一开始都是 ...

  3. C++20初体验——concepts

    引子 凡是涉及STL的错误都不堪入目,因为首先STL中有复杂的层次关系,在错误信息中都会暴露出来,其次这么多类和函数的名字大多都是双下划线开头的,一般人看得不习惯. 一个经典的错误是给std::sor ...

  4. PyQt(Python+Qt)学习随笔:QScrollArea滚动区域的scrollAreaWidgetContents、widget及setWidget等相关概念解释

    老猿Python博文目录 专栏:使用PyQt开发图形界面Python应用 老猿Python博客地址 在Designer中设计将一个lable放到滚动区域上,使用PyUIC生成代码后,可以看到除了QSc ...

  5. PyQt(Python+Qt)学习随笔:formLayout的layoutLabelAlignment 属性

    一.引言 Qt Designer的表单布局(formLayout)中,layoutLabelAlignment 用于控制表单布局中标签的水平对齐方式(包括垂直和水平方向两个方向).如图: 此属性实际对 ...

  6. Oracle函数使用1

    一.字符串处理函数 1.ascii(x):返回字符的ASCII. SQL语句:select ascii('a') from dual; dual:空表,每创建一个用户都会生成这样一个dual表,表中只 ...

  7. SQL数据库优化的六种方法

    SQL命令因为语法简单.操作高效受到了很多用户的欢迎.但是,SQL命令的效率受到不同的数据库功能的限制,特别是在计算时间方面,再加上语言的高效率也不意味着优化会更容易,所以每个数据库都需要依据实际情况 ...

  8. k8s 节点 notReady问题解决流程

    1.在k8smaster 服务器检查节点状态 kubectl describe nodes  aaaa #没有报错,异常信息   2.在节点上检查kubelet服务状态 netstat -tlanp| ...

  9. JavaScript异步编程的四种方法

    1.回调函数 f1(f2); 回调函数是异步编程的基本方法.其优点是易编写.易理解和易部署:缺点是不利于代码的阅读和维护,各个部分之间高度耦合 (Coupling),流程比较混乱,而且每个任务只能指定 ...

  10. git 远端版本回退

    情景:本地更改推送远端后,想要回退到自己推送之前的某个版本. 比如想回退的分支为 test 分支. 风险:远端回退到某一版本后,之后的所有推送都没了(对应的日志记录也没了).如果是团队开发,不仅自己推 ...