尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

短链系统核心链路复盘:从短码生成到跳转的工程决策

短链系统核心链路复盘:从短码生成到跳转的工程决策 短链项目的复习进行到第二天了。Day01我把接口定义和数据模型重新过了一遍今天集中精力把最核心的生成-存储-跳转链路重新走通了。说实话这种个人项目复习最怕的就是当时写的时候能跑回头一看细节全是问号。这篇主要是把我在复习短链过程中重新梳理的关键决策、代码逻辑和一些踩过的坑整理出来给同样在做短链系统、或者想复习自己老项目的朋友一个参考。1. 复习复盘从链路图开始一条短链从创建到跳转经历了什么1.1 先把整条路径放在桌面上短链这个东西原理看起来简单得吓人把长URL压缩成短URL用户点短链再跳回去。但真正自己写一遍就会知道水比想象中深。我复习时第一件事不是看某段代码而是把整条链路从两个方向各走了一遍。创建方向客户端提交长URL → 服务端校验参数和域名合法性 → 生成短码 → 写入短链表 → 预热缓存 → 返回完整短链地址。访问方向用户点击短链 → DNS解析到服务 → 网关/负载均衡 → 短链服务解析短码 → 查缓存 → 缓存未命中则查数据库 → 判断是否过期 → 返回302跳转到目标URL → 异步记录点击流水。这两条链路一拆开就能清楚看到短链系统其实就是两个核心问题生成端要解决短码怎么来、怎么不冲突、怎么不浪费读取端要解决怎么最快把短码映射成长URL、怎么挡掉恶意请求、怎么处理过期数据。1.2 为什么复习要先看链路而不是看代码我个人的习惯是复习老项目时先画链路图而不是直接翻代码。因为代码是当时的结果链路图才是当时的思考过程。很多设计决策比如为什么用这个字段、为什么在这里加缓存、为什么跳转用302单独看代码是看不出原因的但放在链路里每个节点的存在都有它的理由。画链路的同时我会在每个节点旁边标注几个问题这个节点有没有可能成为瓶颈如果流量翻10倍哪个节点先挂如果请求不合法能不能在这一层就拦住这套复习方法非常实用。短链这种读多写少的系统瓶颈几乎一定出现在读取方向要么是缓存扛不住要么是数据库回源太频繁要么是恶意扫描穿透了所有保护直达DB。后面几节的内容其实就是围绕这条链路逐段展开的。1.3 Day02要重点确认的三个节点这次我重点确认了三个节点短码生成、缓存回源、跳转响应。短码生成决定的是短链系统的地基。短码长度、字符集、生成方式、碰撞处理每一项都直接影响后面的缓存设计和DB索引设计。缓存回源决定的是性能上限。缓存命中率高了DB压力自然就小但缓存怎么防击穿、防穿透、防雪崩都是要在链路里真刀真枪解决的问题。跳转响应决定的是用户体验和统计准确性。301和302的选择、统计的埋点、安全校验放在哪一层这些细节都要在跳转那一下里安排好。这三个节点确认完Day02的主线就很清晰了。2. 短码生成的两条路线发号器与随机短码的工程取舍2.1 发号器方案自增ID转62进制发号器是短链系统里最经典的方案。思路很简单维护一个自增的数字ID然后把数字转换成固定长度的62进制字符串0-9、a-z、A-Z这个字符串就是短码。数字转62进制的代码很简单但有几个值得注意的细节。字符集顺序会影响短码的字典序和可读性我建议数字、小写、大写的顺序固定下来别东拼西凑ALPHABET 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ def to_base62(num: int) - str: if num 0: return ALPHABET[0] chars [] while num 0: num, rem divmod(num, 62) chars.append(ALPHABET[rem]) return .join(reversed(chars))注意这里有几个测试用例值得自检ID为1时返回1ID为61时返回Z因为下标61是最后一个字符ID为62时返回10ID为1234时返回jU。这些边界值如果不在写代码时自测一遍上线后很容易出诡异问题。发号器本身怎么实现有几种选择单库自增ID是最简单的方式直接依赖MySQL的auto_increment生成短码时INSERT拿到自增ID再转62进制。问题在于自增ID本身是连续的攻击者可以通过短码反推业务量而且单库自增在写入量上来后会成为瓶颈。Redis批量取号是我这次复习时重新审视的一个方案。用Redis的INCRBY一次性取一段ID区间然后在应用内存里慢慢分配减少对Redis的请求次数import redis r redis.Redis(hostredis, port6379, decode_responsesTrue) BATCH_SIZE 100 def get_id_batch(): # 每调用一次Redis把计数器增加100返回新的最大值 new_max r.incrby(short_url:seq, BATCH_SIZE) # 应用本地获得 [new_max - BATCH_SIZE 1, new_max] 这段ID return range(new_max - BATCH_SIZE 1, new_max 1)这种方案的好处是Redis操作频率降低了100倍应用启动时取一批号放内存用完再取。坏处是如果应用拿到一批号之后崩溃了这批号就永久浪费了短码总量和DB行数会对不上。雪花算法是另一个常见选择它不依赖Redis也不依赖数据库自增而是通过时间戳机器ID序列号生成一个全局唯一的int64。但雪花ID转成62进制之后短码会比较长因为雪花ID本身很大62进制转换后通常在10-11位对短链来说有点偏长。2.2 随机短码方案空间换可预测性随机短码的思路是完全不依赖发号器直接从62个字符里随机抽6到7位作为短码。好处很明显不可枚举别人猜不到相邻短码想批量抓取短链会困难很多。但随机方案绕不开一个数学问题碰撞概率。以6位短码为例总空间是62的6次方约568亿个组合。假设系统里已经有m条短码记录插入一条新短码时发生冲突的概率就是m除以总空间。算一下当m为100万时冲突概率约百万分之1.76看起来很低但当m达到1亿时冲突概率就变成了约万分之1.76。如果用7位短码总空间提升到约3.5万亿1亿条记录时冲突概率降到百万分之2.8。所以随机短码方案里生成后查一下是否已存在这一步是必须的不能省。实际工程中就是用数据库的唯一索引兜底插入时如果报唯一键冲突重新生成短码再试一次一般重试两三次就能成功。2.3 布隆过滤器优化随机短码的查重随机短码方案如果每次都因为潜在冲突去查数据库在高并发创建场景下仍然会形成不必要的DB压力。我在复习时把布隆过滤器加在了短码生成之前先生成候选短码用布隆过滤器判断是否存在如果过滤器说不存在就直接尝试插入如果过滤器说可能存在再走唯一索引冲突重试。布隆过滤器的原理不复杂它用多个哈希函数把元素映射到一个位数组上查询时如果任何一个位为0说明元素一定不存在如果所有位都是1说明元素可能存在但不等同于一定存在。这种特性非常适合低概率冲突但能挡掉绝大多数重复查询的场景。用Redis的bitmap和两个哈希函数就可以实现一个简版import hashlib BIT_SIZE 200_000_000 # 20亿bit约250MB其实这里用2亿bit更合理 BIT_SIZE 200_000_000 def _hash_double(short_code: str): h1 int(hashlib.md5(short_code.encode()).hexdigest()[:8], 16) h2 int(hashlib.sha256(short_code.encode()).hexdigest()[:8], 16) return (h1 % BIT_SIZE, (h1 h2) % BIT_SIZE) def bf_add(r, short_code: str): p1, p2 _hash_double(short_code) r.setbit(short_url:bloom, p1, 1) r.setbit(short_url:bloom, p2, 1) def bf_exists(r, short_code: str) - bool: p1, p2 _hash_double(short_code) return r.getbit(short_url:bloom, p1) and r.getbit(short_url:bloom, p2)生产环境如果不想自己维护可以直接用RedisBloom模块BF.RESERVE、BF.ADD、BF.EXISTS一条命令就搞定。只是要注意布隆过滤器的误判率会随着已插入元素数量增加而升高所以位数组大小最好按预估最大数据量的10倍以上来申请或者定期重建。2.4 两种方案怎么选我做了个对比表方便决策时一眼看清楚维度发号器方案随机短码方案短码是否可预测连续自增可枚举随机不可枚举碰撞处理天然不冲突需要唯一索引重试生成的短码长度ID大小决定通常6位左右固定6-7位依赖组件数据库/Redis/雪花算法随机源布隆过滤器可选适合场景内部系统、可信用户公网开放服务我的结论是公开的短链服务优先用随机短码方案配合布隆过滤器和唯一索引如果是公司内部短链用户可信、量也不大用发号器方案简单省事就够了。我之前那个项目一开始用的发号器后来为了防枚举切了一部分流量到随机短码两种模式并存通过短码校验规则区分来源。3. 读路径是生命线表结构、索引与缓存该怎么配合3.1 短链表结构设计的最终选择短链系统的表结构网上很多教程会建议直接用短码作为主键因为查询时可以避免一次回表。我第一版也是这么干的但复习时认真想了想最终还是决定改成自增主键短码唯一索引的结构。原因有三点。第一短码本身是随机字符串即使是发号器生成的62进制字典序和自增ID也没关系如果直接作为主键新插入的行在主键B树上的位置是随机的数据量上去后会造成大量页分裂和写放大而自增主键是顺序插入性能稳定得多。第二短链通常还有过期时间、状态、用户ID等字段后期做批量软删除、统计都要按这些字段操作单独的自增主键更方便。第三短码那一次回表可以用覆盖索引解决不需要牺牲主键设计。表结构大概是这样的CREATE TABLE short_url ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, short_code VARCHAR(16) NOT NULL COMMENT 短码, long_url VARCHAR(2048) NOT NULL COMMENT 目标长链接, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-生效 0-失效, user_id BIGINT DEFAULT NULL COMMENT 创建者, expires_at DATETIME DEFAULT NULL COMMENT 过期时间NULL表示永久, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_short_code (short_code), KEY idx_status_expires (status, expires_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;3.2 索引设计的两个关键决策第一个关键是短码唯一索引。短码列的索引必须加UNIQUE这不仅是性能需求更是随机短码方案里防止并发创建同码的兜底。没有唯一索引两个请求同时生成同一个随机短码会出现两条记录读路径就只能随机返回其中一条属于严重事故。第二个关键是过期清理的索引。idx_status_expires (status, expires_at)这个联合索引就是给第5节的定时清理任务用的。status在前expires_at在后可以快速定位状态生效但已经过期的行。要注意的是很多短码是永远不过期的expires_at为NULL这类数据不会被索引有效利用但对规模不算太大的系统问题不大如果永久短码占绝对多数可以考虑把永久和临时的短码分成两套表或者给expires_at加一个默认的远期值比如2038-01-01保证所有行都有值。读路径的查询语句也要写对不要无脑SELECT *SELECT long_url, expires_at, status FROM short_url WHERE short_code %s AND status 1;在unique索引上这条查询走索引覆盖时其实还是需要回表一次拿long_url所以如果并发量极高可以建一个(short_code, long_url, expires_at, status)的复合索引来彻底避免回表。但多一个索引多一份写入开销量级没到百万QPS就不用太纠结。3.3 缓存层级与Key设计短链的读路径是典型的读多写少缓存是命脉。完整的缓存设计至少分两层本地缓存处理热数据比如Caffeine设置几十万的容量、几秒钟的过期时间。这一层用来承接最热的短链流量避免把压力全部打到Redis。Redis作为分布式二级缓存存全量热点数据。Key设计我采用short:code:{shortCode}这种格式Value直接存long_url。为什么不在Value里再放expires_at和status因为解析跳转是高频操作序列化和反序列化的开销越小越好。过期和状态判断放在查询时单独处理后面会说到。缓存过期时间不是随便设的。如果短码本身有物理过期时间比如7天后失效那么缓存时间应该取min(7天, 业务容忍的缓存时间)。我之前踩过一个坑短码配置了1小时过期缓存却设了7天结果短码已经失效了用户访问时缓存照样返回302跳到了本来不该访问的URL。这种问题很隐蔽写路径上的缓存预热、读路径上的缓存TTL都要跟短码的过期时间联动。3.4 缓存读路径的完整逻辑一个健壮的读路径不只是先查缓存没命中再查库这么简单。完整的逻辑至少要处理三种异常情况缓存穿透查一个根本不存在的短码、缓存击穿一个热key恰好失效、缓存雪崩大量key同时过期。我用非严格伪代码把完整逻辑捋了一遍def resolve_short_url(short_code: str): # 1. 参数白名单校验挡掉不合法字符 if not re.fullmatch(r[0-9a-zA-Z]{6,8}, short_code): return None # 2. 布隆过滤器快速判断短码是否可能存在不存就直接返回404 if not bloom_exists(short_code): return None # 3. 查Redis缓存 url redis.get(fshort:code:{short_code}) if url: return url # 4. 加分布式锁防止大量请求同时回源DB with redis_lock(flock:short:{short_code}): # 4.1 二次检查缓存double-check url redis.get(fshort:code:{short_code}) if url: return url # 4.2 查DB row db.query_one(...) if not row or row[status] ! 1: # 4.3 不存在的记录也要短暂缓存防止恶意穿透 redis.setex(fshort:code:{short_code}, 10, __NOT_FOUND__) return None if row[expires_at] and row[expires_at] now(): return None # 4.4 只有有效的短码才写入Redis cache_ttl min(row[expires_at] - now(), 7 days) if expires else 7 days redis.setex(fshort:code:{short_code}, int(cache_ttl), row[long_url]) return row[long_url]这段逻辑里的几个细节都是踩坑踩出来的。空值也要缓存10秒这个设计非常关键。如果某个短码不存在又没法通过布隆过滤器100%挡住布隆过滤器有误判恶意用户就可以不断请求随机短码每次都穿透到DB。加10秒空值缓存之后同一批随机短码短时间内只会打DB一次。分布式锁的做法是先用SET lock:xxx NX PX 3000加锁拿到锁的线程做DB回源和缓存写入拿不到锁的线程sleep几十毫秒后重新读缓存。这样能避免缓存刚失效时几百个请求同时打到数据库。当然如果能保证单机部署用进程内的互斥锁更简单但都做短链了一般还是按分布式去设想。3.5 缓存写路径创建短码时就直接预热既然读路径已经依赖缓存了创建短码时就应该顺手把缓存写好而不是等第一次访问时再回源。这样用户体验更好也能减少不必要的DB查询。创建接口在写完DB后执行一次SETEX short:code:{code} {ttl} {long_url}把缓存预热。注意这里TTL同样要跟expires_at联动。更重要的是如果创建后短码被管理员手动封禁status改为0必须显式删除缓存否则用户在缓存过期前还能访问到被封禁的链接。我在复习时就发现自己的老代码里漏了这个主动删缓存的动作后来补上了。4. 跳转那一下的细节301/302、统计与安全卡点4.1 301和302不只是状态码的区别短链服务的核心出口就是一个HTTP重定向。不少初学者会随手写302或者随手写301但这两者的区别对短链系统来说是根本性的。301是永久重定向浏览器会把这个跳转关系缓存下来。同一个短码第二次访问时浏览器直接跳到目标URL根本不访问短链服务。302是临时重定向浏览器每次都会先访问短链服务拿到新的Location之后再去目标页。两张方案的对比如下维度301永久重定向302临时重定向浏览器缓存会缓存后续不再请求短链每次都请求短链点击统计基本统计不全只能统计第一次可以统计每次点击修改目标URL老用户可能一直访问旧地址下次访问立刻生效业务扩展无法做设备分流、A/B可以根据UA跳不同页面服务端压力小大需要缓存扛流量短链系统选302是行业共识。原因很简单短链的价值不只是缩短URL而是可控、可统计、可修改。如果用了301用户第一次点击后浏览器就记住了跳转地址之后你改了长URL用户也不一定刷新得过来点击统计更是直接失真。所以只要不是纯静态的永久推广位一律用302。当然302也有代价就是每次访问都要打到短链服务。解决的办法就是第3节说的缓存层只要缓存命中率高302的开销完全可控。4.2 点击统计不能阻塞跳转既然选了302就绕不开统计。点击次数、用户UA、来源IP、跳转时间这些都是短链的黄金数据。但统计不应该放在跳转链路上阻塞响应。推荐的方案是异步化。在解析到long_url之后、返回302之前把点击事件扔进消息队列或Redis stream后端再异步消费写入统计表。如果不想引入重型中间件最简单的做法是Redis里INCR一个计数器定时批量把计数落库。统计链路哪怕挂了也不影响正常跳转这才是正确的服务降级方式。我在老项目里还把是否记录统计做成了短码级别的开关。有些内部使用的短码不需要统计省掉写Redis的开销。4.3 短链安全卡点这里最容易被忽略短链的本质是一个开放重定向接口天然容易成为钓鱼和恶意跳转的工具。复习时我把安全卡点重新梳理了一遍主要在四个位置第一入参校验。短码必须走白名单正则[0-9a-zA-Z]{6,8}这一步能挡掉大量注入类请求。SQL查询一律参数化不能拼字符串。第二长URL合法性校验。创建短链时对目标URL做域名级校验至少要检查协议是否为http/https不能允许javascript:这类伪协议。公网服务最好维护一个恶意域名黑名单或调用安全眼查接口拿不准的URL在跳转前先展示一个安全提示页。第三频率限制。这个主要是防枚举扫描。发号器生成的短码天然连续可猜攻击者只要拿到一个短码就能遍历前后几千个短码把整个系统的短链数据抓走。随机短码方案能缓解这个问题但依然需要按IP限制创建频率和解析频率。对解析接口来说正常用户不可能在一分钟内请求上千个不同的短码所以这个限流可以设得严格一些。第四跳转目标的安全兜底。即使创建时校验过了目标URL的页面内容也可能在后续变得不安全。比较稳妥的做法是可以配置跳转前安全检测命中风险规则就返回拦截页。完全不校验的裸跳转风险太高不建议公网开放。这些安全卡点不需要每一个都做全套但至少要清楚自己承担了哪些风险。内部工具可以砍掉安全页公网服务则建议全部落实。5. 过期短码的清理与归档懒删除和定时任务怎么搭5.1 两条腿走路查询懒删除定时任务短链系统里必然存在大量过期短码。释放短码空间、清理脏数据、避免用户访问到已失效的链接这些都要求有一个完善的过期处理机制。我复习后确定了两条腿走路的方案查询时懒删除负责实时判断定时任务负责批量清理。懒删除的逻辑其实在第3节的伪代码里已经出现了读路径查到短码后如果发现expires_at已经早于当前时间就直接返回410 Gone或者404并顺手把缓存删掉。这种做法的好处是不需要额外的扫描任务坏处是它只处理有人访问的过期短码没人访问的过期行会一直躺在表里。所以需要定时任务来兜底。每隔一段时间扫描并清理已经过期但还没被物理删除的行。5.2 定时清理任务的操作细节定时任务最容易犯的错就是一条DELETE FROM short_url WHERE expires_at NOW()把全表锁住。数据量稍大业务就直接被拖垮。正确写法是分批删除-- 每次只取1000条过期记录的ID SELECT id FROM short_url WHERE status 1 AND expires_at NOW() LIMIT 1000;拿到这1000个id之后逐条删除缓存并物理删除DB记录或者做软删除。删除时用DELETE ... WHERE id IN (...)主键删除不会扫描全表。批量操作时注意任务里还要做两件事。一是删除Redis缓存否则缓存里会残留已经删除的短码造成缓存里有、DB里没有的不一致。二是更新布隆过滤器因为布隆过滤器不支持删除单个元素如果使用的是自实现版本删掉一批短码后过滤器里的位还是1会导致后续对已删除短码的查询误判为可能存在最多只是多查一次DB影响不大。如果确实要精确BloomFilter的定期重建也是一种方案比如每天低峰期重建一次。过期短码到底物理删除还是软删除我最终的结论是保留一段时间的软删除状态用于审计和防误操作确认真不需保留了再物理删除。可以通过给status字段打标记来实现查询时一律过滤status1。5.3 统计数据和短码数据的分手这里有个容易忽略的点过期短码的统计数据不能被短码的物理删除牵连。短码本体删了但点击统计表里还有大量按时间聚合的记录。这些数据对分析用户行为、渠道效果都有价值不能直接丢。所以统计表的主键是独立的short_code hour/days不依赖短链表的外键约束物理删除短码时不需要同步删除统计记录查询统计时即使短码已不存在历史数字依然能查出来。实际的实现里我会把统计放在ClickHouse或者MongoDB这种适合分析的存储里MySQL只做主库。对于个人项目来说一张独立的short_url_stats表就够了定时从Redis计数落库。5.4 续期逻辑要跟清理任务配合用户如果发起续期把过期时间往后推这里的处理会比第一次设置稍微复杂一点。续期接口要做三件事更新DB里的expires_at、删掉旧缓存、重新写入带新TTL的缓存。注意顺序不能反。如果先写缓存后更新DB中间这几十毫秒里如果有请求进来缓存TTL还是旧值可能很快又过期。我自己习惯的顺序是先改DB再删缓存最后预热新缓存。这样即使删缓存和预热之间存在空窗期顶多trigger一次DB回源数据不会错。6. 复习中发现的三个值得改造的角落6.1 公开服务必须重视短码枚举问题这次复习让我最不舒服的一个问题就是如果还用发号器生成短码别人拿到一个短码后就可以枚举相邻ID的短码相当于把整个短链系统的目标URL批量带走了。就算加了限流也只是降低速度没有根治。我最后做的改造是对外公开的短链全部切到随机短码方案并且通过布隆过滤器DB唯一索引双保险。发号器只保留给内部批量导入工具用因为内部工具的用户本来就可信。6.2 批量取号要考虑空洞问题Redis批量取号那个方案应用拿一批ID后如果还没用就崩溃会浪费一整个BATCH的ID导致发号器水位线和DB实际行数对不上。这个偏差在没有参考意义的地方确实无所谓但在做渠道统计时就很恶心你无法判断一个短码从来没有被创建还是创建了但被浪费了。要解决要么把批次分配这个动作做成可恢复的比如把当前批次号也存进DB恢复时接着用要么干脆接受浪费在监控里记录浪费率 发号水位 - 有效短码数超过阈值报警。我选择后者因为纠正浪费的复杂度比浪费本身还高。6.3 不存在短码的空值缓存必须加上读路径里最容易漏的就是空值缓存。很多人只考虑了热key失效后怎么防止击穿却忽略了一个更常见的场景攻击者批量请求随机短码这些短码大概率不存在如果每次都不命中缓存直接查DB数据库会被穿透连击打挂。我的做法是DB回源之后如果没查到也往Redis写一个10秒的__NOT_FOUND__空值。这样同一个不存在的短码在10秒内只会穿透一次。配合布隆过滤器能挡掉99%以上的无意义DB查询。复习Day02做到这里我最大的体会是短链系统真正难的从来不是算法而是权衡。发号器还是随机短码、自增主键还是短码主键、301还是302、懒删除还是定时清理每个选择都有代价只不过有些代价要等流量上来才看得见。复习的意义大概就是把当时没想明白的代价重新想一遍。
返回列表