短链接系统设计:发号、分片、缓存、热点与容灾

从创建与跳转两条请求路径出发,设计短码生成、数据分片、多级缓存、热点隔离、更新删除、访问统计、安全治理与多地域容灾,并说明 UID、主键和分片键怎样各自承担职责。

短链接看起来只做一件事:把 https://s.example/7Fk3aQ 跳转到一段更长的 URL。投入生产后,它会同时面对两类差异很大的请求。创建接口流量较低,却要保证短码唯一、请求幂等和写入结果确定;跳转接口可能高出几个数量级,要求在几十毫秒内找到目标地址,还要应对热点、恶意扫描、链接封禁和依赖故障。

本文回答的问题是:怎样把分布式 ID、短码、主键、分片键、缓存和容灾组合成一套闭合的短链接系统,并让每个机制只承担它能够证明的职责?前文已经分别解释了 Leaf 等分布式 ID、分片键与路由、缓存异常与一致性和热点治理。这里直接使用这些机制,重点放在完整请求怎样穿过系统,以及故障发生后还能返回什么。

一、先把创建和跳转看成两个系统

短链接至少有创建、跳转、管理和统计四类能力。创建与管理属于控制面,负责改变映射;跳转属于数据面,消费已经确认的映射;访问统计可以异步处理,不应成为跳转成功的前置条件。

短链接系统的创建链路与跳转链路

两条主路径的目标并不相同:

路径 典型比例 主要目标 允许的取舍
创建 POST /links 低 唯一、幂等、可管理 可接受几十毫秒发号与持久化
跳转 GET /{code} 极高 低延迟、高可用 可在约定时间内读取旧映射
管理 PATCH /links/{code} 很低 权限、版本、一致性 可以等待缓存失效确认
统计事件 与跳转量相同 不丢或可估算、可聚合 允许秒级到分钟级延迟

设计时先给出一组用于推导的假设。假设每年创建 1 亿条链接,日均约 27 万条,峰值创建 200 QPS;跳转峰值 100,000 QPS,读写峰值比约 500:1;跳转 P99 目标 50 ms,链接默认长期有效,允许用户修改或停用。每条主记录连同索引按 1 KB 粗估,一年原始数据约 100 GB,副本、索引和统计另算。

这些数字不代表通用答案。内部营销平台可能只有几千 QPS,却要求所有目标域名在白名单中;公共短链平台的跳转量更高,滥用治理成本甚至超过存储成本。容量数字的用途是暴露设计约束:创建库不需要按十万 QPS 规划,跳转路径也不能每次都访问关系数据库。

对外契约先决定缓存和一致性

一条短链至少要声明以下语义:

  • 同一个创建请求重试,返回原短链还是允许生成新短链;
  • 同一个长 URL 由不同用户提交,是否应该得到同一个短码;
  • 目标 URL 修改后,全球节点最晚多久看到新值;
  • 链接停用、过期和安全封禁分别需要多快生效;
  • 不存在、已过期、被封禁和后端故障使用什么响应;
  • 查询参数是否透传,目标 URL 已有查询参数时怎样合并。

“同一长 URL 生成同一短码”通常不是必要条件。同一个地址可能属于不同租户,拥有不同过期时间、权限、渠道和统计维度。系统可以提供显式去重选项,默认创建则把每次业务意图视为独立资源。同一个请求因超时而重放时才需要返回原结果,这由幂等键处理。

二、UID、主键、短码和分片键各做一件事

短链接设计容易把几个标识混成一个词。一个可维护的数据模型会先拆开它们:

字段 职责 是否公开 能否单独定位分片
link_id 全局标识,也可作为主键 通常不公开 取决于路由设计
short_code URL 中的业务标识 是 本文方案可以
owner_id 归属、权限与后台列表 否 定位用户索引分片
request_key 一次创建意图的幂等键 只在创建 API 使用 定位幂等记录
bucket_id 稳定逻辑分片 否 通过桶表找到物理分片

