
做短链接系统这事一开始我是拒绝的。听起来不就是把一个长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.xRedis用来扛短码到长链接的查询热点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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT访问日志表;2.4 为什么要把短码和长链接的映射拆成独立服务OceanUrl在代码结构上有一个明确的分层短码生成器、映射存储、访问统计是三个独立的模块。这看起来只是代码组织的洁癖但实际操作中救过大命。短码生成逻辑如果和业务逻辑耦合在一起将来想在别的项目里复用时必须把整坨代码搬过去。统计模块如果和跳转模块强耦合统计服务挂了跳转也跟着挂这是绝对不能接受的。把映射查询和日志记录拆开之后可以做到“跳转主链路不依赖统计链路”。具体做法是跳转接口查到长链接后直接返回302同时把访问日志丢进一个内存队列或者Kafka后台线程批量消费写入数据库。这样日志写入哪怕慢一点、挂掉重来都不会影响用户点击短链接的体验。这个设计理念贯穿了OceanUrl的整条数据流。3. 核心接口实现从创建到跳转的完整链路这一节是OceanUrl的代码重头戏。创建短链接和访问短链接两个接口覆盖了绝大多数业务场景需要把每一步的校验、缓存策略、异常处理都想清楚。我会把代码和设计意图放在一起讲这样你能同时看到“怎么做”和“为什么这么做”。3.1 创建短链接接口的实现细节创建接口接收一个长链接返回短链接。最核心的逻辑是三步校验长链接、生成短码、落库并回填缓存。PostMapping(/api/url) public ResultString 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布隆过滤器本地版本可以这么初始化BloomFilterString 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的一个特色功能允许用户提交短链接时指定想要的短码。这个功能看似只是把“自动生成”换成“用户输入”实际上引入了一个新问题保留字冲突。api、admin、login这些短码如果被普通用户注册走了后续扩展管理端时会撞路径。所以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优化器在特定情况下选择了全表扫描主要原因还是查询条件里包含了status和expire_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-appSpring Boot应用、oceanurl-redisRedis、oceanurl-mysqlMySQL。这么做的好处是环境一致性开发、测试、生产用同一套镜像不会再出现“本地好好的服务器上跑不起来”的尴尬。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_id或tenant_id字段所有短链接归属到具体的租户接口鉴权用Token而不是裸调。这个改造宜早不宜迟如果等到用户量上来再做历史数据的迁移会让人崩溃。OceanUrl在设计时已经把t_url_map表预留了扩展字段的位置但实际要加租户字段时还是需要做一次完整的数据迁移。这个经历告诉我表结构设计时有一列预留的biz_type或user_id哪怕暂时用不到也会让你在未来的扩展中游刃有余。数据可视化是短链接平台区别于内部工具的分水岭。普通用户需要一个简单的仪表盘能看到自己的短链接在什么时间段被点击了多少次、来源渠道是什么、用了什么设备。OceanUrl的统计模块目前只做了基础的计数和趋势图要做到平台级还得接入地图API做地域分布做浏览器和设备的聚合分析。这些都是可以预见的、有明确需求的方向。风控系统再往后是永远绕不开的话题。开放平台一旦上线黑产就会盯上你的创建接口批量生成用于赌博、诈骗的短链接。OceanUrl目前的安全策略还是偏基础真正要做成平台需要引入内容识别长链接指向的URL是否命中黑名单、行为风控注册时间小于N天的用户每天的创建配额限制、人工审核高风险短链接进入审核队列三层机制。这已经不是一个短链接系统的问题而是一个内容安全平台的问题。回到技术层面短链接系统虽然小但它把高并发读写、缓存设计、数据一致性、安全防护、监控告警这些后端核心话题全串起来了。一个系统做完你在分布式和架构设计上的体感会扎实很多。OceanUrl后续可以扩展的方向还很多支持多域名绑定、API开放给第三方调用、对接消息队列做更实时的数据管道、用ClickHouse存访问日志提升查询性能……每一条都是真实的业务需求没有一个是为了造轮子而造轮子。最后分享一段这段实践给我最深的体会小系统的价值不在代码量而在你对边界情况考虑得有多周全。短码冲突、缓存穿透、过期时间错位、301与302的取舍、黑名单的误伤——这些问题单拎出来每一个都“不大”但每一个都能在线上给你一记响亮的耳光。做OceanUrl的过程本质上就是把这些耳光都提前挨了一遍。