系统设计 System Design -4-2-系统设计问题-设计类似 TinyURL 的 URL 缩短服务 (改进版)

设计类似 TinyURL 的 URL 缩短服务 (改进版)

1. 为什么需要缩短网址?

长 URL 难以记忆、分享和输入。对于用户来说,短链接(如 tiny.url/vzet59pa)更易于在社交媒体、短信或印刷材料上传播,并降低了输入错误率。

对于服务提供者(如 Twitter、Bitly 或公司内部),它是一个强大的工具。它允许服务方:

  1. 跟踪分析: 统计每个链接的点击次数、来源、用户地理分布等。
  2. 衡量效果: 用于 A/B 测试或衡量广告活动的投资回报率。
  3. 管理链接: 可以在不更改短链接的情况下,动态地更新背后的原始长 URL。
  4. 安全代理: 在重定向前检查原始链接是否为恶意网站,保护用户。

2. 系统需求与目标

  • 功能要求:

    1. 创建: 给定一个长 URL,系统必须生成一个唯一的、更短的 URL 别名(短链接)。
    2. 重定向: 当用户访问短链接时,系统必须将其重定向到原始的长 URL。
    3. 自定义: 用户应能够选择一个自定义的短链接(例如 tiny.url/my-brand),前提是该别名未被占用。
    4. 过期: 链接应有可配置的过期时间(例如,默认为 2 年,或由用户指定)。
  • 非功能性需求:

    1. 高可用性: 系统必须达到 99.99% 或更高的正常运行时间。服务宕机会导致所有 URL 重定向失败,这是不可接受的。
    2. 极低延迟: URL 重定向(读取操作)必须极快(例如,p99 延迟 < 50ms)。这是系统的核心用户体验。
    3. 不可预测性: 自动生成的短链接不应该是可猜测的(例如,不是 1, 2, 3... 这样的递增 ID),以防止恶意爬取。
    4. 可扩展性: 系统必须能够水平扩展以处理数十亿级别的链接和极高的读取流量。
  • 扩展要求:

    1. 分析: 必须跟踪重定向的次数、时间、来源、地理位置等元数据。
    2. API: 必须通过 REST API 向其他服务(包括内部和第三方)开放。
    3. 安全: 必须能够检测和标记指向恶意软件/钓鱼网站的链接,并在重定向时警告用户。

3. 容量估算与约束

这是一个极端读取密集型的系统。

  • 读写比: 假设为 100:1 (100 次重定向对应 1 次新链接创建)。

  • 写入量 (新 URL): 假设每月 5 亿个新链接。

    • 500,000,000 / (30 天 * 24 * 3600 秒) ≈ 200 QPS (每秒 200 个新链接)。
  • 读取量 (重定向):

    • 200 QPS * 100 (读写比) ≈ 20,000 QPS (每秒 2 万次重定向)。
  • 存储量 (5 年):

    • 5 亿/月 * 12 个月 * 5 年 = 300 亿 条记录。
    • 假设每条记录(ShortKey, LongURL, UserID, Timestamps)平均占用 500 字节。
    • 300 亿 * 500 字节 ≈ 15 TB

关键设计驱动因素:

  1. 20,000 QPS 读取 :重定向路径必须是内存优先的。任何磁盘 I/O(数据库读取)都必须被缓存。绝对禁止在此路径上执行数据库写入。
  2. 300 亿条记录:必须使用可水平扩展的 NoSQL 数据库(如 DynamoDB, Cassandra)。
  3. 200 QPS 写入:这是一个中等负载,但必须高效且无瓶颈。

4. 系统接口定义