本文采用一套具体方案:Leaf 生成 link_id,可逆扰动后做 Base62 编码得到 short_code;主数据按 namespace + short_code 的稳定哈希进入 4096 个逻辑桶;用户后台列表由按 owner_id 分片的索引表承担。UID 提供全局身份,短码是公开查询键,分片函数负责放置,三者可以相互转换,却没有必要合并成同一职责。

UID、短码、逻辑桶与物理分片的职责链

主表可以设计为:

CREATE TABLE short_link_017 (
    link_id       BIGINT UNSIGNED NOT NULL,
    namespace     VARCHAR(64) NOT NULL,
    short_code    VARCHAR(16) NOT NULL,
    target_url    VARCHAR(2048) NOT NULL,
    owner_id      BIGINT UNSIGNED NOT NULL,
    status        TINYINT NOT NULL,
    expire_at     DATETIME(3) NULL,
    version       BIGINT UNSIGNED NOT NULL,
    created_at    DATETIME(3) NOT NULL,
    updated_at    DATETIME(3) NOT NULL,
    PRIMARY KEY (link_id),
    UNIQUE KEY uk_namespace_code (namespace, short_code),
    KEY idx_owner_created (owner_id, created_at, link_id)
) ENGINE=InnoDB;

namespace 可以代表平台域名、自定义域名或租户空间。短码只在命名空间内要求唯一,go.example/a1b2 与 brand.example/a1b2 可以指向不同记录。路由、缓存和唯一索引都必须包含 namespace;只拿 short_code 做缓存 key 会让两个域名互相串数据。

表里的 idx_owner_created 只能加速已经落到这个分片的局部查询,无法高效列出一个用户散落在全部分片的链接。生产方案通常另建 user_link_index(owner_id, created_at, link_id, namespace, short_code, title, status),或者使用搜索系统提供后台列表。索引可以异步更新,主表仍是跳转和权限状态的事实来源。

三、短码怎样生成

短码需要在长度、唯一性、可枚举性和生成依赖之间取舍。常见方案有四种。

递增 ID 编码最容易证明唯一

Base62 使用数字、大小写字母表示整数。七位 Base62 一共有:

62^7 = 3,521,614,606,208

如果底层 link_id 全局唯一,且编码是一一映射,短码不会碰撞。Base62 只改变表示形式,不提供保密性。连续的 ID 会产生可预测短码,攻击者可以枚举附近值;低位 ID 的编码还很短,容易暴露大致创建量。

一种改进是在固定整数域中做可逆置换,再编码公开值:

link_id ── reversible permutation(key_version) ── public_number ── Base62

置换保持一一对应,所以唯一性仍来自 link_id。它只能降低顺序可见性,不能代替授权,也不能把短码变成密码学秘密。算法、密钥版本与数值范围必须长期可解码;轮换时保留旧版本解析器,不能让已发布 URL 失效。若团队不愿维护这份协议,直接使用足够长的随机码更简单。

Base62 中的字母和数字属于 RFC 3986 定义的 unreserved 字符,放在 URL path 中不需要百分号编码。是否保留大小写是产品选择:Base62 空间更大,但人工抄写和某些渠道归一化更容易出错;排除 0/O、1/l/I 的 Base58 可读性更好,码长会略增。

随机码用碰撞重试换掉中心发号

每个创建节点从安全随机源生成 8 到 10 位短码,在目标分片执行唯一插入;冲突就重新生成。它不依赖 Leaf,也不暴露递增关系。空间接近用满时,重试次数会上升,因此要监控唯一键冲突率并预留足够余量。

随机码的碰撞不能用“空间很大”一句话带过。若在 M 个可能值中均匀生成 n 次,预期碰撞对数约为 n(n-1)/(2M)。一亿条记录使用七位 Base62 时,这个期望已达到千次量级;唯一约束和重试能够处理,却说明碰撞一定会出现。八位空间约为 218 万亿,压力显著降低。

对长 URL 做哈希不适合直接承担资源身份

截断 SHA-256(long_url) 可以生成稳定短码,却会把多个业务语义绑在一起。不同用户对同一 URL 的过期时间、渠道和权限可能不同;URL 参数顺序、默认端口、大小写和 percent-encoding 还会制造规范化问题。截断后仍需处理碰撞,完整哈希又失去“短”的价值。

