#TypeScript#DevOps

系统设计:短链系统从 0 设计

短链是系统设计面试的经典题,小而全(读写、唯一性、重定向、缓存、扩展)。本文从需求拆解开始,讲清短链的生成(哈希/自增ID+进制)、存储、重定向(301 vs 302)、缓存抗读、分片扩展,以及面试里怎么"边设计边讲"展示思路。

本文要点

  • 主题聚焦:TypeScript、DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年6月27日·更新于 2026年7月23日

短链系统是系统设计面试里的"守门员题"。它小到 30 分钟能讲完,又大到能一路追问到分库分表、缓存一致性、全球加速——一道题能精准测出候选人到底是"背了八股"还是"真的想过"。可惜我面过的多数人,题目刚念完就开始画三个框加箭头,然后讲"我用 Redis 缓存一下",就没了。真正决定成败的,是愿不愿意在动手前先把"这玩意儿到底要解决什么"问清楚。

这篇文章按面试节奏走,从需求澄清一路讲到分片扩展,代码和架构图都按生产可用标准给。读完你应该能拿到这道题的"基础分 + 加分项"。

一、先别急着画图:把需求问清楚

系统设计第一步永远不是画架构图,而是问问题。我见过太多候选人,讲了 5 分钟方案才发现"哦,原来还要支持自定义短码"——前面全白讲。短链的需求至少要问清这几件:

功能需求

  • 核心两个动作:给长链生成短码(写)、用短码跳回长链(读 / 重定向)。
  • 是否支持自定义短码?(比如 s.cn/my-launch,而不是 s.cn/x7K9aQ)
  • 短码是否过期?营销活动链通常要,个人永久链通常不要。
  • 是否需要分析:点击量、来源、地域?

非功能需求(这才是拉开差距的地方)

  • 读写量:典型 100:1,读远多于写。具体数字必须问出来,直接影响后续缓存和分片策略。
  • 短码长度:7 位 base62 够 3.5 万亿条,业界默认。有些场景为了美观会要 6 位甚至更短。
  • 可用性:短链挂了 = 用户所有外投的广告、邮件、推文全部失效,这是商业事故级别,可用性要按 99.99% 来。
  • 延迟:重定向 P99 < 50ms,慢一点用户就觉得"链接坏了"。

容量估算(面试必讲,不讲就扣分)

假设月新增 1 亿条短链、读写比 100:1:

  • 存储:1 亿条/月,每条按 500B 算(长链 + 短码 + 元数据),一年约 600GB,十年 6TB——单库扛得住,但已经该考虑分片。
  • 写 QPS:1 亿 / 30 / 86400 ≈ 40,峰值按 5 倍 ≈ 200,完全不为写入焦虑。
  • 读 QPS:100 倍,即峰值约 2 万 QPS——这才是真正吃设计的地方。

把这套数字甩出来,面试官心里已经给你加了分,因为你证明了自己能把抽象问题落到具体资源。

二、短码怎么生成:四种方案的取舍

短链系统的"灵魂"就在这。方案大致四种,我按推荐度倒着讲。

方案 A:哈希取段(MD5/SHA 取前 6-7 位)

ts
import { createHash } from 'node:crypto';

function hashShorten(url: string): string {
  return createHash('md5').update(url).digest('hex').slice(0, 7);
}

优点是无状态、可客户端算。但缺点致命:截断后冲突率不可忽略。MD5 是 128 位,你只取 28 位(7 个 hex),生日悖论下大约 2^14 ≈ 1.6 万条就开始撞。撞了怎么办?加随机盐重算再去查库——一查就得读盘,等于把"无状态"的好处全还回去了。生产里基本不这么干。

方案 B:自增 ID + 进制转换(强烈推荐)

这是工业界主流(Bitly、Twitter 大致都是这类思路的变体)。两步:用一个全局唯一发号器给每条长链发一个号,再把这个十进制号编码成更高进制的字符串。

