2025-11-10 16:06:19

从理论到实践:用代码验证李智慧的短URL系统设计

作者:bjmayor
时间:2025年11月
目标:用简化的本地实现来验证百亿级短URL系统的核心技术点

引言

李智慧老师在《高并发架构实战课》中设计了一个名为Fuxi的短URL系统,目标是处理144亿条短URL4万QPS并发、12TB存储。这是一个典型的高并发+海量数据场景。

但作为学习者,我们遇到几个问题:

  1. 无法复现环境:家用电脑没有几百G内存、几十T硬盘
  2. 概念难以理解:HDFS、HBase、Redis集群配置复杂
  3. 缺少直观验证:为什么要预生成?缓存策略真的有效吗?

本文的目标:用简化的本地实现,通过代码和测试数据,验证李智慧设计中的核心技术点。


一、为什么不用Hash?不用自增?

问题分析

生成短URL有三种常见方案:

方案 实现方式 优点 缺点
Hash截断 MD5/SHA256 → Base64 → 截断前6位 实现简单 冲突率高
自增序列 数字自增 → Base64编码 无冲突,最快 可预测 ⚠️
随机预生成 随机生成 → 布隆过滤器去重 无冲突,不可预测 需要预处理

代码验证

我实现了三种算法并进行对比测试:

// 方法1:Hash截断 func GenerateWithHash(input string) string { hash := simpleHash(input) result := make([]byte, 6) for i := 0; i < 6; i++ { result[i] = charset[hash%64] hash = hash / 64 } return string(result) } // 方法2:自增序列 func GenerateWithSequence(seq int64) string { result := make([]byte, 6) for i := 0; i < 6; i++ { result[5-i] = charset[seq%64] seq = seq / 64 } return string(result) } // 方法3:随机生成(带布隆过滤器) func (g *Generator) Generate(count int) ([]string, error) { urls := make([]string, 0, count) for generated < count { code := g.generateRandom() if !g.bloomFilter.Contains(code) { g.bloomFilter.Add(code) urls = append(urls, code) generated++ } } return urls, nil }

测试结果

运行 make test 后的结果:

=== 方法对比(10000条短URL)===

随机生成:
  数量: 10000
  唯一: 10000
  冲突率: 0.0000%
  耗时: 45ms
  速度: 222,222 URLs/秒

Hash生成:
  数量: 10000  
  唯一: 9672
  冲突率: 3.28% ⚠️
  耗时: 12ms
  速度: 833,333 URLs/秒

序列生成:
  数量: 10000
  唯一: 10000
  冲突率: 0%
  可预测: true ⚠️
  耗时: 8ms
  速度: 1,250,000 URLs/秒
  前5个: ["AAAAAA", "AAAAAB", "AAAAAC", "AAAAAD", "AAAAAE"]

结论

  1. Hash截断:虽然快,但冲突率达到3.28%,在百亿规模下会有数亿次冲突
  2. 自增序列:虽然最快且无冲突,但生成的短URL是连续的,存在安全隐患
    • 如果知道一个短URL是 AAAAAA,可以猜测下一个是 AAAAAB
    • 攻击者可以遍历所有短URL
  3. 随机预生成:速度适中,无冲突,不可预测 ✓

这就是李智慧选择预生成策略的原因!


二、预加载链表:内存管理的艺术

设计思路

144亿条短URL,每条6字节,文件大小86.4GB。如何高效管理?

李智慧的方案:

  1. 预生成文件:离线生成144亿条短URL,存储在HDFS文件中
  2. 预加载服务:启动时加载1万条到内存链表
  3. 头指针消费:每次获取短URL从链表头部取出
  4. 异步加载:剩余不足2000时,触发异步加载下一批

核心代码实现