哈希适合做可选去重指纹。系统先按租户和规范化策略计算 fingerprint,命中后由业务决定复用还是创建新资源。它不应成为默认主键规则。

自定义别名走相同唯一约束

用户指定 brand.example/autumn 时,系统仍按 namespace + short_code 路由并执行唯一插入。保留词、敏感词、大小写规范和删除后能否重新占用都要写进规则。自定义码不会经过 UID 编码,但记录仍可持有 Leaf link_id 作为内部主键。

因此短码生成算法与数据路由不应强绑定。生成码、随机码和自定义码都能进入同一套 hash(namespace, short_code) 路由,后续管理链路也无需猜测短码来自哪一代算法。

四、分片从跳转请求携带的字段出发

公开请求是 GET /{short_code},天然携带域名和短码,不携带 owner_id。主表若按用户分片,缓存未命中后要先查全局目录,再访问用户分片;主表按短码路由则可以一步找到数据。短链接读多写少,跳转延迟比后台列表更敏感,本文选择后者。

路由分为稳定逻辑桶与可变物理放置:

route_key = UTF8(lowercaseHost + "\0" + exactShortCode)
bucket    = unsigned_xxhash64(route_key) % 4096
shard     = bucket_map[topology_version][bucket]

域名按 DNS 规则归一化,short code 按产品约定保持大小写,二者之间加入不会出现在规范域名中的分隔字节,避免字符串拼接歧义。哈希算法、字符编码、无符号取模和桶数都是持久协议,不能依赖 Java hashCode() 之类可能跨语言不一致的实现。

4096 是逻辑桶数,不是数据库数量。初期可以由 8 个物理分片各承载 512 个桶;扩到 16 个分片时迁移一半桶,外部短码和桶号不变。迁移协议、双读校验和唯一写入点已在分库分表文章展开,这里只要求跳转服务携带路由版本,旧 owner 收到请求时能返回新版本,而不是悄悄读取一份长期过时的数据。

为什么主方案不从短码反解分片

若短码直接编码 link_id,确实可以解码后再算桶,少做一次字符串哈希。问题在于自定义别名、随机码和新编码版本都要额外分支。将路由统一定义为短码哈希,生成策略可以独立演进,代价只是一次本地哈希。

另一套系统也可以选择“短码反解 ID,再取 ID 中的路由基因”。这适合短码全部由同一可逆协议生成、只有 ID 的查询极多且逻辑桶长期稳定的场景。两种方案都合理,不能同时让部分服务按短码哈希、部分服务按 ID 低位路由,否则同一条记录会被计算到两个位置。

用户列表是另一条访问路径

主表按短码均匀分布后,GET /users/{ownerId}/links 需要用户索引。创建主记录时写 Outbox,消费者把摘要写入按 owner_id 分片的 user_link_index。列表页只展示短码、标题、创建时间和状态,可直接读取索引;修改和删除仍回到主表确认版本与权限。

异步索引意味着创建成功后列表可能短暂不可见。接口可以返回 link_id 和短码,让前端先插入本地结果;索引事件携带主表版本,重复消费时取较大版本。若产品要求严格的 read-your-writes,创建响应前同步写索引或在一段时间内由主记录补齐,但这会把另一套存储带进成功条件。

五、创建链路要同时处理幂等与结果未知

推荐的创建接口如下:

POST /v1/links
Idempotency-Key: 01J9Q7...
Content-Type: application/json

{
  "targetUrl": "https://shop.example/items/42?from=autumn",
  "domain": "s.example",
  "expireAt": "2027-01-01T00:00:00Z",
  "customCode": null
}

服务先校验身份、目标 URL、域名归属和配额,再在幂等存储中以 (owner_id, request_key) 抢占创建意图。抢占成功者申请 Leaf ID、生成短码、计算逻辑桶并写主记录;并发重试者读取同一幂等记录。主记录与用户索引不必做跨库事务,主分片事务同时写 Outbox,索引由事件构建。

