ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

半天实现仿微博URL短地址系统:Spring Boot+Redis+MySQL完整实践

半天实现仿微博URL短地址系统:Spring Boot+Redis+MySQL完整实践 很多团队做短链系统第一反应就是“这有什么难的”直到自己动手才发现坑比想象中多。我上一家公司要做外部分享链接的跳转统计产品直接拿微博短链当对标要求半天出第一版。我带着一个后端同学用 Spring Boot Redis MySQL从中午做到下班前跑通了生成、跳转、点击统计、简单拦截这四个核心链路。这篇就把当时的选型、代码、踩坑全梳理一遍想快速落地一套仿微博URL短地址系统的人可以直接照着抄。半天实现仿微博URL短地址系统1. 项目需求与整体设计拆解1.1 微博短链到底解决了什么问题微博短链表面上是把一长串URL变短方便在有限字数内塞进更多内容。但你仔细拆一下它实际解决了四个问题这四个问题决定了你系统的核心架构。第一是长度限制。140字时代一条 t.cn/xxxxxx 只有十几个字符比原来几百字符的链接省太多。第二是合规与安全。平台需要在用户跳转前做一次安全扫描、域名黑名单拦截直接放原始链接根本来不及审查。第三是统计。点击量、来源、时间、设备都可以通过短链这个统一入口收集。第四是链路可控。原始链接一旦失效可以不改发布内容只改短链映射关系实现“内容不动目标可换”。所以你做的时候不要只做一个“长短映射”而是要把跳转前的校验和跳转后的统计留好接口。我第一版只做了映射和跳转结果上线后运营要点击数据又花了半天补统计这在“半天交付”的语境下是致命的。1.2 技术选型我为什么用 Spring Boot Redis MySQL技术选型我几乎没犹豫直接定了三件套Spring Boot 负责接口层Redis 负责短码到长链的缓存MySQL 负责持久化。原因很简单团队熟、生态全、半天内能跑通。有人会问是不是可以用 NoSQL比如 MongoDB 存映射关系可以但没必要。短链系统的核心数据模型就一个表短码、长链、创建时间、过期时间、状态。MySQL 处理这种结构化数据非常稳而且你后续要做按时间清理过期数据、按用户查列表SQL 写起来比写 MapReduce 顺手得多。Redis 在这里不是主力存储是加速层。短链跳转是典型的读多写少场景同一个短码可能被点击几万次每次都查数据库太亏。所以我的请求路径是先查 Redis命中了直接 302没命中再查 MySQL查到后回填 Redis查不到就返回 404。这套逻辑半天实现完全来得及。为什么不引入消息队列做异步统计因为半天交付的第一版统计可以同步写库或者直接打到日志里后面再上 MQ 也来得及。我见过很多新手上来就设计成“生成短链发 MQ、点击也发 MQ”半天光调 MQ 就耗完了。第一版能跑比架构“完美”重要。1.3 短码生成策略对比与选择短链的短码是核心资产生成策略有很多我对比过三种常见方案。第一种是随机短码 冲突检测。每次生成一个固定长度的随机字符串比如 6 位然后查数据库有没有冲突有就重新生成。优点是实现简单缺点是冲突率比你想象得高数据过百万后经常要重试好几次。第二种是哈希截取。把原始 URL 用 MD5/SHA-256 哈希截取一部分作为短码。这种方式最大的问题是碰撞不可控而且同一个 URL 生成的短码是固定的看起来省空间但没法区分不同渠道来源的同一个链接。第三种是发号器 62 进制转换。这是微博这类大流量系统最常用的思路用一个全局自增 ID然后把这个十进制数转成 62 进制字符串数字 大小写字母作为短码。比如 ID100000转成 62 进制就是 q0U 这种 3~7 位字符串。这样生成过程无冲突、短码短、还能反推出大概的生成顺序。我最后选的就是第三种。具体用 Redis 的 INCR 命令维护一个全局自增 ID然后转 62 进制。这样一台机器就够了如果以后要扩容可以升级成分布式发号器比如雪花算法但第一版完全不需要。2. 数据库设计和核心接口定义2.1 表结构设计够用比复杂重要第一版的表结构我控制在三张表内核心表就是 url_map结构如下CREATE TABLE url_map ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, short_code varchar(16) NOT NULL COMMENT 短码, long_url varchar(2048) NOT NULL COMMENT 原始长链接, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态 1-正常 0-禁用, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, expired_at datetime DEFAULT NULL COMMENT 过期时间NULL为永久, PRIMARY KEY (id), UNIQUE KEY uk_short_code (short_code), KEY idx_created_at (created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT短链映射表;这里我做了几个关键取舍。long_url 字段我直接给了 2048 长度因为实际业务里经常有带一堆参数的链接长度动不动就超过 1000太短会被截断。status 字段很重要运营遇到投诉链接时可以快速禁用不用删数据。expired_at 是可选的因为不是所有短链都要永久有效做营销活动时一般会设置过期时间。如果你想记录点击量可以加一张 url_visit_log字段包括 id、short_code、visit_ip、user_agent、referer、visit_time。第一版可以只把原始日志打到文件里后面再异步解析入库这样不阻塞跳转主流程。还有一个经常被忽略的表是短码映射的“自定义码”表。微博允许用户自己指定短码吗其实平台侧一般不允许但我们的产品希望运营能自定义比如 618 大促要一个 /promo618 这种短码。这时候就需要一个自定义短码的校验逻辑防止和发号器生成的短码冲突。我的做法是无论自定义还是系统生成都先查 uk_short_code冲突就提示用户换一个。2.2 接口定义生成短链和跳转接口就两个核心生成接口和跳转接口。生成接口 POST /api/short-url入参就两个字段longUrl 和可选的自定义 code。返回结构我习惯用统一的 Result 包装{ code: 0, data: { shortUrl: http://s.example.com/Bv7xQ2 } }跳转接口 GET /{shortCode}注意这里没有前缀 /s因为我用独立的短域名解析到服务根路径占位。如果你和主站共用域名可以用 /s/{shortCode} 来区分路由。跳转接口的返回有讲究。我第一版直接用 302后来发现很多 SDK 会把 302 当临时跳转为了统计点击率我们需要每次点击都走一遍短链服务来计数所以用 302 是对的。如果你用 301浏览器会缓存跳转结果第二次直接访问长链接不再请求短链服务你的点击统计就废了。微博短链也是倾向于 302 的因为他们要记录每个点击。生成接口还需要考虑幂等。我的做法是如果同一个 longUrl 在 N 分钟内重复提交直接返回已有短码防止运营同事短时间内重复操作制造大量脏数据。实现方式是 Redis SETNX 一个 longUrl 映射短码的 key过期时间设 5 分钟。注意别把这个当成永久缓存否则运营想生成两个不同的短链指向同一个 URL 时会出问题。2.3 短码校验与URL合法性验证网络热词里那些奇怪的错误很多都是因为上游系统没有做好 URL 合法性验证。我在这块吃了大亏值得单独讲。先说短码校验。如果用户传入了自定义 code必须限制字符集和长度。我允许的是数字、大小写字母长度 4~16 位正则就是^[a-zA-Z0-9]{4,16}$。不能用汉字不能用下划线因为短链要兼容各种聊天软件和浏览器字符集越保守越不容易出问题。再说 URL 校验。不能只用一个正则去匹配比如/^https?:\/\/./这种因为恶意用户可以传http://127.0.0.1:15721/v1/responses这种内网地址也可能传file:///etc/passwd这种协议。我在项目里做了三层校验第一层协议白名单。只接受 http 和 https其他协议一律拒绝。这样unsafe attempt to load URL file:///这类问题从入口就挡住了。注意解析协议时要用new URL(longUrl).getProtocol()不要用字符串 startsWith 判断容易被httpjavascript这种混淆绕过。第二层端口校验。URL 里的端口必须是 1~65535 的数字不能有小数点或者负数。踩过的坑就是调用方在长链接里拼了一个非法端口导致生成接口直接报错错误信息类似curl: (3) URL rejected: port number was not a decimal number between 0 and 65535。其实问题出在调用方但我们的系统如果没提前校验会把这种脏数据存进去后面跳转时浏览器又不认用户就会遇到“链接打不开”。第三层域名校验。这个看业务需要如果不限制任何人都能拿你的短链平台做跳板跳转到赌博、诈骗网站域名会被封。我们当时加了一个域名白名单/黑名单配置至少在生成时检查一下目标域名是否在黑名单里。完整的做法是异步调用安全扫描服务第一版先做黑名单匹配就够了。3. 半天落地核心代码实现与实现细节3.1 发号器生成短码的实现发号器我用 Redis INCR 实现这个操作是原子的不会出现两个进程拿到同一个 ID。代码如下public String generateShortCode() { Long id redisTemplate.opsForValue().increment(short_url:global_id); return toBase62(id); } private static final String BASE62_CHARS 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ; public String toBase62(long num) { if (num 0) { return String.valueOf(BASE62_CHARS.charAt(0)); } StringBuilder sb new StringBuilder(); while (num 0) { int remainder (int) (num % 62); sb.append(BASE62_CHARS.charAt(remainder)); num / 62; } return sb.reverse().toString(); }注意我把 62 进制字符集顺序定义成“数字、小写字母、大写字母”而不是常见的“大小写字母数字”。这样短码里数字在前后面接字母可读性稍微好一点。顺序本身不影响唯一性只要生成和解析用同一套顺序就行。还要注意 Redis 发号器的持久化问题。默认 Redis 如果不配置 AOF重启后计数可能回退导致新生成的短码和旧的重复。解决方法是定期把当前最大 ID 持久化到 MySQL 表中Redis 重启后用 MySQL 里的最大值做初始化。第一版可以用一个单独的表存 max_id每次生成批次后更新。这个细节让我避免了一次线上事故因为我当时测试环境重启 Redis 后确实出现了短码冲突。3.2 重定向接口实现与R301/R302选择跳转接口代码很少但细节很多。核心就三步取短码查长链重定向。GetMapping(/{shortCode}) public void redirect(PathVariable String shortCode, HttpServletRequest request, HttpServletResponse response) throws IOException { if (shortCode null || shortCode.isEmpty()) { response.sendError(400); return; } String longUrl urlService.getLongUrl(shortCode); if (longUrl null) { response.sendError(404); return; } response.setStatus(HttpServletResponse.SC_MOVED_TEMPORARILY); response.setHeader(Location, longUrl); // 异步记录访问日志 visitLogService.asyncLog(shortCode, request); }这里我使用SC_MOVED_TEMPORARILY也就是 302。为什么不用 301前面已经说过是为了让每次点击都经过短链服务方便统计。还有一个考虑是如果长链地址哪天要换301 会因为有浏览器缓存而无法立刻生效用户会一直跳到旧地址。302 就没这个问题。跳转时注意要对 longUrl 做一次净化。存库的时候可能是带空格的或者末尾有多余换行跳转前 trim 一下否则拼到 Location 头里可能导致 502 或浏览器解析异常。我记得有一次就看到日志里大量unexpected status 502 bad gateway查下来不是我们服务问题而是某个上游返回的 Location 头带了非法字符被网关过滤了。虽然直接原因不在我们但这种问题会算到短链系统头上只能自己提前防御。3.3 缓存与并发控制避免缓存击穿Redis 缓存策略是我花时间最多的地方。第一版如果只是简单 get/set热点短码一旦过期高并发下会有大量请求同时穿透到 MySQL。我用了双重检查锁 本地互斥。public String getLongUrl(String shortCode) { // 1. 先查 Redis String longUrl (String) redisTemplate.opsForValue().get(short_url: shortCode); if (longUrl ! null) { return longUrl; } // 2. 加锁防止缓存击穿 String lockKey short_url:lock: shortCode; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(3)); if (!locked) { // 拿不到锁就先等一会儿再查缓存避免堆请求 try { Thread.sleep(20); } catch (InterruptedException ignored) {} return redisTemplate.opsForValue().get(short_url: shortCode); } try { // 3. 查数据库 UrlMap urlMap urlMapper.selectByShortCode(shortCode); if (urlMap ! null) { redisTemplate.opsForValue().set(short_url: shortCode, urlMap.getLongUrl(), Duration.ofHours(24)); return urlMap.getLongUrl(); } return null; } finally { redisTemplate.delete(lockKey); } }这段代码的核心是setIfAbsent做互斥锁保证同一时刻只有一个请求去查数据库。锁的过期时间设置 3 秒防止线程异常后死锁。你可能会问为什么不用 Redisson 这种分布式锁框架因为半天交付没时间引新依赖Redis 原生命令足够解决这个场景。缓存过期时间我设成 24 小时意味着短链生成后如果有运营修改映射比如换活动链接最坏 24 小时后才生效。如果业务要求立刻生效可以在修改接口里主动删缓存。这个取舍要提前和产品对齐否则会以为是系统 bug。4. 实际踩坑与排查实录4.1 请求重定向返回404/403的问题上线第一天运营反馈有些链接点了提示 404。我第一反应是短码没查到打开日志一看根本不是短码问题而是用户访问的路径带上了多余的斜杠或参数。比如http://s.example.com/Bv7xQ2/和http://s.example.com/Bv7xQ2?fromweiboSpring 默认路由/{shortCode}不会匹配带斜杠的路径直接抛 404。解决方法是加一个过滤器或者用更宽松的路由匹配把短码从 URI 里手动提取出来然后截取到第一个斜杠或问号为止。这个看起来很低级但真的很容易漏。还有一种 404 是短码本身不存在这个要区分对待日志里要记录是哪种原因方便后续分析。403 的情况一般是反代层拦截。我们当时用 Nginx 做了短域名转发默认配置对某些 User-Agent 返回 403比如一些爬虫。排查时可以先绕开 Nginx 直连应用如果通问题就在 Nginx 的 UA 过滤规则。我建议在 Nginx 层只做转发不要做太多业务拦截安全校验放在应用层做更可控。4.2 URL校验不严导致的脏数据前面我提到 URL 三层校验这里分享一个具体事故。某天发现库里有一条长链接是http://127.0.0.1:15721/v1/responses这种本机地址显然是测试环境的调用方误传上来的。当时校验只做了正则没做内网地址过滤结果运营在后台点开这条短链跳到了服务器自己的接口上虽然没有造成破坏但已经属于严重的安全隐患了。事后我总结了校验清单协议必须为 http/https主机名不能是内网 IP127.0.0.1、10.x、192.168.x、172.16~31.x主机名不能是保留域名localhost、.local端口必须在合法范围内且为纯数字URL 总长度不能超过 2048判断内网 IP 不要自己写正则直接使用 JDK 的InetAddress.getByName(host).isSiteLocalAddress()或isLoopbackAddress()。不过注意这会触发 DNS 解析如果目标域名解析失败会有异常所以要放在最后一步做或者做成异步校验。4.3 502、503等网关错误的排查路径日志里有段时间频繁出现unexpected status 502 bad gateway我们第一反应是短链应用挂掉了。登录服务器一看进程还在但响应特别慢最后发现是数据库连接池被打满了。原因是某个短链突然上了热搜大量请求同时穿透到数据库连接池默认大小只有 10直接打爆。处理方案分三层第一层调大数据库连接池。HikariCP 的 maximumPoolSize 从 10 调到 50。但这不是万能连接太多也会压垮数据库。第二层Redis 缓存一定要做。那次事故的根因是缓存过期时间太短导致同一时刻所有请求都去查库。我把热点短码的缓存时间从 2 小时调成 24 小时压力立刻降下来。第三层加熔断。如果数据库查询异常率超过阈值直接返回一个降级页而不是让请求继续堆积。这个用 Spring Cloud Gateway 的 Fallback 或者 Sentinel 都行但半天交付来不及我第一版用了一个朴素的做法如果 Redis 挂了直接返回 503 错误页避免数据库被拖垮。至于 503service unavailable这种情况在我这个系统里通常是后端服务健康检查失败导致的。排查时先看健康检查接口是不是偶发超时很多网关会在实例 CPU 飙高时标记不健康然后把请求转到正常实例表现就是部分用户遇到 503。解决方法是给健康检查多加一点超时余量同时确保本地缓存预热。5. 安全加固与上线前检查5.1 防止短链被遍历和恶意刷接口短码只有 7 位左右理论上可以被遍历。虽然 62 进制的空间很大但发号器生成的 ID 是连续的攻击者只要抓到一个短码就能顺着枚举前后几个短码。这是短链系统最常见的风险。我的对策是不要直接用发号器 ID 做短码而是做一步混淆。比如对 ID 做位运算或异或一个固定盐值再转 62 进制。这样生成的短码看起来毫无规律攻击者无法通过一个短码推算出相邻短码。混淆算法要保证可逆否则跳转时没法还原 ID 去查库。简单做法是private static final long SALT 20220517L; public static long obfuscate(long id) { return id ^ SALT; } public static long restore(long obfuscatedId) { return obfuscatedId ^ SALT; }异或是对称的很好理解但 SALT 要保密最好通过配置中心下发。接口防刷也要做。生成短链的接口至少要加一个 IP 维度的限流比如单 IP 每分钟 20 次。跳转接口虽然要开放给所有人但也应该做总量限流防止被脚本刷到数据库连接崩溃。限流第一版可以直接用 Redis 的INCREXPIRE实现不用引额外框架。5.2 链接封禁与域名白名单短链系统的合规风险很大因为你不知道用户会把短链跳到哪里。即使你只给自己公司内部用也建议做一层域名黑名单校验。我当时的做法是维护一张 forbidden_domain 表存前缀和域名生成短链时把长链接解析出来检查域名是否命中黑名单。这里有个细节不能只判断字符串包含。比如黑名单里有bad.com攻击者传https://bad.com.evil.com字符串包含 bad.com 但实际域名不是 bad.com如果误判会误伤正常域名。正确做法是用new URL(longUrl).getHost()拿到完整域名再逐级检查先查完整域名再查其父域名直到顶级域名。覆盖全面同时避免误伤。如果业务需要更严格可以做成域名白名单模式只有白名单里的域名才允许生成短链。但这样会限制灵活度适合内部系统。外部开放平台还是用黑名单更现实然后配合人工审核和事后举报。5.3 监控和日志上报半天交付可以不做复杂监控但至少要记录三类日志。第一类是访问日志每条跳转请求要记录短码、来源 IP、UA、时间。第二类是生成日志记录谁在什么时间生成了哪个短码方便后续追踪恶意操作。第三类是异常日志针对校验失败、数据库异常、Redis 异常分别打点。我第一版是直接用 logback 输出 JSON 格式日志然后通过 Filebeat 采集到 ELK。如果你没有这套基础设施简化为按天分文件输出也可以。重点是日志里不要忘记记录 shortCode否则排查问题时根本不知道是哪条短链出了问题。有一个小技巧在跳转接口返回前给响应头加一个X-Short-Code字段值为当前短码。这样前端或者调试工具看到响应就能知道走了哪个短码排查问题时能省很多时间。这个习惯我一直保留到现在。结束前的一点私货最后分享一个我个人的经验短链系统虽然看起来简单但它处在“用户端 - 网关 - 应用 - 缓存 - 数据库 - 外部目标网站”这条链路中间任何一个环节出问题都会变成你的问题。所以不要只把 CRUD 写完就完事要留好日志、做好缓存兜底、对入参保持“不信任”的态度。半天实现第一版完全可行但前提是头脑里对优先级有数先保证生成和跳转两个主流程稳再补统计和安全不要一上来就追求架构上所有事情一步到位。如果你之后打算把它扩展成一个平台可以考虑把发号器换成分布式版增加自定义域名绑定、批量生成、按用户维度管理短链。但在那之前先把 404、502、防遍历这些细节打磨好系统的口碑就是从这些“小事”里来的。
返回列表