ts
const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const BASE = 62; // 0-9 + A-Z + a-z

// 自增 ID → 短码
function encode(id: number): string {
  let n = id;
  if (n === 0) return ALPHABET[0];
  let out = '';
  while (n > 0) {
    out = ALPHABET[n % BASE] + out;
    n = Math.floor(n / BASE);
  }
  return out;
}

// 短码 → 自增 ID(用于反查、排障)
function decode(code: string): number {
  let n = 0;
  for (const ch of code) n = n * BASE + ALPHABET.indexOf(ch);
  return n;
}

encode(10000);          // '2bI'
encode(10_000_000_000); // 'Aukyoa' —— 百亿级 ID 也只要 6 位

为什么好:天然唯一(发号器保证)、长度可控(7 位 base62 ≈ 3.5 万亿,够用几十年)、无需查重(号不重,码就不重)、可反解(出问题能从短码直接还原 ID 排障)。

发号器是另一个独立小系统。最简单是 MySQL 一张 counter 表 AUTO_INCREMENT,够撑到中等规模;再大可以用 Redis INCR、Snowflake 类算法、或专门发号服务。别让发号器变成单点瓶颈就行。

方案 C:随机串 + 查重

随机生成 7 位字符串,再查库看是否冲突。简单粗暴,缺点是写入路径多一次读、概率上还得重试。规模一大就暴露问题,只适合 demo。

方案 D:预生成(Keygenning)

后台批量预生成几千万个短码放队列,写入时直接 pop。写入路径极快、无冲突、发号器压力为 0;代价是多维护一个 KGS。延迟敏感、写入量大的场景值得做,面试可以作为"加分方案"提一句。

三、存储:一张表 + 两个索引就够起步

别一上来就 NoSQL。短链的存储模型非常简单——就是一张 KV 映射,关系库完全够用:

sql
CREATE TABLE url_map (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  short_code  VARCHAR(10) NOT NULL UNIQUE,
  long_url    TEXT        NOT NULL,
  user_id     BIGINT,
  created_at  DATETIME    NOT NULL,
  expires_at  DATETIME,
  KEY idx_user (user_id)
);

short_code 上必须建唯一索引——它就是这条记录的"业务主键",所有读请求都从它进来。id 留着,既是发号器源头,也是后续分片的基础。到 6TB 再考虑分片(见第六节);在那之前,MySQL 主从 + 合理索引,一台扛下来。

四、重定向:301 还是 302,这是个产品问题

重定向用哪个状态码,不是技术细节,是产品决策。很多人没意识到:

  • 301 Moved Permanently:永久重定向。浏览器会强力缓存,下次再点同一短链,直接从本地跳,根本不回源。
  • 302 Found:临时重定向。浏览器每次都回源,服务器每次都能记录这次点击。

技术上看 301 更快、更省服务器,但只要产品要做点击分析(广告投放、邮件营销几乎都要),就必须用 302。否则用户回访的点击统计不到,商务对账直接崩。Bitly、短链平台类产品默认 302,就是这个原因。

ts
app.get('/:code', async (req, res) => {
  const longUrl = await lookup(req.params.code);
  if (!longUrl) return res.status(404).end();
  analytics.emit('click', { code: req.params.code, ...req.meta }); // 异步,不阻塞
  res.redirect(302, longUrl);
});

一个细节:埋点必须异步。重定向的延迟感知极强,同步写分析库会把 P99 拖到几百毫秒,用户就以为链接坏了。

五、缓存:读多写少的天然战场

读写比 100:1,意味着每条短链被读 100 次后才写一次——这是缓存的完美场景。Redis 几乎是标配:

text
GET /:code
  ├─ 1. 查 Redis(cache-aside)
  │     命中 → 302
  │     未命中 ↓
  ├─ 2. 查 MySQL
  │     命中 → 回填 Redis(TTL 几小时到一天)→ 302
  │     未命中 → 404(同时缓存空值,防穿透)