RECEIVED → ALLOCATED(link_id, code, bucket) → STORED → SUCCEEDED
                         │                     │
                         └──── retry/reconcile ┘

ALLOCATED 状态很重要。Leaf 已返回 ID、应用却在数据库响应前超时,此时新请求不能盲目再生成一条链接。恢复任务根据已记录的 code 和 bucket 查询主分片:记录存在就推进成功,不存在且确认事务未提交再补写。无法判断时保持处理中,由查询接口返回当前状态。

一部分系统允许 Leaf ID 出现空洞,却不持久化 ALLOCATED。这也能正确工作,前提是同一个请求重试前能按 request_key 找到已经提交的主记录,并且重复创建的业务后果可接受。幂等存储把不确定窗口显式化,更适合计费、配额或自定义别名等不能重复占用的场景。

创建成功的提交点应定义为主记录已经持久化。Redis 回填、用户索引和统计标签可以异步完成。把“所有缓存均已预热”放进创建事务会增加故障依赖;完全不考虑新链接的首次读取,也会让全球边缘缓存短暂返回 404。后面的多地域部分会给出激活策略。

六、跳转链路只做定位、判定和响应

跳转服务收到请求后执行以下步骤:

  1. 规范化 Host,解析并校验 short code 的字符与长度。
  2. 查询边缘封禁规则,拒绝已知恶意或已下架链接。
  3. 按 namespace + code 查询本地缓存,再查 Redis。
  4. 缓存未命中时计算 bucket,只访问一个主数据分片。
  5. 检查状态与过期时间,返回带 Location 的跳转响应。
  6. 把访问事件异步写入本地批次或消息队列。

缓存对象应包含 target_url、status、expire_at 和 version,避免命中后还要查数据库确认状态。权限信息不属于公开跳转路径;如果某类链接要求登录或一次性 token,它已经不是普通公共短链,需要单独的访问令牌与消费状态设计。

302、307、301 和 308 怎样选

RFC 9110 的重定向语义中,301 和 308 表示永久迁移,302 和 307 表示临时目标;307、308 明确要求自动跳转时保留原请求方法,而 301、302 允许某些客户端把 POST 改为 GET。

普通短链入口只接受 GET 和 HEAD,其他方法返回 405,因此方法保留通常不是主要矛盾。允许用户修改、停用和封禁的短链更适合返回 302,并设置有界的 Cache-Control;浏览器和中间代理可能长期记住 301/308,使后台已经更新,用户仍绕过短链服务访问旧目标。只有目标永远不可变、愿意让客户端永久缓存时,才考虑 301 或 308。

HTTP/1.1 302 Found
Location: https://shop.example/items/42?from=autumn
Cache-Control: public, max-age=60, stale-if-error=300

stale-if-error 是否启用取决于安全边界。普通营销链接在源站短时故障时可以继续使用五分钟前的映射;已封禁链接不能因 stale 策略重新放行。封禁名单需要比普通映射更短的传播路径,并在边缘层优先判断。

查询参数合并必须有固定规则

请求 s.example/abc?utm_source=x 是否把查询参数附加到目标 URL,要由产品显式定义。若允许透传,应使用 URI 解析器分别处理 scheme、authority、path、query 和 fragment,不能直接字符串拼接 target + "?" + requestQuery。目标 URL 已有同名参数时,是覆盖、追加还是拒绝也要固定。

浏览器不会把原 URL 的 fragment 发送给服务器,因此 s.example/abc#part 中的 #part 无法被跳转服务读取。服务只能在保存的目标 URL 中配置 fragment。把这些边界写进契约,可以避免渠道参数在不同语言客户端中出现不一致。

七、多级缓存怎样分担十万 QPS

短链映射读多写少,适合从边缘到数据库建立多级缓存:CDN 或边缘节点缓存 302,应用内 L1 缓存热点映射,Redis 保存较大工作集,MySQL 分片持有权威记录。

短链接多级缓存与热点隔离