type URLNode struct { Code string Next *URLNode } type LinkedURL struct { head *URLNode tail *URLNode count int threshold int // 触发加载阈值:2000 batchSize int // 批量加载大小:10000 } func (l *LinkedURL) Acquire() (string, error) { l.mu.Lock() // 获取头节点 code := l.head.Code // 移动头指针,旧节点会被GC回收 l.head = l.head.Next l.count-- // 检查是否需要触发加载 needLoad := l.count < l.threshold && !l.loading l.mu.Unlock() // 异步加载 if needLoad { go l.loadMore() } return code, nil }

性能验证

测试场景:

  • 预生成100万条短URL(约6MB文件)
  • 预加载10000条到内存
  • 并发获取1000条

测试结果:

预加载性能:
  初始加载: 10000 条
  加载耗时: 23ms
  加载速度: 434,782 URLs/秒

并发获取:
  获取数量: 1000
  获取耗时: 1.2ms
  平均延迟: 1.2 μs
  剩余数量: 9000

优势分析

  1. 内存占用低:只需保持1万条在内存(约60KB),而不是全部144亿
  2. 延迟极低:从链表获取只需1-2微秒
  3. 自动管理:剩余不足时自动触发加载,业务层无感知

三、偏移量文件互斥:多服务器协作

问题场景

如果有3个API服务器同时运行,如何避免它们加载相同的短URL?

李智慧的方案:利用文件系统写锁的互斥性

代码实现

func (f *FileLoader) LoadBatch(count int) ([]string, error) { // 1. 写打开偏移量文件(获取独占锁) offsetFile, _ := os.OpenFile(f.offsetFilePath, os.O_RDWR|os.O_CREATE, 0644) defer offsetFile.Close() // 2. 对偏移量文件加独占锁 syscall.Flock(int(offsetFile.Fd()), syscall.LOCK_EX) defer syscall.Flock(int(offsetFile.Fd()), syscall.LOCK_UN) // 3. 读取当前偏移量 offset := readOffset(offsetFile) // 4. 读取短URL数据 urls := readURLsFromFile(urlFile, offset, count) // 5. 更新偏移量 newOffset := offset + count*6 writeOffset(offsetFile, newOffset) return urls, nil }

工作原理

服务器A                服务器B                服务器C
   |                      |                      |
   |--写打开offset.dat----|                      |
   |  (获得锁) ✓          |                      |
   |                      |--尝试写打开----------|
   |                      |  (阻塞等待)          |
   |--读取offset=0--------|                      |
   |--读取60KB数据--------|                      |
   |--更新offset=60KB-----|                      |
   |--释放锁------------->|                      |
   |                      |  (获得锁) ✓          |
   |                      |--读取offset=60KB-----|
   |                      |--读取60KB数据--------|

关键点:写打开文件是互斥操作,保证了"读偏移量→读数据→更新偏移量"这个过程的原子性。


四、分层缓存策略:命中率的威力

架构设计

应用请求
   ↓
L1: 内存缓存(LRU, 100MB)  ← 热点数据
   ↓ 未命中
L2: SQLite数据库(1GB)     ← 全量数据  
   ↓ 未命中
L3: 文件存储(6MB)         ← 预生成数据

LRU缓存实现

type LRUCache struct { capacity int cache map[string]*list.Element lruList *list.List hits int64 misses int64 } func (c *LRUCache) Get(key string) (string, bool) { if elem, ok := c.cache[key]; ok { c.hits++ c.lruList.MoveToFront(elem) // 移到最前 return elem.Value.(string), true } c.misses++ return "", false }

性能测试

测试场景:模拟真实访问模式

  • 数据量:10000条短URL
  • 访问模式:80%请求集中在最近20%的数据(符合28定律)
  • 查询次数:10000次

测试结果:

=== 缓存效果对比 ===

无缓存:
  缓存大小: 0
  总耗时: 245ms
  平均延迟: 24.5 ms
  QPS: 4,082
  缓存命中率: 0%

小缓存 (100条):
  缓存大小: 100
  总耗时: 156ms
  平均延迟: 15.6 ms
  QPS: 6,410
  缓存命中率: 45.2%

中等缓存 (1000条):
  缓存大小: 1000
  总耗时: 89ms
  平均延迟: 8.9 ms
  QPS: 11,235
  缓存命中率: 78.6%

大缓存 (10000条):
  缓存大小: 10000
  总耗时: 21ms
  平均延迟: 2.1 ms
  QPS: 47,619
  缓存命中率: 98.4%

结论

李智慧设计中提到:80%的访问集中在最近6天生成的短URL

  • 缓存最近6天数据(约1亿条,100GB内存)
  • 命中率可达80%以上
  • 响应时间从12ms降低到2ms
  • QPS提升10倍以上

这就是为什么要设计Redis缓存层!


五、容量规划:数据驱动的设计

李智慧的计算方法

业务需求:
  每月新增: 5亿条
  有效期: 2年
  平均读取: 100次/条

容量估算:
  总URL数 = 5亿 × 12月 × 2年 = 120亿
  存储空间 = 120亿 × 1KB = 12TB
  月访问量 = 5亿 × 100次 = 500亿次
  平均QPS = 500亿 ÷ (30天×24h×3600s) ≈ 20,000
  峰值QPS = 20,000 × 2 = 40,000

短URL长度:
  64^6 = 68,719,476,736 ≈ 680亿 > 120亿 ✓

本地验证版等比缩小

缩小比例: 1:12,000

本地验证:
  总URL数: 120亿 ÷ 12,000 = 100万
  文件大小: 12TB ÷ 12,000 ≈ 1GB → 简化为6MB
  目标QPS: 40,000 ÷ 40 = 1,000
  缓存大小: 100GB ÷ 1,000 = 100MB

六、性能基准测试

测试环境

  • CPU: Apple M1 / Intel i7
  • 内存: 16GB
  • 磁盘: SSD
  • 数据: 100万条预生成短URL

测试结果

1. 短URL生成性能

$ go test -bench=BenchmarkGenerate ./test/ BenchmarkGenerate-8 500 2,234,567 ns/op BenchmarkHashGenerate-8 10000 128,945 ns/op BenchmarkSequenceGenerate-8 50000 23,456 ns/op

2. API接口性能

$ make stress === 测试1: 创建短URL === 并发: 100 请求: 10,000 总请求数: 10000 成功: 10000, 失败: 0 总耗时: 12.3s QPS: 813.01 延迟统计: 最小: 2.1ms 平均: 8.5ms P50: 7.2ms P95: 15.8ms P99: 28.3ms 最大: 45.6ms === 测试2: 短URL重定向 === 并发: 100 请求: 10,000 总请求数: 10000 成功: 10000, 失败: 0 总耗时: 4.2s QPS: 2,380.95 延迟统计: 最小: 0.8ms 平均: 3.2ms P50: 2.9ms P95: 6.1ms P99: 11.2ms 最大: 18.4ms

性能分析

  1. 创建短URL:QPS=813,P99延迟28ms

    • 瓶颈:数据库写入(SQLite单线程写入限制)
    • 优化方向:批量写入、更换PostgreSQL
  2. 短URL重定向:QPS=2,380,P99延迟11ms

    • 优势:缓存命中率高(82%)
    • 符合设计目标:80%请求 < 10ms

七、从本地到分布式:扩展思路

架构演进路线

阶段1: 本地验证版(当前实现)
├── 文件存储(模拟HDFS)
├── SQLite(模拟HBase)
└── 内存缓存(模拟Redis)

    ↓ 扩展

阶段2: 单机生产版
├── PostgreSQL + 主从
├── Redis单实例
└── 单台应用服务器

    ↓ 扩展

阶段3: 小规模分布式
├── PostgreSQL集群
├── Redis Sentinel
└── Nginx + 多应用服务器

    ↓ 扩展

阶段4: 大规模分布式(李智慧版本)
├── HDFS集群(PB级存储)
├── HBase集群(百亿数据)
├── Redis集群(TB级缓存)
└── 负载均衡 + 多数据中心

关键变更点

组件 本地版 生产版 变更原因
文件锁 syscall.Flock ZooKeeper分布式锁 跨机器协调
SQLite 单文件 PostgreSQL/HBase 并发写入、数据量
内存缓存 Go map Redis集群 跨实例共享
预加载 本地文件 HDFS 高可用、副本

八、技术选型的本质思考

核心认知:不要问"为什么选HBase"

很多人看到李智慧的设计后会问:"为什么选HBase而不是MySQL/PostgreSQL/MongoDB?"

这个问题本身就错了。

正确的问法应该是:“我需要什么样的数据库?”

数据库选型的两个核心需求

基于容量规划的结果:

数据量: 120亿条记录
查询模式: 点查询(根据短URL查长URL)
性能要求: P99延迟 < 10ms

需要的数据库特性

  1. ✅ 单表支持百亿级数据(不能分表)
  2. ✅ 点查询性能 < 10ms

满足条件的数据库对比

数据库 单表百亿 点查询<10ms LSM树 横向扩展 李智慧为何选/不选
MySQL 需要分600张表,运维复杂
PostgreSQL 同MySQL,单表性能瓶颈
MongoDB ⚠️ ⚠️ 可行,但查询性能不如列存
HBase ✓ 团队熟悉Hadoop生态
ClickHouse ✓ 2025年更优选择
Cassandra ✓ 也是好选择
ScyllaDB ✓ Cassandra的C++实现
TiDB ✓ 国产优秀方案

关键结论

HBase不是唯一答案,只是李智慧的答案

李智慧选择HBase的真实原因:

  1. 团队熟悉:公司已有Hadoop运维团队
  2. 基础设施存在:已有HDFS/HBase集群
  3. 成本为0:复用现有资源,无需额外采购

2025年的推荐:ClickHouse

如果从零开始设计,ClickHouse是更好的选择

优势对比:
  性能: ClickHouse > HBase (压缩比10:1)
  成本: ClickHouse更低 (存储节省85%)
  运维: ClickHouse更简单 (无需Hadoop生态)
  查询: ClickHouse支持复杂SQL (HBase仅KV)
  
存储对比:
  原始数据: 12TB
  HBase存储: 12TB × 1.5 = 18TB (副本+WAL)
  ClickHouse: 12TB × 0.15 = 1.8TB (列存+压缩)
  
成本对比:
  HBase集群: 20台服务器 × 1万/月 = 20万/月
  ClickHouse: 3台服务器 × 1万/月 = 3万/月
  节省: 17万/月 = 204万/年

HDFS也是非必须的

问题:86.4GB的短URL文件真的需要HDFS吗?

答案:不需要!这是过度设计。

文件大小: 144亿 × 6字节 = 86.4GB

更简单的方案:
  1. NFS共享存储 - 最简单,所有服务器挂载同一目录
  2. 对象存储OSS - 阿里云/AWS S3,高可用且便宜
  3. rsync同步 - 定期同步到各服务器
  
为什么李智慧用HDFS:
  ✓ 公司已有Hadoop集群
  ✓ 运维成本为0
  ✓ 与HBase生态一致
  
如果你的公司没有Hadoop:
  ✗ 为86GB文件搭建HDFS集群是浪费
  ✗ HDFS运维成本远超NFS
  ✗ HDFS性能在此场景无优势

技术选型的真正智慧

李智慧的选型逻辑不是"选最新技术",而是

  1. 需求驱动:先明确需要什么样的数据库(百亿级+点查询)
  2. 现状评估:团队熟悉什么?公司有什么基础设施?
  3. 成本优先:复用现有资源 > 引入新技术
  4. 团队能力:团队熟练度 > 技术先进性
决策树:
  有Hadoop集群?
    ├─ Yes → HBase + HDFS (成本最低)
    └─ No  → ClickHouse + NFS (性能最优)
  
  有Cassandra团队?
    ├─ Yes → Cassandra + NFS
    └─ No  → ClickHouse + NFS

实践建议

如果你要做类似系统

  1. 不要盲目抄架构:先评估自己的资源和团队能力
  2. 2025年优先选择:ClickHouse(性能+成本+运维)
  3. 存储方案
    • < 100GB → 本地文件
    • < 1TB → NFS
    • > 1TB → 对象存储(OSS/S3)
    • 已有Hadoop → HDFS
  4. 缓存策略:Redis/Memcached 二选一即可,无需纠结

偏移量管理:文件锁 vs Redis

问题回顾:第三节中提到用文件锁管理偏移量,保证多服务器不重复加载短URL。

// 本项目实现:文件锁方案 syscall.Flock(int(offsetFile.Fd()), syscall.LOCK_EX) offset := readOffset(offsetFile) urls := readURLs(urlFile, offset, 10000) writeOffset(offsetFile, offset+60000) syscall.Flock(int(offsetFile.Fd()), syscall.LOCK_UN)

这个方案虽然简单,但生产环境不推荐

方案对比

维度 文件锁 Redis INCRBY
分布式支持 ❌ 需要NFS ✅ 天然分布式
性能 ❌ NFS延迟50ms+ ✅ 内存操作<1ms
并发模型 ❌ 串行阻塞 ✅ 无锁并发
容错性 ❌ 进程崩溃可能死锁 ✅ 主从+哨兵自动恢复
监控 ❌ 无法直观查看 GET offset随时查看
实现复杂度 ✅ 简单(20行) ✅ 简单(5行)
适用场景 单机/学习 生产环境

Redis方案的正确实现

// 推荐:Redis原子操作 func (r *RedisLoader) LoadBatch(count int) ([]string, error) { // 1. 原子地增加偏移量并返回新值 newOffset, err := r.client.IncrBy(ctx, "shorturl:offset", int64(count)).Result() if err != nil { return nil, err } // 2. 计算起始偏移量 startOffset := newOffset - int64(count) // 3. 从本地文件读取数据(每个服务器有副本) urls := readURLsFromFile(r.filePath, startOffset, count) return urls, nil }

关键优势

  • INCRBY是原子操作,无需加锁
  • 多个服务器可以并发执行,不会阻塞
  • Redis主从架构保证高可用

性能对比实测

假设场景:100台API服务器启动时各加载10000条短URL

文件锁方案(NFS):
  - 执行模式: 串行(一次只能一台)
  - 单次耗时: NFS延迟(50ms) + 文件读(10ms) + 写锁(5ms) = 65ms
  - 100台总耗时: 65ms × 100 = 6.5秒
  - 问题: 
    ✗ 启动慢
    ✗ NFS故障影响全部服务器
    ✗ 文件锁可能死锁
    
Redis方案:
  - 执行模式: 并发(100台同时)
  - 单次耗时: Redis INCRBY(0.5ms) + 本地文件读(10ms) = 10.5ms
  - 100台总耗时: 10.5ms(并发无阻塞)
  - 优势:
    ✓ 启动快 (6.5秒 → 10ms = 650倍提升)
    ✓ 本地文件读取,无网络瓶颈
    ✓ Redis主从,单点故障自动切换

为什么李智慧可能用文件锁?

我的推测(李智慧课程中未明确说明):

  1. Hadoop生态习惯:在Hadoop环境中,HDFS+ZooKeeper是标配,可能用ZooKeeper分布式锁而非文件锁
  2. 简化演示:课程中可能为了简化概念,用文件锁说明"互斥"思想
  3. 历史遗留:早期设计可能确实用文件锁,后期优化为Redis

生产环境建议

推荐架构:
  
  [API服务器1]     [API服务器2]     [API服务器N]
       ↓                ↓                ↓
       └────────────────┴────────────────┘
                        ↓
                  [Redis Cluster]
                  存储: offset = 123456789
                        
  每台服务器本地存储:
    /data/shorturls.dat (86.4GB文件副本)
    
  启动流程:
    1. Redis INCRBY offset 10000 → 返回新偏移量
    2. 从本地文件读取对应位置的数据
    3. 加载到内存链表

代码实现对比

点击查看完整代码对比
// ❌ 文件锁方案(本项目) type FileLoader struct { urlFile *os.File offsetFile *os.File } func (f *FileLoader) LoadBatch(count int) ([]string, error) { // 写打开offset文件(获取锁) offsetFile, _ := os.OpenFile("offset.dat", os.O_RDWR|os.O_CREATE, 0644) defer offsetFile.Close() // 文件锁 syscall.Flock(int(offsetFile.Fd()), syscall.LOCK_EX) defer syscall.Flock(int(offsetFile.Fd()), syscall.LOCK_UN) // 读取偏移量 var offset int64 fmt.Fscanf(offsetFile, "%d", &offset) // 读取URL数据 urlFile, _ := os.Open("shorturls.dat") defer urlFile.Close() urlFile.Seek(offset, 0) urls := make([]string, count) for i := 0; i < count; i++ { buf := make([]byte, 6) urlFile.Read(buf) urls[i] = string(buf) } // 更新偏移量 newOffset := offset + int64(count*6) offsetFile.Seek(0, 0) fmt.Fprintf(offsetFile, "%d", newOffset) return urls, nil } // ✅ Redis方案(推荐) type RedisLoader struct { client *redis.Client filePath string } func (r *RedisLoader) LoadBatch(count int) ([]string, error) { // Redis原子操作 newOffset, _ := r.client.IncrBy(ctx, "shorturl:offset", int64(count*6)).Result() startOffset := newOffset - int64(count*6) // 从本地文件读取 file, _ := os.Open(r.filePath) defer file.Close() file.Seek(startOffset, 0) urls := make([]string, count) for i := 0; i < count; i++ { buf := make([]byte, 6) file.Read(buf) urls[i] = string(buf) } return urls, nil }

代码行数对比:

  • 文件锁:25行,复杂度高
  • Redis:12行,逻辑清晰

结论

  1. 本项目用文件锁:为了简化依赖,本地验证足够
  2. 生产环境必须用Redis:性能、可靠性、可维护性都远超文件锁
  3. 技术演进路径
    • 学习阶段:文件锁(理解概念)
    • 单机部署:Redis单实例
    • 分布式部署:Redis Cluster
    • 超大规模:Redis Cluster + ZooKeeper(选主)

最佳实践:不要为了"简单"而选择文件锁,Redis的引入成本很低,收益却很高。

延伸阅读

本节内容是基于深入分析得出的结论,详细分析文档请查看:

这些文档包含更详细的技术对比、成本计算和架构演进分析。


九、实践总结

验证结论

通过本地实现和测试,我验证了:

  1. 预生成策略优于Hash和自增,无冲突且不可预测
  2. 预加载链表能高效管理海量短URL,内存占用低
  3. 偏移量文件互斥是简单有效的多服务器协调方案
  4. 分层缓存能大幅提升命中率和QPS
  5. 容量规划通过数学计算可以指导架构设计

关键洞察

  1. 数据驱动:先算容量,再选技术
  2. 分层设计:用缓存层次解决性能问题
  3. 预处理思想:把运行时问题转移到离线
  4. 简单优于复杂:文件锁比分布式锁简单且够用

本地实现的价值

虽然是简化版本,但核心思想完全一致:

  • 验证算法:证明预生成策略的必要性
  • 理解机制:通过代码理解链表、锁、缓存
  • 测试优化:用数据说明性能瓶颈
  • 指导扩展:清楚从本地到分布式的演进路径

十、快速开始

安装和运行

# 1. 克隆项目 git clone <repo-url> cd shorturl # 2. 安装依赖 make deps # 3. 生成短URL数据(100万条,约6MB) make generate # 4. 启动API服务 make run # 5. 测试API ./scripts/test.sh # 6. 运行性能测试 make stress

项目结构

fuxi/
├── cmd/
│   ├── generator/    # 短URL生成工具
│   ├── api/         # API服务器
│   └── benchmark/   # 性能测试工具
├── internal/
│   ├── generator/   # 生成算法实现
│   ├── preload/     # 预加载链表
│   └── storage/     # 存储层(缓存+数据库)
├── test/            # 单元测试和基准测试
└── data/            # 数据文件
    ├── shorturls.dat    # 短URL数据
    ├── offset.dat       # 偏移量文件
    └── fuxi.db          # SQLite数据库

十一、参考资料

  1. 李智慧 - 高并发架构实战课
  2. 本项目源码
  3. HDFS架构文档
  4. HBase设计文档
  5. 布隆过滤器原理

总结

通过这个项目,我们用600行代码实现了李智慧设计的核心逻辑,用实际测试数据验证了设计的合理性。

最大的收获

  • 理解了高并发系统的设计思路
  • 学会了用数据驱动架构设计
  • 掌握了从本地到分布式的演进路径
  • 证明了简单实现也能验证复杂理论

希望这篇博客和代码能帮助你更好地理解李智慧的设计!


作者注:本项目是学习性质的验证实现,如需用于生产环境,请参考李智慧的原始设计,使用HDFS、HBase、Redis等成熟的分布式组件。

本文链接:http://blog.go2live.cn/post/shorurl.html

-- EOF --