实战要点:

  • TTL 不必和短链有效期完全一致,设 1-7 天就够,长链改了也能容忍短时间不一致。
  • 防穿透:不存在的短码也缓存空值(短 TTL),不然恶意刷随机短码能把 MySQL 打爆。
  • 防雪崩:TTL 加随机抖动(ttl + rand(0, 60s)),别让一批 key 同一秒集体过期。
  • 热点 key:个别爆款短链可能占 30% 流量,在 Redis 之前再加一层本地 LRU,挡掉绝大多数回源。

六、扩展:从单机到分片

走到这一步,系统已经过了"演示"阶段,要面对真实规模。整体架构长这样:

text
                       用户
                        │
             ┌──────────┴──────────┐
             ▼                     ▼
         [CDN / 边缘]         [负载均衡 Nginx]
         命中即 302                  │
             │ miss         ┌───────┴────────┐
             ▼              ▼                ▼
        [读服务集群]    [写服务集群]    [分析服务]
             │              │             (异步)
             ├──── Redis ───┤  (热缓存)
             ▼              ▼
      [MySQL 分片 0..N]  ← 发号器(独立服务)
      (按 short_code hash)

分片策略:按 short_code hash 分到 N 个库。注意——别按自增 id 分,读请求只带短码、不带 id,按 id 分片后读还要先反解,白白多一次路由。永远按读路径上的 key 分片,这是新人最常见的分片错误。

高可用:MySQL 每个分片主从,Redis 哨兵或集群模式,写服务无状态可水平扩。发号器是单点风险,要么用 Snowflake 类算法(每台机器分一段 ID 空间),要么多主 MySQL 各预留步长。

CDN:短链重定向响应极小(几百字节头部),非常适合 CDN 缓存。但和第四节必须一起考虑——CDN 缓存会让 302 的点击统计丢失。常见做法:热门但不需要分析的部分走 CDN + 301,需要分析的部分回源 + 302。

七、面试里怎么"边设计边讲"

最后讲方法论。这道题面试官真正在看的不是你知不知道 base62,而是:

  1. 先问需求再画图:上来 30 秒把读写比、是否要分析、是否要自定义短码问出来,省掉这一步,后面方案一定偏。
  2. 先给基础方案,再优化:别一上来就讲"多级缓存 + 分片 + KGS",先给"一张 MySQL + base62"的最小可用版本,按瓶颈驱动一步步优化。层次感比炫技重要。
  3. 讲清容量估算:把 QPS、存储、带宽数字说出来,说明你能把抽象问题落地到资源。
  4. 主动讲取舍:301 vs 302、按 id 还是按 code 分片、要不要 KGS——每一步都说"选 A 是因为 X,代价是 Y"。没有取舍的设计是没思考过的设计。
  5. 承认不知道:被追问到盲区(比如发号器时钟回拨),老实说"细节我没深究,大概思路是 XXX",比硬编强。

小结

  1. 系统设计第一步永远是澄清需求——把读写比、是否要分析、是否要自定义短码问出来,再开始画图。
  2. 短码生成首推"自增 ID + base62":天然唯一、长度可控、可反解,工业界主流方案。
  3. 301 vs 302 是产品决策不是技术细节:要做点击分析就必须 302,即使代价是每次回源。
  4. 读路径上每个环节都要为延迟和穿透防御:Redis cache-aside + 空值缓存 + TTL 抖动 + 本地热点 LRU。
  5. 分片按读路径上的 key 分,别按数据库内部 id 分——这是新人最常见也最致命的分片错误。

一句话收尾:短链系统的难度不在任何单点,而在于把"生成、存储、重定向、缓存、扩展"五件事串成一条不漏的链路。面试官要看的,是你能不能讲清这条链路上每一步的取舍。

Related

相关推荐