假设入口 100,000 QPS,边缘命中 90%,剩余 10,000 QPS 到服务;L1 再命中 80%,Redis 承担 2,000 QPS;Redis 命中率 99%,数据库稳定回源约 20 QPS。真实容量还要考虑失效相关性:边缘规则变更、应用发布清空 L1、Redis 分片故障可能让这些 miss 同时发生,数据库不能按稳定 20 QPS 设计保护策略。

缓存 key 和 value 都要带完整语义

key   = short:v3:{normalized_host}:{exact_code}
value = { targetUrl, status, expireAt, version, loadedAt }

版本前缀用于缓存格式升级,不代表数据版本。Value 中的业务 version 防止旧回填覆盖新值。TTL 不能超过链接剩余有效期;永久链接也要保留最大 TTL,让漏掉的失效最终收敛。

应用本地缓存可以订阅失效消息,也可以使用 Redis client-side caching。Redis 的客户端缓存文档说明,服务端会跟踪读取过的 key,并在 key 修改时向相关客户端发送 invalidation;连接断开时客户端应清空本地副本,避免遗漏失效后继续返回旧值。无论使用哪种通道,都要用 TTL 约束消息丢失后的最长陈旧时间。

不存在的短码也要限制回源

公开短码会被爬虫随机扫描。如果每个不存在的 code 都查 Redis 和数据库,攻击者可以用低成本制造缓存穿透。防线包括字符与长度校验、来源和网段限流、短时负缓存,以及在规模足够大时使用 Bloom Filter 预判。

负缓存的 TTL 应较短,因为刚创建的链接可能与某个边缘节点缓存过的 404 相遇。Bloom Filter 更新也不是主事务的权威证据:创建记录已经提交、过滤器事件尚未到达时,直接相信“明确不存在”会误伤新链接。可以让新创建 code 在激活窗口内绕过过滤器,或者让过滤器版本只覆盖一个已经封闭的历史范围。

八、一个爆款短链不能把统计写成单点

明星发布一条短链后,单个 code 可能承担几十万 QPS。缓存命中能够复制同一映射,但如果每次跳转都同步执行 INCR clicks:{code}、写访问明细或检查数据库状态,热点只是从主表移动到了计数器、消息分区或日志系统。

跳转响应应在映射判定后立即返回,统计事件写入进程内批次、UDP 式采集链路或可限时等待的消息生产者。允许少量统计误差的产品可以采样;广告计费等需要可对账的场景,应让边缘或服务节点生成事件 ID,批量持久化并明确重复消费语义,但统计失败仍不能把用户跳转拖到超时。

单链接计数可以按时间与来源分桶:

click:{link_id}:{yyyyMMddHH}:{producer_bucket}

写入分散到多个桶,查询时聚合,离线任务再压缩为小时或天级结果。Kafka 以 link_id 做唯一分区键会让爆款链接卡住一个 partition;可以在不要求单链接严格事件顺序时加入 producer bucket,把同一链接分散到多个分区。去重与聚合依靠 event ID 和窗口状态,不依赖全局顺序。

映射缓存本身也可能形成 Redis 单 key 读热点。边缘缓存和每实例 L1 通常已经把中心读取降得很低;仍不足时,可以复制成多个物理 key,由请求哈希选择副本,并在更新时携带统一版本失效。具体拆分边界见热点数据文章。

九、修改和删除最难的是旧副本仍在跳转

更新目标 URL 时,主分片使用版本做条件更新:

UPDATE short_link_017
SET target_url = :newUrl,
    version = version + 1,
    updated_at = NOW(3)
WHERE namespace = :namespace
  AND short_code = :code
  AND owner_id = :ownerId
  AND version = :expectedVersion;

事务同时写 Outbox。提交后先删除或覆盖 Redis,再由事件使 L1、CDN 和用户索引失效。若失效消息重复,消费者按版本处理;若消息延迟,TTL 提供最终上界。回源线程把版本 7 写回缓存前,发现当前已有版本 8,就必须放弃旧结果。

