作者:bjmayor
时间:2025年11月
目标:用简化的本地实现来验证百亿级短URL系统的核心技术点
引言
李智慧老师在《高并发架构实战课》中设计了一个名为Fuxi的短URL系统,目标是处理144亿条短URL、4万QPS并发、12TB存储。这是一个典型的高并发+海量数据场景。
但作为学习者,我们遇到几个问题:
- 无法复现环境:家用电脑没有几百G内存、几十T硬盘
- 概念难以理解:HDFS、HBase、Redis集群配置复杂
- 缺少直观验证:为什么要预生成?缓存策略真的有效吗?
本文的目标:用简化的本地实现,通过代码和测试数据,验证李智慧设计中的核心技术点。
一、为什么不用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"]
结论
- Hash截断:虽然快,但冲突率达到3.28%,在百亿规模下会有数亿次冲突
- 自增序列:虽然最快且无冲突,但生成的短URL是连续的,存在安全隐患
- 如果知道一个短URL是
AAAAAA,可以猜测下一个是AAAAAB - 攻击者可以遍历所有短URL
- 如果知道一个短URL是
- 随机预生成:速度适中,无冲突,不可预测 ✓
这就是李智慧选择预生成策略的原因!
二、预加载链表:内存管理的艺术
设计思路
144亿条短URL,每条6字节,文件大小86.4GB。如何高效管理?
李智慧的方案:
- 预生成文件:离线生成144亿条短URL,存储在HDFS文件中
- 预加载服务:启动时加载1万条到内存链表
- 头指针消费:每次获取短URL从链表头部取出
- 异步加载:剩余不足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万条在内存(约60KB),而不是全部144亿
- 延迟极低:从链表获取只需1-2微秒
- 自动管理:剩余不足时自动触发加载,业务层无感知
三、偏移量文件互斥:多服务器协作
问题场景
如果有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
性能分析
-
创建短URL:QPS=813,P99延迟28ms
- 瓶颈:数据库写入(SQLite单线程写入限制)
- 优化方向:批量写入、更换PostgreSQL
-
短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
需要的数据库特性:
- ✅ 单表支持百亿级数据(不能分表)
- ✅ 点查询性能 < 10ms
满足条件的数据库对比
| 数据库 | 单表百亿 | 点查询<10ms | LSM树 | 横向扩展 | 李智慧为何选/不选 |
|---|---|---|---|---|---|
| MySQL | ❌ | ❌ | ❌ | ❌ | 需要分600张表,运维复杂 |
| PostgreSQL | ❌ | ❌ | ❌ | ❌ | 同MySQL,单表性能瓶颈 |
| MongoDB | ⚠️ | ⚠️ | ❌ | ✅ | 可行,但查询性能不如列存 |
| HBase | ✅ | ✅ | ✅ | ✅ | ✓ 团队熟悉Hadoop生态 |
| ClickHouse | ✅ | ✅ | ✅ | ✅ | ✓ 2025年更优选择 |
| Cassandra | ✅ | ✅ | ✅ | ✅ | ✓ 也是好选择 |
| ScyllaDB | ✅ | ✅ | ✅ | ✅ | ✓ Cassandra的C++实现 |
| TiDB | ✅ | ✅ | ✅ | ✅ | ✓ 国产优秀方案 |
关键结论
HBase不是唯一答案,只是李智慧的答案
李智慧选择HBase的真实原因:
- 团队熟悉:公司已有Hadoop运维团队
- 基础设施存在:已有HDFS/HBase集群
- 成本为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性能在此场景无优势
技术选型的真正智慧
李智慧的选型逻辑不是"选最新技术",而是:
- 需求驱动:先明确需要什么样的数据库(百亿级+点查询)
- 现状评估:团队熟悉什么?公司有什么基础设施?
- 成本优先:复用现有资源 > 引入新技术
- 团队能力:团队熟练度 > 技术先进性
决策树:
有Hadoop集群?
├─ Yes → HBase + HDFS (成本最低)
└─ No → ClickHouse + NFS (性能最优)
有Cassandra团队?
├─ Yes → Cassandra + NFS
└─ No → ClickHouse + NFS
实践建议
如果你要做类似系统:
- 不要盲目抄架构:先评估自己的资源和团队能力
- 2025年优先选择:ClickHouse(性能+成本+运维)
- 存储方案:
- < 100GB → 本地文件
- < 1TB → NFS
- > 1TB → 对象存储(OSS/S3)
- 已有Hadoop → HDFS
- 缓存策略: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主从,单点故障自动切换
为什么李智慧可能用文件锁?
我的推测(李智慧课程中未明确说明):
- Hadoop生态习惯:在Hadoop环境中,HDFS+ZooKeeper是标配,可能用ZooKeeper分布式锁而非文件锁
- 简化演示:课程中可能为了简化概念,用文件锁说明"互斥"思想
- 历史遗留:早期设计可能确实用文件锁,后期优化为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行,逻辑清晰
结论
- 本项目用文件锁:为了简化依赖,本地验证足够
- 生产环境必须用Redis:性能、可靠性、可维护性都远超文件锁
- 技术演进路径:
- 学习阶段:文件锁(理解概念)
- 单机部署:Redis单实例
- 分布式部署:Redis Cluster
- 超大规模:Redis Cluster + ZooKeeper(选主)
最佳实践:不要为了"简单"而选择文件锁,Redis的引入成本很低,收益却很高。
延伸阅读
本节内容是基于深入分析得出的结论,详细分析文档请查看:
-
为什么选HBase而不是MySQL?
深入对比MySQL/PostgreSQL/MongoDB/HBase/ClickHouse的单表容量、查询性能、运维成本 -
HDFS是必须的吗?
分析86.4GB文件是否需要HDFS,对比NFS/OSS/rsync/HDFS的成本和适用场景 -
HDFS和HBase如何协作?
详细解释HDFS存储短URL池、HBase存储映射关系的完整工作流程
这些文档包含更详细的技术对比、成本计算和架构演进分析。
九、实践总结
验证结论
通过本地实现和测试,我验证了:
- ✅ 预生成策略优于Hash和自增,无冲突且不可预测
- ✅ 预加载链表能高效管理海量短URL,内存占用低
- ✅ 偏移量文件互斥是简单有效的多服务器协调方案
- ✅ 分层缓存能大幅提升命中率和QPS
- ✅ 容量规划通过数学计算可以指导架构设计
关键洞察
- 数据驱动:先算容量,再选技术
- 分层设计:用缓存层次解决性能问题
- 预处理思想:把运行时问题转移到离线
- 简单优于复杂:文件锁比分布式锁简单且够用
本地实现的价值
虽然是简化版本,但核心思想完全一致:
- 验证算法:证明预生成策略的必要性
- 理解机制:通过代码理解链表、锁、缓存
- 测试优化:用数据说明性能瓶颈
- 指导扩展:清楚从本地到分布式的演进路径
十、快速开始
安装和运行
# 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数据库
十一、参考资料
总结
通过这个项目,我们用600行代码实现了李智慧设计的核心逻辑,用实际测试数据验证了设计的合理性。
最大的收获:
- 理解了高并发系统的设计思路
- 学会了用数据驱动架构设计
- 掌握了从本地到分布式的演进路径
- 证明了简单实现也能验证复杂理论
希望这篇博客和代码能帮助你更好地理解李智慧的设计!
作者注:本项目是学习性质的验证实现,如需用于生产环境,请参考李智慧的原始设计,使用HDFS、HBase、Redis等成熟的分布式组件。