服务将通过 RESTful API 公开,以支持 Web 客户端、移动应用和第三方集成。

  • POST /api/v1/shorten

    • Body: {"long_url": "...", "custom_alias": "...", "expires_in_days": 7}
    • Response (201): {"short_url": "http://tiny.url/aZ8xL9p"}
    • Response (409): {"error": "Custom alias already taken"}
  • GET /{shortened_url}

    • Response (302): Location: {original_long_url} (用于浏览器重定向)
    • Response (404): {"error": "Not Found"} (如果链接不存在或已过期)
    • Response (403): (显示一个警告页面,如果链接被标记为恶意)
  • GET /api/v1/analytics/{shortened_url}

    • Response (200): {"key": "aZ8xL9p", "click_count": 10500, "created_at": "...", "analytics_data": [...]}
  • GET /api/v1/user/urls

    • (需要认证)
    • Response (200): {"urls": [{"key": "aZ8xL9p", ...}, {"key": "my-brand", ...}]}
  • DELETE /api/v1/{shortened_url}

    • (需要认证和所有权)
    • Response (204): No Content (删除成功)

5. 数据库设计 (改进)

我们将采用 NoSQL 数据库(如 AWS DynamoDB)作为主存储,它能自动处理 15TB+ 数据的扩展。

表名:URLMapping

  • ShortKey (String, 分区键 (Partition Key) ):

    • 这是我们的短链接(例如 aZ8xL9pmy-brand)。
    • 这是数据表的主键,所有高效查询都将通过它进行。
  • LongURL (String): 原始的长 URL(例如 https://www.designgurus.org/...)。

  • UserID (String, GSI 分区键):

    • 创建此链接的用户 ID(如果用户已登录)。
    • 在此字段上创建二级索引 (GSI) ,以高效支持 GET /api/v1/user/urls API。
  • CreationTimestamp (Timestamp): 链接创建的 Unix 时间戳。

  • ExpirationTimestamp (Timestamp, TTL 属性):

    • 链接的过期时间戳。
    • 我们将配置数据库使用此字段来自动删除过期条目。
  • ClickCount (Number):

    • 总点击次数。
    • [重要] 此字段将由分析服务异步批量更新绝不 在每次 GET 重定向时实时更新。
  • IsFlagged (Boolean):

    • 安全标记(true/false),用于标记恶意链接(详见第 12 节)。

6. 基本系统设计和算法 (关键改进)

我们将彻底废除"密钥生成服务 (KGS)"

KGS 是一个经典的设计模式,但它本身是一个有状态的、复杂的单点瓶颈。管理一个包含数百 GB"未使用密钥"的数据库,并处理其并发、故障转移和分发,比核心业务逻辑还要复杂。

改进的算法:7 位随机密钥 + 数据库重试

  1. 增加密钥长度: 6 位(687 亿)对于 300 亿条目来说太短了,碰撞概率会变得很高 (生日悖论)。我们将使用 7 位 Base64 字符串([A-Z, a-z, 0-9, _, -])。

    • <math xmlns="http://www.w3.org/1998/Math/MathML"> 6 4 7 ≈ 4.4 64^7 \approx 4.4 </math>647≈4.4 万亿 个唯一密钥。
    • 300 亿条目在 4.4 万亿的密钥空间中,占用率 < 1% 。这使得随机碰撞概率极低
  2. 写入(创建)流程 (POST /shorten):

    • 请求进入应用服务器(一个无状态的 Go/Java/Python 服务)。

    • 情况 A:如果用户提供了自定义别名 (custom_alias = "my-brand"):

      a. 使用 ShortKey = "my-brand"。

      b. 尝试将其写入数据库,并使用 "条件写入" (例如 DynamoDB 的 ConditionExpression = "attribute_not_exists(ShortKey)")。

      c. 如果写入成功,返回 201。

      d. 如果写入失败(条件不满足),说明该键已存在,向用户返回 409 Conflict ("自定义别名已被占用") 错误。

    • 情况 B:如果用户未提供别名(99% 的情况):

      a. 应用服务器在本地循环(例如,最多 3 次):

      b. 生成一个随机的 7 位 Base64 字符串(例如 aZ8xL9p)。

      c. 使用 ShortKey = "aZ8xL9p" 尝试"条件写入"(同上)。

      d. 如果写入成功,立即跳出循环,返回 201。

      e. 如果写入失败(因为极小概率的碰撞),循环继续,生成下一个随机密钥。

此改进的优点:

  • 无单点瓶颈: KGS 被彻底移除。
  • 无状态: 应用服务器是完全无状态的,可以无限水平扩展。
  • 极简架构: 不再需要管理"已使用/未使用"的密钥数据库。
  • 统一逻辑: "创建随机链接"和"创建自定义链接"现在是同一个数据库操作(条件写入)。

7. 数据分区和复制 (改进)

我们不需要手动实现数据分区。

  • 分区(Sharding): 我们通过选择 ShortKey 作为 NoSQL 数据库(如 DynamoDB)的分区键 ,数据库会自动为我们处理分区。我们的 7 位随机 密钥(例如 aZ8xL9p, bK2qR5s)具有极高的基数和随机性。这确保了写入和读取均匀分布 在所有数据库分区上,天然地避免了热点问题
  • 复制(Replication): 复制由云数据库自动管理。例如,DynamoDB 会自动将数据同步复制到 3 个不同的可用区(Data Centers),确保了高可用性和持久性。

8. 缓存 (改进)

缓存是满足 20,000 QPS 读取性能的唯一途径。我们将使用一个分布式的内存缓存集群(如 Redis 或 Memcached)。

  • 缓存内容: 缓存 ShortKeyLongURL 的直接映射。

  • 缓存策略: 采用读穿 (Read-Through) 策略。

  • 读取(重定向)流程 (GET /aZ8xL9p):

    1. 应用服务器收到请求 GET /aZ8xL9p

    2. 服务器首先 查询缓存(Redis)集群:GET "aZ8xL9p"

    3. 缓存命中 (Cache Hit) - (预期 >99% 的情况):

      a. 缓存立即返回 LongURL。

      b. 服务器异步地向分析队列(Kafka)发送一条消息(见第 11 节)。

      c. 服务器向用户返回 HTTP 302 重定向 到 LongURL。

    4. 缓存未命中 (Cache Miss):

      a. 服务器查询主 NoSQL 数据库:GET Item WHERE ShortKey = "aZ8xL9p"。

      b. 如果数据库中不存在(或已过期),返回 404 Not Found。

      c. 如果存在,数据库返回 LongURL。

      d. 服务器异步地("即发即忘")将此 (ShortKey, LongURL) 写回缓存,并设置一个合理的 TTL(例如 24 小时)。

      e. 执行上述步骤 3b 和 3c。

9. 负载均衡器 (LB)

我们将在系统的最前端部署 L7 负载均衡器(如 Nginx, AWS ALB)。

  1. 终止 HTTPS: LB 负责处理 SSL/TLS 加密解密,减轻后端应用服务器的 CPU 负担。

  2. 健康检查: LB 会持续检查后端服务器的健康状况,自动剔除无响应的实例。

  3. 内容路由: 它将根据 HTTP 路径智能地路由流量。例如:

    • GET /aZ8xL9p (短链接) -> 路由到"重定向服务"集群。
    • POST /api/v1/shorten -> 路由到"API 服务"集群。
    • GET /api/v1/analytics/* -> 路由到"分析 API"集群。

10. 清除或数据库清理 (关键改进)

废除所有手动的清理脚本或 Cron Jobs。

运行 DELETE FROM ... WHERE ... 这样的批量删除操作会对生产数据库造成巨大压力,引发 I/O 风暴。

  • 改进的方案:原生 TTL(Time-to-Live)

    1. 写入时:POST /shorten 时,我们计算 ExpirationTimestamp(例如 NOW() + 2 years)。
    2. 数据库配置: 我们在 URLMapping 表上启用 TTL 功能,并将其指向 ExpirationTimestamp 字段。
    3. 自动清理: 数据库(DynamoDB/Cassandra/MongoDB)自动会在后台扫描并删除时间戳已过期的行。
  • 优点: 零运维成本,没有定时任务,没有删除操作引起的负载峰值。密钥也永不回收(4.4T 密钥空间足够大,回收密钥比它带来的好处要复杂得多)。

11. 遥测 (分析) (关键改进)

这是本设计的第二个核心 。在 20,000 QPS 的读取负载下,绝对禁止 在重定向路径上执行任何数据库写入(例如 UPDATE ... SET ClickCount = ClickCount + 1)。

  • 改进的流程:异步流处理 (Async Stream Processing)

    1. 事件发射 (在 App Server):

      • 如第 8 节所述,当一个 GET 请求(无论是缓存命中还是未命中)成功时,应用服务器唯一 要做的额外工作就是向一个高吞吐量的消息队列 (如 Kafka 或 AWS Kinesis)"即发即忘"地提交一条 JSON 消息。
      • 这个操作在 1 毫秒内完成,对主重定向延迟的影响可以忽略不计。
      • 消息内容:{"key": "aZ8xL9p", "timestamp": "...", "ip_hash": "...", "user_agent": "...", "referer": "..."}
    2. 事件处理 (独立的消费者服务):

      • 一个完全独立的微服务(例如,一个 Go/Flink/Spark 应用)订阅 Kafka 主题。
      • 该服务在内存中 对事件进行聚合(例如,使用 map["aZ8xL9p"]++)。
    3. 批量更新:

      • 每隔 10 秒(或累计 10000 条事件),该服务将聚合后的计数批量写入数据库。
      • 方案 A (简单): 写回主 NoSQL 数据库(UPDATE URLMapping SET ClickCount = ClickCount + 150 WHERE ShortKey = "aZ8xL9p")。
      • 方案 B (更优): 将数据写入一个专门的分析数据库 (如 ClickHouse, Redshift, BigQuery)。GET /api/v1/analytics/* API 将从这个数据库读取,从而实现生产库和分析库的完全隔离。

12. 安全和权限 (改进)

  • 权限 (用户数据):

    • 所有 API 端点(POST, DELETE, GET /user)都必须通过 API 密钥或 OAuth 令牌进行身份验证。
    • DELETE /api/v1/{key} 时,服务器必须检查认证后的 UserID 是否与 URLMapping 表中该 keyUserID 字段相匹配,确保用户只能删除自己的链接。
    • GET /api/v1/user/urls API 将使用 UserID 上的二级索引 (GSI) 来高效查询该用户的所有链接。
  • 改进 - 恶意链接防护 (安全):

    • 我们必须防止服务被用于传播恶意软件或钓鱼网站。

    • 流程(异步检查):

      1. POST /shorten 收到一个新的 LongURL 时,除了写入主数据库,应用服务器还将其提交到一个单独的 Kafka 主题 (例如 url-security-check)。
      2. 一个独立的"安全工作者"服务消费此主题。
      3. 该工作者调用Google Safe Browsing API 或其他第三方服务来检查 LongURL
      4. 如果该 URL 被标记为"恶意",该工作者将更新 主数据库中的 URLMapping 表,设置 IsFlagged = true
    • 重定向时的检查:

      1. GET /aZ8xL9p 时,如果从缓存或数据库中获取的条目 IsFlagged == true
      2. 系统不会返回 302 重定向。
      3. 相反,它会返回 HTTP 403 Forbidden 或渲染一个全屏警告页面("此链接被标记为不安全,是否继续?")。
      4. 缓存失效 :当安全工作者将 IsFlagged 设为 true 时,它还必须向缓存发送一个 DELETE "aZ8xL9p" 命令,以确保下一次访问能读到最新的安全状态。)

13. 知识点

分析数据库 (ClickHouse, Redshift, BigQuery)

专门用于"数据仓库"和"大数据分析"的工具,和我们平时用的业务数据库(如 MySQL, DynamoDB, Redis)目的完全不同 。在行业里被称为 OLTP vs OLAP

a. 业务数据库 (OLTP - 在线事务处理)

  • 你熟悉的: MySQL, PostgreSQL, DynamoDB , Redis
  • 特点:

    1. 快: 必须在几毫秒内完成操作。
    2. 简单: 操作都非常简单,比如 GET User WHERE UserID=123(查一个用户),或者 UPDATE Order SET Status='Shipped' WHERE OrderID=456(改一个订单)。
    3. 高并发: 必须能同时处理成千上万个这样的简单操作。
  • 它不擅长的: 复杂的"分析"查询。如果你问 MySQL:"过去 5 年,所有 30 岁以下、在周二购买了红色商品的用户,总共花了多少钱?" ------ 数据库会崩溃 。因为它必须一行一行地扫描整个数据库(几十亿条记录)来回答你。

b. 分析数据库 (OLAP - 在线分析处理)

  • ClickHouse , BigQuery (Google), Redshift (AWS), Snowflake。

  • 用途: 运行 "数据分析"

  • 特点:

    1. 快(针对分析): 专门为上面那种"过去 5 年..."的复杂问题优化。
    2. 海量: 专门用于扫描海量(PB级)数据。
    3. 慢(针对单条): 它们不适合,也不能用于"在线业务"。它们插入一条数据可能要几秒钟,查询一条数据也可能很慢。
    4. 并发低: 它们不处理上万的 QPS,可能同时只有几个"分析师"在用。

为什么它们这么快(和 NoSQL 的区别)?

  • NoSQL / MySQL (行存储)
  • ClickHouse / BigQuery (列存储)

总结: 把事件(点击)写入 Kafka,然后:

  • 消费者 A(分析服务) 把数据聚合后写入 NoSQL (DynamoDB) ,用来给用户看"你的总点击是 10500 次"。
  • 消费者 B(数据仓库)每一条 原始事件都写入 BigQuery (OLAP) ,用来给公司内部的"分析师"提问:"上个月从巴西来的点击有多少?"
Kafka 安全主题与缓存

"单独的 Kafka 主题"所触发的安全服务 ,在完成它的工作时,必须做两件事

  1. 更新 NoSQL 数据库(设置 IsFlagged = true)。
  2. 主动向缓存(Redis)发送一个 DELETE "key-123" 命令。

"删除" 是解决缓存失效问题最简单、最可靠的办法。

二级索引 (GSI)

当启用 GSI 时,是在告诉数据库:"请你额外维护第二本书"。

这第二本书: 不按"章节号"排序,而是按 "作者名"UserID)排序。

这本书的内容很简单,就是 [作者名] -> [章节号列表]

  • 'Adam' -> [key-001, key-007, ...]
  • 'John' -> [key-002, key-100, key-500, ...]
  • 'Mary' -> [key-003, ...]

当写入一个新章节,数据库(DynamoDB) 会自动做两件事

  1. 把章节内容存入 "主书" (按 ShortKey 排序)。
  2. key-500 这个引用添加到 "第二本书"John 的作者名下)。

问题查询 db.Query(ctx, "UserID = 'John'")

  • 数据库的操作: 管理员极其聪明 。它发现这个查询是按 UserID 查的,于是它根本不看"主书"
  • 它直接跑去 "第二本书" (GSI),用二分查找法(极快)找到 'John' 这一行。
  • 它立刻拿到了所有章节号列表:[key-002, key-100, key-500]
  • 结果: 查询在几毫秒内完成。

总结: GSI 是数据库层 的一种功能,只需要在创建表的时候声明 它("请在 UserID 字段上创建一个 GSI")。之后,数据库会自动维护它,而 Go 应用代码只需要正常地 Query 那个字段,数据库就会自动使用这个索引来加速查询。

相关推荐
top_designer5 小时前
告别“静态”VI手册:InDesign与AE打造可交互的动态品牌规范
设计模式·pdf·交互·vi·工作流·after effects·indesign
落言5 小时前
AI 时代的工程师:懂,却非懂的时代
前端·程序员·架构
笨手笨脚の6 小时前
微服务核心
微服务·架构·服务发现·康威法则
非凡的世界6 小时前
深入理解 PHP 框架里的设计模式
开发语言·设计模式·php
一叶飘零_sweeeet6 小时前
深入 Spring 内核:解密 15 种设计模式的实战应用与底层实现
java·spring·设计模式
Mr_WangAndy6 小时前
C++设计模式_行为型模式_状态模式State
c++·设计模式·状态模式
文火冰糖的硅基工坊7 小时前
[嵌入式系统-136]:主流AIOT智能体软件技术栈
嵌入式硬件·架构·嵌入式·cpu·gpu
bkspiderx8 小时前
C++设计模式之行为型模式:访问者模式(Visitor)
c++·设计模式·访问者模式
JanelSirry8 小时前
微服务是不是一定要容器化(如 Docker)?我该怎么选
docker·微服务·架构