短链接系统实战:从短码生成到高并发跳转与安全防护全解析
2026/9/16 3:15:01 网站建设 项目流程

做短链接系统这事,一开始我是拒绝的。听起来不就是把一个长URL存起来,换个短的返回去吗?直到自己动手做了OceanUrl这套系统,才发现里面全是细节:短码怎么生成才够快、跳转怎么才能不丢参数、并发上来Redis和数据库怎么分工、恶意请求怎么拦……每一个环节都能写一篇文章。这个项目做完,我对“小系统也有大文章”这句话有了真实的体感。

这篇东西不聊虚的,直接把OceanUrl从需求拆解到核心实现再到压测调优的全过程整理出来。不管你是刚接触短链接、想自己写一个练手,还是已经在做但被性能和安全问题卡住,这里面的思路和代码实现都可以直接参考。

1. 需求梳理与整体设计思路

动手写代码之前,先把OceanUrl要解决的问题、产品形态和系统边界想清楚。这一节的目标是让后面每一步都有据可依,而不是想到哪写到哪。

1.1 短链接系统到底在解决什么问题

短链接的核心价值是把一串很长的URL变成短字符串,用户在点击时能正确跳转到原始地址。表面看是“变短”,本质上其实是两件事:一是缩短传播成本,短信、社交媒体、印刷品上的链接越短越不容易被截断;二是拿到可控的访问数据,原始链接散落在各个渠道没法统一统计,但流量只要经过短链接服务,每一次点击都能被记录下来。

举几个常见的落地场景:短信营销里链接太长会被运营商拆分,用短链可以保证可点性;微博、Twitter这类有字数限制的平台,短链能省出大量文案空间;线下二维码如果直接编码一个超长URL,生成的码会非常密集难以扫描,短链能让二维码瞬间清爽。还有广告投放场景,不同渠道、不同素材各生成一个短链,最后通过点击数据反推哪个渠道效果好。

OceanUrl定位为一个可自托管的通用短链接服务,核心功能四个:

  • 长链接转短链接,支持自定义短码
  • 访问短链接时301/302跳转到原始地址
  • 记录访问日志,提供基础统计能力
  • 提供HTTP API,方便其他系统集成

这四个功能决定了系统的技术选型和表结构设计,后面每个模块都会围绕它们展开。

1.2 技术选型:为什么是Spring Boot + Redis + MySQL

短链接系统的读写模型非常特殊:写少读多。创建短链接是个低频操作,但短链接一旦被发到线上,会被大量用户点击,尤其碰上营销活动,短时间内的QPS可能很高。所以存储和缓存的选型要围绕“读链路极快、写链路可靠”来展开。

OceanUrl的后端采用了Spring Boot 3.x,原因很直接:生态成熟、上手快、团队里没人有学习成本。存储层用了MySQL 8.0加Redis 6.x,Redis用来扛短码到长链接的查询热点,MySQL作为最终数据落点。为什么非要两层?因为如果所有请求都打到MySQL上,一旦出现热点短链,数据库连接会被瞬间打满。而Redis的单机读性能可以到10万+ QPS,扛住日常流量完全够用。两层之间通过缓存预热和失效策略保持最终一致,不需要引入消息队列那么重的组件。

前端管理界面用Vue3 + Element Plus,这就是个标准的增删改查后台,不值得在技术上过度纠结。真正考验系统的地方在短码生成算法和跳转链路的设计,后面重点讲。

1.3 整体架构和数据流向

OceanUrl的系统架构可以分三层理解:

  • 接入层:Nginx负责SSL终结、负载均衡和基础的限流配置,把请求转发到后端的Spring Boot应用。
  • 应用层:负责短码生成、写入MySQL、更新Redis缓存、处理跳转逻辑、记录异步访问日志。
  • 存储层:MySQL持久化存储短链映射关系和访问统计数据,Redis承担短码到长链接的实时查询缓存。

一条完整的数据流是这样的:用户通过管理界面或API提交长链接,后端生成唯一短码,先写MySQL(状态为可用),再写入Redis缓存,最后把短链接返回给调用方。用户点击短链接时,请求到达后端,先查Redis,命中就直接302重定向到长链接,同时把这次的访问信息写入MQ或日志表;Redis没命中就回源查MySQL,查到后回填Redis并设置过期时间,查不到就返回404。