删除更适合写 tombstone,而不是立刻物理删除。状态改为 DISABLED 或 DELETED 后保留 namespace、code、owner、version 和审计时间,使旧缓存回源时得到明确拒绝,也防止同一短码立即被别人重新注册。物理清理在保留期后异步执行。

安全封禁的传播要求通常高于普通编辑。可以维护一份小而快的 denylist,在 CDN Worker、网关或跳转服务最前面检查;主记录状态随后收敛。若边缘控制面故障,风险链接选择 fail closed,普通映射可以继续使用短时间旧值。所有缓存统一使用一条慢速失效通道,会让下架速度受最慢层决定。

缓存一致性的完整竞态已在缓存文章说明。短链场景需要额外记住:302 本身也可能被客户端和 CDN 缓存,应用删除 Redis 并不等于访问者已经看不到旧目标。

十、访问统计与主映射使用不同的数据模型

主映射回答“这个短码现在跳到哪里”,适合按 code 点查;统计回答“某段时间、渠道和地区发生了多少次访问”,适合追加事件与聚合查询。把每次点击明细写进主库,会让高频追加与低频映射更新争夺相同连接、索引和备份窗口。

一种常见链路是:

redirect service
  → local batch / collector
  → Kafka
  → raw event storage
  → minute/hour aggregate
  → analytics API

事件至少包含 event_id、link_id、服务端时间、入口 region、经过脱敏的来源维度和 user-agent 分类。不要默认保存完整 IP、Referer 查询参数和目标 URL,它们可能含有账号、token 与个人信息。保留周期、脱敏方式和删除请求要进入数据治理。

消息语义也要与报表口径匹配。至少一次投递会重复,消费者按 event_id 去重或让聚合支持幂等;允许近似统计时可以使用采样和概率数据结构。实时面板与结算报表不一定共用同一精度,前者优先低延迟,后者从可重放原始事件校正。

消息积压不会阻止跳转,但会让统计新鲜度下降。监控应展示最老事件年龄而非只看消息条数,运营侧也要看到“数据延迟 12 分钟”,不能把旧统计伪装成实时结果。

十一、多地域部署要处理新链接的可见性

跳转天然适合多地域读:DNS 或 Anycast 把用户送到最近区域,各区域拥有跳转服务、L1 和 Redis,主映射通过数据库复制、变更日志或边缘 KV 分发。创建可以固定到 home region,减少多主唯一性与并发更新冲突。

边缘存储往往以最终一致换取全球低延迟。以 Cloudflare Workers KV 为例,其一致性文档说明,写入在其他网络位置可能需要 60 秒或更久才可见,连“不存在”的读取也会被缓存。这很适合读多写少的映射,却意味着刚创建的短链可能在另一地区暂时返回 404。

系统需要从以下策略中明确选择:

  • 创建成功前等待映射达到指定地区或复制确认,换取更高创建延迟;
  • 新链接在一段激活窗口内统一路由到 home region,之后再就近读取;
  • 创建先返回 ACTIVATING,查询接口确认全球可用后再允许投放;
  • 接受可量化的短暂不可见,并禁止边缘对新 code 做长时间负缓存。

更新也有相同问题。普通营销链接可以接受几十秒陈旧,封禁则要走独立的快速 denylist。将“映射复制”和“安全撤销”拆成两个通道,能让日常写入继续使用廉价的最终一致分发,同时保留紧急停止能力。

依赖故障时按能力降级

故障 创建路径 跳转路径
Leaf 不可用 用完缓存号段后暂停创建 不受影响
主数据库分片不可用 对应桶暂停创建和修改 缓存命中继续;miss 受限失败
Redis 不可用 主记录仍可写,延迟回填 L1 命中继续;受控回源,不能全量打 DB
用户索引积压 创建成功,后台列表延迟 不受影响
统计队列积压 不受影响 继续跳转,统计标记延迟或采样
单 Region 故障 路由到写入 Region 或暂停 其他 Region 读取已复制映射

缓存故障时是否返回旧值取决于状态风险。长期不变的普通映射可使用带最大年龄的 stale 副本;过期时间已到、denylist 命中或版本无法确认的链接应拒绝。高可用设计的完整接管路径见高可用设计文章。

