前提
半年前(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+:生產部署使用JDK11MVC框架與容器:spring-boot-starter-webflux或者spring-cloud-gateway,主要是必須使用Netty作為底層通訊容器內部RPC框架:Dubbo服務註冊與發現:Nacos可選APM工具:Pinpoint中間件依賴(因為之前整個服務集群都上雲了,低負載的服務共用了部分中間件):
MySQL8.xRedis5.x普通主從或者哨兵集群RabbitMQ3.8.x集群,使用鏡像隊列服務的設計圖如下:
最新的版本考慮把黑白名單的攔截器去掉,替換成一個基於布隆過濾器現實的攔截器。服務使用了兩個攔截器(雖然Filter翻譯是過濾器,但是出於習慣,下文稱為攔截器)鏈,容器提供的攔截器組成的攔截器鏈主要是負責服務安全、調用鏈跟蹤的功能,而服務內部自定義的攔截器鏈主要是實現請求參數解析、URL轉換、重定向和異步事件記錄等功能。
模塊劃分:
- (ROOT) octopus - octopus-contract - octopus-serveroctopus-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.WebFilter(WebFlux的Filter接口),主要有四個實現(順序如下):
MappedDiagnosticContextFilter:引入transmittable-thread-local通過MDC做TraceId的請求上下文綁定,WebFlux的線程模型和常見的Servlet容器的線程模型不一樣,這裡不能直接使用ThreadLocal或者Slf4j中原有的MDC實現BlockIpFilter:判斷客戶端請求IP是否命中黑名單AccessDomainFilter:判斷域名是否命中短鏈域名白名單(可選的,因為外部已經通過NGINX做了一次攔截,這個實現是可有可無的)ExcludeUriFilter:判斷當前請求的URI是否命中了URI黑名單這裡簡單展示一下MappedDiagnosticContextFilter的實現:
@Order(value = Integer.MIN_VALUE)@Componentpublic 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.md中logback與transmittable-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:請求頭解析UrlTransformFilter:URL轉換RedirectionTransformFilter:重定向處理TransformEventProcessTransformFilter:轉換事件記錄以UrlTransformFilter為例子,源碼如下:
@Slf4j@Scope(scopeName = ConfigurableBeanFactory.SCOPE_PROTOTYPE)@Componentpublic 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@RestControllerpublic 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或者UCloud的ULB。添加或者移除短鏈域名,關鍵在於修改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小時緊急迭代出來的一個微型項目,到現在為止更新了很多次,生產上已經基本穩定。文中描述的版本是公司生產版本的移植版,精簡了大量代碼同時移除了一些業務耦合的設計,這裡把源碼開放出來,讓一些有可能用到短鏈服務的場景提供一個可參考但儘可能不要複製的解決思路。源碼倉庫:
Gitee:https://gitee.com/throwableDoge/octopusGithub:https://github.com/zjcscut/octopus代碼都在main分支。
彩蛋
最近鴿了很長一段時間,原因是年底比較多業務功能迭代,內部的一個標籤服務重構花了大量時間。筆者一直在摸索著通過"分片"、"異步"等等思想,在時間可控的前提下,對小數據量(百萬和千萬級別)前提下,通過常用的關係型資料庫、緩存、消息隊列等非大數據平臺架構替代實現《用戶畫像方法論與工程化解決方案》裡面提到的解決方案。
標籤服務內部的代號是"千尋",取自於辛棄疾《青玉案元夕》中的"眾裡尋他千百度",項目名來自於宮崎駿的動漫《千與千尋》的女主千尋(千尋羅馬音是chihiro):
待後面項目上線一段時間穩定後,應該會抽時間寫一個系列談談怎麼不用大數據那套體系,提供用戶畫像的工程化解決方案。
個人博客
Throwable's Blog(本文完 c-10-d e-a-20201227)