这里有一个关键的设计取舍:跳转用301还是302。301是永久重定向,浏览器会缓存跳转结果,后续访问直接走浏览器本地缓存,不再请求短链接服务,这样服务端压力小但拿不到真实的点击数据。302是临时重定向,每次点击都会先请求短链接服务再跳转,能完整记录每一次访问,代价是多一次网络请求。OceanUrl在需要统计场景下全部使用302,实时性要求不高的纯跳转场景可以按需切换。这个决策直接决定了后续统计数据的准确性。

2. 短码生成算法与核心存储设计

短码是整个系统的“门面”,也是第一个需要较真的模块。短码不仅要短、好记,还得保证高并发下不重复、不可预测。存储层的表结构也要提前把扩展性考虑进去,不然数据量上来之后迁移表结构是一件非常痛苦的事情。

2.1 短码生成方案对比:哈希截取、发号器与预生成池

短码生成我调研过三种主流方案,各自有各自的使用场景。

第一种是哈希截取法:对原始长链接做MD5或MurmurHash,取结果的前6位或8位作为短码。优点是实现简单,同样的长链接每次生成的短码一致,天然支持去重。缺点是哈希冲突需要额外处理,而且6位Base62编码的容量只有62的6次方(约568亿),看着很大,但冲突概率并不是0,碰撞后需要加盐重算或者改用更长位数。更关键的问题是不可控,你没法预测下一个短码是什么,也没法让用户得到一个有意义的短码。

第二种是全局发号器:利用数据库自增ID或者Redis的INCR命令,为每个长链接分配一个递增的整数ID,再用Base62编码转换成短码。优点是绝对唯一、性能极高,ID自增本身就解决了冲突问题。缺点是ID规律性太强,别人完全可以通过短码反推出你的业务量。比如今天创建的短码是abc123,明天就是abc124,如果短码系统是对外开放的,攻击者可以遍历所有短码抓取你的全部短链接数据。这个问题可以在最终返回时对ID做一次可逆混淆来规避。

第三种是预生成短码池:系统启动或空闲时预先批量生成一批短码,放到Redis队列里,使用时直接POP一个出来。这个方案的优点是把生成和使用的耦合解除掉,创建短链接的接口延迟极低,因为不需要在请求时算号。缺点是维护成本高,要监控池子的水位、要处理短码被占用的回滚,属于收益有上限但复杂度没下限的优化。

OceanUrl最终选择了“基于发号器 + Base62编码 + 可逆混淆”的组合方案。具体流程是:先通过数据库自增ID或Redis INCR拿到一个全局递增的数值型ID,将这个ID加上一个固定偏移量再进行位运算混淆,最后用Base62编码成短码。这样做既享受了自增ID不冲突的红利,又避免了短码被直接遍历的裸奔问题。

2.2 Base62编码原理与代码实现

Base62编码是短链接系统里最基础的工具方法,字符集包含26个小写字母、26个大写字母和10个数字,总共62个字符。之所以不用Base64,是因为Base64里的+/在URL中需要转义,放在短链路里容易脏数据,而Base62的字符集天生对URL友好。

编码逻辑是从十进制ID不断除以62取余数,余数映射到字符集得到每一位,最后把结果倒序拼接成字符串。我可以给出核心代码,这段逻辑OceanUrl里直接用的这个:

public class Base62Encoder { private static final String BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; private static final int BASE = 62; public static String encode(long num) { if (num == 0) { return String.valueOf(BASE62_CHARS.charAt(0)); } StringBuilder sb = new StringBuilder(); while (num > 0) { int remainder = (int) (num % BASE); sb.append(BASE62_CHARS.charAt(remainder)); num = num / BASE; } return sb.reverse().toString(); } }

数字转短码很简单,但顺序生成的ID直接编码出来会有明显规律——后生成的短码只比前一个的尾号大一点。为了避免被遍历,OceanUrl在编码前先做一个混淆:

public class ShortCodeGenerator { private static final long OFFSET = 987654321L; private static final long MASK = 0xFFFFFFFFL; public static String generate(Long rawId) { long mixed = (rawId * 31 + OFFSET) & MASK; return Base62Encoder.encode(mixed); } }