十二、短链接也是一个需要治理的重定向入口

公共短链很容易被用于钓鱼、恶意下载、垃圾信息和绕过域名过滤。OWASP 的未验证重定向说明指出,攻击者会借可信域名包装恶意目标。短链接的产品目的本来就是外部重定向,无法简单禁止它,只能把目标验证、信誉和下架能力纳入主流程。

创建时至少执行:

  • 只接受明确允许的 scheme,普通产品通常限定 http 与 https;
  • 使用标准 URL 解析器,拒绝含用户名密码、畸形 host 和超长输入;
  • 对国际化域名保存规范形式并在审核界面展示可识别形式;
  • 按账号、域名和来源限流,检查黑名单、恶意文件与钓鱼信誉;
  • 对企业内部短链使用目标域名 allowlist,并记录创建人。

如果服务为了生成网页标题或预览图主动抓取目标 URL,还会引入 SSRF:攻击者可以指向环回地址、云元数据地址、内网域名,或利用 DNS rebinding 改变解析结果。预览抓取应放在隔离网络,限制协议、端口、解析后的 IP、重定向次数、响应大小和下载时间。单纯返回 302 的跳转节点不需要抓取目标内容。

短码不可作为授权凭证。即使随机码难以猜测,只要链接被转发、Referer 泄露或日志外传,任何获得者都能访问。私密分享需要带过期时间和签名的访问 token,或者在跳转前完成身份验证。枚举防护可以降低批量扫描,无法提供访问控制证明。

域名与证书也属于系统资产。自定义域名接入要验证所有权、自动续签证书,并在解绑后停止接受旧租户配置。路由缓存若只按 code、不按 Host 隔离,既是数据错误,也是跨租户安全漏洞。

十三、走完一次创建、爆火、修改和故障

假设用户 42 创建一条活动链接,请求携带幂等键 req-a8f。幂等服务抢占成功,Leaf 分配 link_id=300001,编码器生成示例短码 7Fk3aQ。路由器计算:

hash("s.example\0" + "7Fk3aQ") % 4096 = bucket 417
bucket_map[v12][417] = shard-3

shard-3 在一个事务中写主记录和 Outbox。提交后幂等记录进入 SUCCEEDED,接口返回 https://s.example/7Fk3aQ。用户索引消费者稍后把摘要写入 owner 42 的列表分片。若响应在途中丢失,客户端带同一个 req-a8f 重试,会得到原短链。

第一次跳转发生在上海。L1 与 Redis 都未命中,服务只查询 shard-3,读取版本 1 的 ACTIVE 记录,回填 Redis 和本地缓存,返回 302。访问事件进入本地批次。接下来链接被大账号转发,边缘缓存吸收大部分请求,各实例 L1 继续分散 Redis 压力;统计事件按 link_id + producer_bucket 分区,不会把一个 Kafka partition 写满。

用户把目标 URL 更新为另一个活动页。shard-3 将版本改为 2 并写失效事件,Redis、L1 和 CDN 逐层清理。某个慢回源线程随后拿着版本 1 尝试回填,被版本栅栏拒绝。更新 API 返回预期最大传播时间,不能声称全世界已经同时切换;安全下架则同步写入边缘 denylist,优先阻断旧缓存。

此时 Redis 集群部分故障。边缘和 L1 命中继续服务,miss 请求先做 singleflight 合并,再按数据库保护额度回源。超过额度的普通链接可以返回短暂错误,不能把 100,000 QPS 全部压到 MySQL。创建链路仍写主库,缓存回填进入待重试队列。

随后 bucket 417 从 shard-3 迁移到 shard-9。迁移期间短码和 bucket 都不变,路由版本从 v12 升到 v13。旧实例把 miss 发到 shard-3 后收到 ROUTE_STALE(v13),刷新桶表并重试 shard-9。写入切换后 shard-3 拒绝修改,避免两个 owner 同时接受版本更新。