这段混淆的核心是把线性的ID分布打散到更大的空间。乘以31相当于移位加自身,加OFFSET改变了起点,与0xFFFFFFFF做位与保证结果在一个合理的范围内不会无限膨胀。解码的时候反转这几个运算就能从短码还原出原始ID。实际使用中要注意一点:混淆后的数值范围不能超出Base62在固定长度下的最大值,否则短码长度会不可控。

这里可以给一个容量参考:6位Base62短码的容量是62的6次方,约568亿,全球任何一个短链接服务在可见范围内都用不完。所以OceanUrl默认固定6位短码,不搞变长。

2.3 数据库表结构设计:从url_map到访问日志

OceanUrl的数据库有核心映射表t_url_map和访问日志表t_access_log两种,先看映射表的建表语句:

CREATE TABLE `t_url_map` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', `short_code` varchar(16) NOT NULL COMMENT '短码', `long_url` varchar(2048) NOT NULL COMMENT '原始长链接', `expire_time` datetime DEFAULT NULL COMMENT '过期时间,NULL为永久有效', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1启用,0禁用', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_short_code` (`short_code`), KEY `idx_created_at` (`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短链接映射表';

这里有几个细节值得展开。long_url字段用了varchar(2048)而不是text,因为绝大多数长链接在2048字符以内,text类型会在行溢出时增加额外的IO开销。short_code必须加唯一索引,这是数据一致性的底线,可以在数据库层面堵住并发创建时的重复问题。expire_time允许为空的设计,让“永久链接”和“限时活动链接”共用一张表,省掉了一张过期表。

访问日志表t_access_log的核心字段包括短码、访问IP、User-Agent、Referer、访问时间等。这张表的写入量会非常大,后续做主从分离或分区表都是独立的话题,但OceanUrl在初期直接用了异步批量写入,避免同步写日志拖慢跳转接口的响应时间。