这条链路把各机制的边界串起来了:Leaf 管 ID,编码器管公开表示,短码哈希管逻辑桶,桶表管物理位置,缓存承担读取副本,Outbox 传播派生状态,denylist 负责紧急封禁。任何一层超时都应能说清事实状态与下一步,不能统一返回“稍后重试”。

十四、监控、压测与故障演练

短链接监控应把创建、跳转、派生索引和统计分开。全局平均值会掩盖单桶、单 code 和单 Region 的问题。

层次 关键指标
创建 成功率、P99、幂等命中、结果未知、Leaf 剩余号段、唯一冲突
跳转 分状态成功率、P50/P99、每 Region QPS、302/404/410/429/5xx
缓存 CDN/L1/Redis 分层命中率、负缓存率、回源 QPS、陈旧年龄
分片 每 bucket 与 shard 的 QPS、容量、路由过期、迁移差异
热点 top code QPS、最大值与平均值比、统计分区 lag
一致性 失效事件延迟、各层残留旧版本、封禁传播时间
安全 创建拒绝原因、扫描速率、恶意域名命中、预览抓取拦截

压测不能只随机读取均匀 code。至少准备普通 Zipf 分布、单个爆款占据大部分流量、全随机不存在 code、缓存全冷、Redis 故障和单分片延迟六种负载。记录每一层实际 QPS,验证 miss 放大是否与容量模型一致。

性质测试适合覆盖不会随示例变化的不变量:Base62 编解码往返一致;可逆置换在测试域中不重复;不同语言路由同一批 host/code 得到相同 bucket;Host 规范化不会合并两个不同租户;路由版本变化只改变物理 owner,不改变逻辑桶。

故障演练还要验证:

  1. Leaf 数据库不可用时,剩余号段耗尽前告警,跳转不受影响。
  2. 创建事务响应丢失后,同一幂等键只得到一个有效资源。
  3. Redis 清空时,回源合并、限流和数据库连接预算同时生效。
  4. 热门 code 更新后,旧版本不会由慢查询重新写回。
  5. 封禁事件在目标时间内覆盖 CDN、L1 和 Redis。
  6. 一个 bucket 迁移时,旧路由读请求能刷新,旧 owner 不再接受写。
  7. 统计队列积压时,跳转延迟不随 lag 上升。
  8. 单 Region 断网后,其他地区仍能跳转已复制链接,新链接按契约处理。

上线前检查清单

  • 创建、跳转、管理和统计是否拥有各自的 SLO?
  • link_id、short_code、owner_id 和 bucket 的职责是否写清?
  • 生成码、随机码与自定义码是否走同一套唯一约束和路由?
  • 幂等键的作用域、保留时间和结果未知恢复流程是什么?
  • Host、短码大小写、字符编码、哈希算法和取模规则是否跨语言一致?
  • 用户列表、搜索和报表是否与高 QPS 主映射拆开?
  • 302 的浏览器与 CDN 缓存多久,更新和封禁最晚何时生效?
  • 缓存全冷或 Redis 故障时,数据库最多接受多少回源?
  • 爆款链接的映射读取、计数和消息分区是否都避免单点?
  • 新链接跨 Region 的可见性是否与创建响应语义一致?
  • 目标 URL 校验、恶意链接下架和预览 SSRF 隔离是否存在?
  • 短码是否被误当成私密链接的授权凭证?
  • 分片迁移期间,哪个节点拥有唯一写权限?
  • 能否从指标判断当前旧映射还残留在哪一层?

结语

短链接系统的难点来自两条不对称路径。创建链路关心唯一、幂等和持久化;跳转链路关心就近读取、热点复制和故障时的有界回源。把两者塞进同一个数据库读写模型,规模上来后会同时失去延迟和正确性。

一套闭合方案会让职责保持清晰:Leaf 或随机生成器提供身份,短码承担公开寻址,稳定逻辑桶负责数据归属,缓存复制已确认映射,Outbox 构建用户索引和失效事件,统计链路异步吸收点击流量,denylist 提供快速撤销。UID 和分库分表可以在同一篇系统设计里出现,因为一次真实请求会同时经过它们;它们仍然是两个机制,不能互相替代。

参考资料