CREATE TABLE `t_access_log` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `short_code` varchar(16) NOT NULL, `ip` varchar(64) DEFAULT NULL, `user_agent` varchar(512) DEFAULT NULL, `referer` varchar(512) DEFAULT NULL, `access_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_short_code_time` (`short_code`, `access_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='访问日志表';

2.4 为什么要把短码和长链接的映射拆成独立服务

OceanUrl在代码结构上有一个明确的分层:短码生成器、映射存储、访问统计是三个独立的模块。这看起来只是代码组织的洁癖,但实际操作中救过大命。短码生成逻辑如果和业务逻辑耦合在一起,将来想在别的项目里复用时必须把整坨代码搬过去。统计模块如果和跳转模块强耦合,统计服务挂了,跳转也跟着挂,这是绝对不能接受的。

把映射查询和日志记录拆开之后,可以做到“跳转主链路不依赖统计链路”。具体做法是:跳转接口查到长链接后直接返回302,同时把访问日志丢进一个内存队列(或者Kafka),后台线程批量消费写入数据库。这样日志写入哪怕慢一点、挂掉重来,都不会影响用户点击短链接的体验。这个设计理念贯穿了OceanUrl的整条数据流。

3. 核心接口实现:从创建到跳转的完整链路

这一节是OceanUrl的代码重头戏。创建短链接和访问短链接两个接口覆盖了绝大多数业务场景,需要把每一步的校验、缓存策略、异常处理都想清楚。我会把代码和设计意图放在一起讲,这样你能同时看到“怎么做”和“为什么这么做”。

3.1 创建短链接接口的实现细节

创建接口接收一个长链接,返回短链接。最核心的逻辑是三步:校验长链接、生成短码、落库并回填缓存。

@PostMapping("/api/url") public Result<String> createShortUrl(@RequestBody CreateUrlRequest request) { // 1. 校验长链接合法性 String longUrl = request.getLongUrl(); if (!isValidUrl(longUrl)) { return Result.error("invalid url"); } // 2. 生成短码(自带唯一索引兜底) String shortCode = shortCodeGenerator.generate(); try { urlMapMapper.insert(shortCode, longUrl, request.getExpireTime()); } catch (DuplicateKeyException e) { // 极小概率的短码冲突,重新生成 shortCode = shortCodeGenerator.generate(); urlMapMapper.insert(shortCode, longUrl, request.getExpireTime()); } // 3. 缓存回填,设置过期时间 redisTemplate.opsForValue().set( CACHE_KEY_PREFIX + shortCode, longUrl, getCacheExpire(request.getExpireTime()), TimeUnit.SECONDS ); return Result.success(buildShortUrl(shortCode)); }

这个接口看着简单,需要注意的细节全在边界处理里。isValidUrl不能只判断非空,要校验协议头是http或https,防止用户存一段javascript:之类的危险内容进去。短码冲突的DuplicateKeyException捕获是必须的,虽然发号器冲突概率极低,但数据库唯一索引是最后一道防线,捕获重试比直接报错体验好得多。

缓存过期时间的设置是个容易忽略的坑:如果短链接本身设置了过期时间,缓存过期时间应该和它保持一致,否则会出现“数据库里链接已经过期,但Redis里还能查到”的脏数据。OceanUrl在创建时统一通过getCacheExpire方法计算缓存存活时间,保证缓存不会比数据库活得更久。

3.2 跳转接口:缓存策略与回源逻辑

跳转接口是最核心的读接口,性能压力最大。OceanUrl的跳转逻辑遵循经典的Cache Aside模式:先查Redis,命中直接返回;未命中回源MySQL,查到后回填缓存;查不到返回404。

@GetMapping("/{shortCode}") public void redirect(@PathVariable String shortCode, HttpServletResponse response) throws IOException { // 1. 先查Redis缓存 String longUrl = redisTemplate.opsForValue().get(CACHE_KEY_PREFIX + shortCode); if (longUrl == null) { // 2. 缓存未命中,回源数据库 UrlMap urlMap = urlMapMapper.selectByShortCode(shortCode); if (urlMap == null || urlMap.getStatus() == 0 || isExpired(urlMap)) { response.sendError(HttpStatus.NOT_FOUND.value()); return; } longUrl = urlMap.getLongUrl(); // 3. 回填缓存,设置随机过期时间防止缓存雪崩 int expire = BASE_CACHE_SECONDS + ThreadLocalRandom.current().nextInt(300); redisTemplate.opsForValue().set(CACHE_KEY_PREFIX + shortCode, longUrl, expire, TimeUnit.SECONDS); } // 4. 302跳转 response.setStatus(HttpStatus.FOUND.value()); response.setHeader("Location", longUrl); }

这段代码里的缓存过期时间用了基础过期时间 + 随机300秒,这一个细节就是防止缓存雪崩的常规做法。如果所有短码的缓存都在同一时刻过期,回源请求会同时打到数据库上,数据库瞬间就挂了。加上随机值之后,过期时间被分散到300秒的区间内,回源压力被摊平。

还有一个值得注意的点:Redis里永远不存储短码对应的数据库自增ID,只存长链接。因为跳转链路只需要长链接,多存一个ID不仅浪费内存,还会增加数据不一致的概率。如果后续需要根据短码查询更多的元数据(创建时间、归属用户等),可以单独提供管理端接口走数据库,不需要为了这个扩展牺牲主链路的简洁性。

3.3 Redis缓存穿透防护:布隆过滤器与空值缓存

跳转接口还有一个经典的坑,就是缓存穿透。比如攻击者故意请求一个不存在的短码,Redis查不到,每次都回源数据库,数据库扛不住这种流量。OceanUrl在缓存层面做了两层防护。

第一个方案是缓存空值。当回源数据库发现短码不存在时,在Redis缓存一个空字符串并设置较短的过期时间(比如60秒),这样同一个不存在的短码在60秒内的重复请求不会打到数据库。但这个方法防不住攻击者随机生成大量不存在的短码,因为每个短码都是不同的键。

第二个方案是布隆过滤器。系统启动时把当前所有有效的短码加载到布隆过滤器里,跳转请求先经过布隆过滤器判断,如果过滤器说“不存在”,直接返回404,根本不会去查Redis和数据库。布隆过滤器允许有极小概率的误判,但不会漏判,也就是说它说“存在”的短码可能实际上已失效,但说“不存在”的一定不存在。OceanUrl里用了Google的Guava布隆过滤器,本地版本可以这么初始化:

BloomFilter<String> bloomFilter = BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), expectedInsertions, 0.01 );

expectedInsertions根据预估的短码总量设置,0.01表示允许1%的误判率。实际压测下来,布隆过滤器把大部分无效请求阻挡在Redis之前,效果非常显著。

4. 数据统计与安全防护:短链接系统不可忽视的附加能力

短链接系统做到能创建、能跳转,已经是一个可以用的MVP了。但OceanUrl的目标是能真实上线跑业务,统计能力、防滥用能力、异常治理能力一个都不能少。这一节会拆解这些“看起来不起眼实际很关键”的模块。

4.1 访问统计的异步记录方案

访问统计最容易犯的错误是同步写日志。每次跳转都同步插一条数据库记录,高并发下数据库写入会成为瓶颈,接口RT也会被拖慢。OceanUrl的接法是“跳转与统计分离”。

跳转接口在拿到长链接并发送302响应后,只做一件事:把本次访问信息封装成一个LogEntry对象,丢进一个阻塞队列。后台一个单线程消费者批量从队列里取出LogEntry,攒够100条或者每隔2秒批量插入一次数据库。这样跳转接口的响应时间完全不依赖数据库写入性能,日志的批量插入也比逐条插入快一个数量级。

选阻塞队列而不是直接上Kafka,主要是因为OceanUrl的初期规模不需要引入额外的中间件。如果后续访问量增长到单机队列处理不过来,把“丢队列”换成“发送到Kafka topic”即可,接口层的代码完全不用修改。这是典型的“预留扩展点、但不提前引入复杂度”的做法。

4.2 恶意请求识别与封禁策略

短链接服务天然会被黑产盯上,最常见的攻击模式是批量创建短链接用于钓鱼、批量访问短链接探测有效地址。OceanUrl在接入层和应用层各加了一道防线。

接入层的Nginx配置了基础的IP限流,针对/api/url创建接口限流到每个IP每秒2次,针对跳转接口限流到每个IP每秒20次。超限的直接返回503。这是一个粗糙但有效的第一道闸门。

应用层多了一个黑名单IP和UA识别逻辑。请求进来时先判断IP是否在黑名单中,再判断User-Agent是否匹配已知的恶意爬虫特征。识别到恶意特征时,不仅拒绝当前请求,还会把IP加入Redis里的临时黑名单,有效期24小时。之所以用Redis存黑名单而不是数据库,是因为黑名单的读取非常频繁、写入相对低频,Redis的读写性能更合适。

安全防护这里有一个需要平衡的点:限流太严会误伤正常用户,比如公司内部NAT出口的IP是共享的,一个IP下可能有大几十个真实用户。所以OceanUrl的限流参数没有写死,全部做成配置项,上线初期可以先把阈值调高一点,观察一段时间的访问日志再逐步收紧。

4.3 自定义短码与过期策略的边界情况

自定义短码是OceanUrl的一个特色功能,允许用户提交短链接时指定想要的短码。这个功能看似只是把“自动生成”换成“用户输入”,实际上引入了一个新问题:保留字冲突。apiadminlogin这些短码如果被普通用户注册走了,后续扩展管理端时会撞路径。所以OceanUrl在处理自定义短码时,最先检查的是一个保留字列表,命中保留字的请求直接被拒绝。

过期策略的边界情况也很多。一个短链接过期后,跳转接口应该返回什么?OceanUrl的做法是返回410 Gone(资源已删除),并提示用户原链接已失效。这里有一个经验教训:不要在跳转接口把过期的短链接302到一个宣传页,这个行为虽然可以挽回一些流量,但会破坏用户对短链接的信任——用户以为点开是目标网站,结果跳到不想看的东西,一次还好,两次就不会再有人点你的短链了。

5. 性能压测、数据库索引优化与部署实践

前面四节已经把OceanUrl的设计和代码串完了,但代码写出来不代表系统能扛住流量。这一节分享实际部署和压测阶段踩过的坑、优化过的点,整个过程非常能代表一个真实项目的成长路径。

5.1 压测方案与结果分析

OceanUrl的压测环境是两台4核8G的云服务器,一台跑Nginx和Spring Boot应用,一台跑MySQL和Redis。压测工具用JMeter,模拟了三种场景:短链接缓存命中(Redis里有数据)、缓存未命中(需要回源MySQL)、无效短链接(不存在的数据)。

压测结果很有参考价值:

场景并发线程数QPS平均响应时间错误率
缓存命中200820012ms0%
缓存未命中200124096ms0%
无效短链接200760015ms0%

缓存命中的QPS能到8000+,得益于Redis的O(1)读取和Spring Boot的线程池配置。缓存未命中的QPS掉到1240,瓶颈在MySQL的单条查询上,虽然带索引,但连接获取和SQL执行有固定开销。无效短链接的QPS高是因为布隆过滤器直接把大部分请求挡在了Redis前面。

这个数据说明一个事情:短链接系统到了真实业务环境,绝大多数请求都应该命中Redis。如果缓存命中率低于99%,说明你的缓存过期策略有问题,或者缓存预热没做好。

5.2 MySQL索引优化与慢查询治理

压测过程中暴露了一个慢查询:按短码查询时偶尔出现数百毫秒的延迟。查看执行计划发现,虽然uk_short_code唯一索引存在,但MySQL优化器在特定情况下选择了全表扫描,主要原因还是查询条件里包含了statusexpire_time的过滤条件,索引匹配不够精准。

优化方式是建立一个覆盖索引,让查询可以走索引覆盖,避免回表:

ALTER TABLE t_url_map ADD INDEX idx_code_status_expire (short_code, status, expire_time);

改完之后,同样的查询从平均90ms降到了5ms以内。这个案例给了一个很重要的教训:单列唯一索引和联合索引是两回事。如果你经常按短码查询,并且查询条件里还带着状态、过期时间,就要建一个覆盖这些条件的联合索引。

另一个优化点是MySQL连接池的配置。Spring Boot默认的maximum-pool-size是10,对压测场景来说太小了。调大到50之后,数据库连接不再是瓶颈。这里要注意连接数不是越大越好,每个连接都要占用MySQL的内存资源,50是4核8G机器上的适中值。

5.3 Docker化部署与监控指标

OceanUrl的部署方式选择了Docker Compose,服务编排成三个容器:oceanurl-app(Spring Boot应用)、oceanurl-redis(Redis)、oceanurl-mysql(MySQL)。这么做的好处是环境一致性,开发、测试、生产用同一套镜像,不会再出现“本地好好的,服务器上跑不起来”的尴尬。

services: app: image: oceanurl:1.0.0 ports: - "8080:8080" environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/oceanurl SPRING_DATA_REDIS_HOST: redis depends_on: - mysql - redis redis: image: redis:6.2-alpine ports: - "6379:6379" mysql: image: mysql:8.0 environment: MYSQL_DATABASE: oceanurl MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} volumes: - mysql-data:/var/lib/mysql

上线后的监控指标主要盯四个:短链接跳转QPS、Redis命中率、MySQL慢查询数量、应用GC耗时。前三个决定了系统能不能稳定抗住流量,第四个决定了会不会出现“系统没挂但接口很慢”的诡异现象。OceanUrl初期引入了Spring Boot Actuator暴露健康检查接口,配合云监控平台的告警规则,指标异常时能第一时间收到通知,不至于业务方反馈了才知道出问题。

6. 常见问题速查表与避坑经验

OceanUrl从开发到上线,踩过的坑至少有一打。这一节整理成速查表,方便你有类似问题时直接对照排查,不用再走一遍弯路。

现象可能原因解决方案
短链接创建成功后立即访问404Redis缓存回填失败,DB数据正常检查创建接口中缓存写入是否包在事务外层,避免事务回滚导致缓存写入丢失
跳转偶尔很慢,RT从10ms跳到500ms缓存过期时间集中,多个短码同时回源给缓存过期时间加随机偏移,避免同一秒大量key失效
无效短码请求量巨大,数据库CPU飙升缓存穿透,空值没被缓存加布隆过滤器,配合缓存空值策略双保险
批量插入访问日志时数据库锁等待单条insert效率低,事务过长改为批量插入,每批控制在100条以内,事务快速提交
自定义短码提示已存在但数据库查不到短码被Redis缓存误标记检查自定义短码的占位检测逻辑,是否只查了Redis没查DB
短链接在微信里被拦截域名没有备案,或链接被举报改用已备案域名,设置明显的内容安全策略,避免跳转到风险站点
后台统计的PV数远小于实际跳转数浏览器缓存了301跳转,后续请求没到服务器统计要求准确的场景改用302跳转
删除短链接后,Redis里还能访问删除操作只删了DB,没删缓存删除短链接时同步删除Redis key,并广播缓存清理

这些坑里,最有代表性的是301和302的选择。曾有过一个活动链接,上线当天点击量看着正常,第二天数据直接腰斩,后来查了日志才发现浏览器把301结果全部缓存了,用户第二次点击根本没经过服务端。换成302之后,数据曲线立刻恢复正常。技术选型上一个字符的差别,对业务数据的准确性影响就是这么大。

避坑经验方面,我强烈建议开发阶段就在代码里打上完整的访问日志。OceanUrl早期因为日志不完整,排查问题时两眼一抹黑,后来加了切面日志记录入参、出参和耗时,定位问题的时间至少缩短了一半。生产环境日志级别设为INFO,但短链接跳转核心路径必须记录每个关键节点的耗时。这不是为了炫技,而是线上问题排查时的救命稻草。

再补一条关于MySQL事务的体会:创建短链接时,插入DB和回填Redis这两个操作,不建议放在同一个事务里。因为Redis操作失败不应该回滚数据库插入,否则极端情况下会陷入“不断重试、不断失败”的循环。正确顺序是:先插入DB(独立事务),再回填Redis(失败就失败,最多缓存未命中回源一次)。这也是上面速查表里第一条的来源。

7. 从OceanUrl到通用短链接平台:扩展性思考

OceanUrl到这一步,已经能作为一个生产可用的短链接系统发挥价值了。但如果想把它做成一个开放平台,租户隔离、数据可视化、风控系统都需要继续深化。顺着这个方向多聊几句我的个人判断,也给正在规划的人一个参考。

做开放平台,第一件事就是租户体系。新的数据库表里至少要加user_idtenant_id字段,所有短链接归属到具体的租户,接口鉴权用Token而不是裸调。这个改造宜早不宜迟,如果等到用户量上来再做,历史数据的迁移会让人崩溃。OceanUrl在设计时已经把t_url_map表预留了扩展字段的位置,但实际要加租户字段时,还是需要做一次完整的数据迁移。这个经历告诉我:表结构设计时有一列预留的biz_typeuser_id,哪怕暂时用不到,也会让你在未来的扩展中游刃有余。

数据可视化是短链接平台区别于内部工具的分水岭。普通用户需要一个简单的仪表盘,能看到自己的短链接在什么时间段被点击了多少次、来源渠道是什么、用了什么设备。OceanUrl的统计模块目前只做了基础的计数和趋势图,要做到平台级,还得接入地图API做地域分布,做浏览器和设备的聚合分析。这些都是可以预见的、有明确需求的方向。

风控系统再往后是永远绕不开的话题。开放平台一旦上线,黑产就会盯上你的创建接口,批量生成用于赌博、诈骗的短链接。OceanUrl目前的安全策略还是偏基础,真正要做成平台,需要引入内容识别(长链接指向的URL是否命中黑名单)、行为风控(注册时间小于N天的用户每天的创建配额限制)、人工审核(高风险短链接进入审核队列)三层机制。这已经不是一个短链接系统的问题,而是一个内容安全平台的问题。

回到技术层面,短链接系统虽然小,但它把高并发读写、缓存设计、数据一致性、安全防护、监控告警这些后端核心话题全串起来了。一个系统做完,你在分布式和架构设计上的体感会扎实很多。OceanUrl后续可以扩展的方向还很多:支持多域名绑定、API开放给第三方调用、对接消息队列做更实时的数据管道、用ClickHouse存访问日志提升查询性能……每一条都是真实的业务需求,没有一个是为了造轮子而造轮子。

最后分享一段这段实践给我最深的体会:小系统的价值不在代码量,而在你对边界情况考虑得有多周全。短码冲突、缓存穿透、过期时间错位、301与302的取舍、黑名单的误伤——这些问题单拎出来每一个都“不大”,但每一个都能在线上给你一记响亮的耳光。做OceanUrl的过程,本质上就是把这些耳光都提前挨了一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询