数据库选型
链接数突破 1 万条那天
我查了一下 SQLite 的文件大小:2.3GB。
这个数字让我愣了几秒。一个月前还只有几十 MB,现在膨胀到这个程度。更让我不安的是查询耗时的变化——短链接跳转的平均响应时间从 20ms 爬到了 150ms,偶尔还会出现 500ms 以上的尖刺。
对于一个”点一下就走”的服务来说,用户根本不会等 500ms。他们会以为链接挂了。
之前的紧急迁移让我从 SQLite 换到了 PostgreSQL,暂时扛住了并发问题。但我知道 PostgreSQL 只是救火方案,我从来没有认真想过一个根本性的问题:短链接服务到底该用什么数据库?
这次我决定不糊弄了。花一个周末,好好做一次选型。
我列了一张需求清单
调研之前,我先把需求理清楚。不然看了半天文档,最后还是凭感觉选。
短链接服务的数据特征其实很简单:
| 维度 | 特征 | 说明 |
|---|---|---|
| 读写比 | 100:1 | 创建一次,被访问成百上千次 |
| 数据结构 | 固定 | 短链接码、长 URL、创建时间、点击数——几乎不会变 |
| 一致性要求 | 高 | 短链接码必须唯一,跳转不能出错 |
| 数据量 | 中等 | 1 万条还在增长,目标是百万甚至千万级 |
| 查询模式 | 简单 | 99% 都是 WHERE short_code = ?,偶尔按用户查 |
总结一句话:读写比极端、数据结构固定、查询模式简单、一致性要求高。
带着这份需求清单,我开始逐个考察候选方案。
方案一:MySQL——“老大哥”
MySQL 是我第一个想到的。不是因为最好,而是因为最熟。
我在之前的公司用了三年 MySQL,增删改查、索引优化、主从复制,都干过。生态成熟意味着:遇到问题 Google 一下就能找到答案,Stack Overflow 上有无数前人的踩坑记录。
优势很明显:
- 事务支持:短链接创建涉及唯一性检查 + 插入,需要事务保证。MySQL 的 InnoDB 引擎天生支持 ACID。
- 成本可控:云数据库(阿里云 RDS)最低配置大约 ¥50/月,我能接受。
- 工具链丰富:mysqldump 备份、pt-online-schema-change 改表、慢查询日志分析……运维工具一应俱全。
- 人才储备:万一哪天我需要招人,MySQL DBA 比什么数据库的都好找。
但让我犹豫的也有:
- 读写性能天花板:单机 MySQL 在高并发读的场景下,QPS 大概在 1 万左右。对于我目前的量级够用,但如果用户持续增长呢?
- 运维负担:虽然是云数据库,但监控、备份策略、慢查询优化……这些还是得我自己来。作为一个还在白天上班的独立开发者,我花在运维上的时间越少越好。
- 水平扩展难:如果真到了千万级数据量,分库分表是件痛苦的事。
不过说实话,这些”缺点”更多是对未来的担忧,不是当下的痛点。我目前的 1 万条数据,MySQL 处理起来就像大象踩蚂蚁。
方案二:Redis——“速度之王”
Redis 的速度快是出了名的。内存操作,单机 QPS 轻松破 10 万。短链接服务的核心操作——根据短码查长 URL——本质上就是个 key-value 查询,Redis 天生就是干这个的。
但 Redis 让我纠结的地方也最多。
先说好的:
- 极致性能:单次查询微秒级,比 MySQL 快两个数量级。
- 天然适合缓存:短链接”写少读多”,缓存命中率可以做到 99% 以上。
- 数据结构丰富:String 存映射、Hash 存对象、HyperLogLog 做去重统计……几乎为短链接场景量身定制。
- 过期机制:自带 TTL,冷数据自动淘汰,不需要手动清理。
再说不好的:
- 持久化的两难:RDB 快照可能丢数据,AOF 日志太吃性能。虽然可以两者结合,但配置起来需要经验。短链接数据丢了就是事故——用户的链接打不开了。
- 内存成本:内存比磁盘贵得多。100 万条短链接映射,每条大约 500 字节(key + value + 元数据),需要约 500MB 内存。1000 万条就是 5GB。阿里云 Redis 256MB 规格 ¥80/月,2GB 规格 ¥300/月。
- 查询能力有限:Redis 不支持
WHERE、ORDER BY、JOIN。如果我要查”某个用户创建的所有短链接”,Redis 做不到,还是得回关系型数据库。 - 纯 Redis 做主存储的风险:万一 Redis 宕机、内存故障、甚至手误执行了
FLUSHALL,数据全没了。
我专门去查了一下 Redis 的持久化方案,想确认”重启会丢数据”这个担忧是不是过虑了:
| 持久化方式 | 原理 | 数据安全性 | 性能影响 |
|---|---|---|---|
| RDB | 定时快照 | 可能丢失最近几分钟数据 | 低 |
| AOF | 追加写日志 | 最多丢 1 秒数据 | 中 |
| RDB + AOF | 两者结合 | 接近零丢失 | 较高 |
结论是:Redis 可以做到基本不丢数据,但需要额外配置和运维。而且这会降低它的性能优势——有点讽刺。
方案三:MongoDB——“灵活的瑞士军刀”
MongoDB 是文档型数据库,数据以 JSON/BSON 格式存储。我身边有几个做全栈的朋友很喜欢它,说”不用设计表结构,直接塞 JSON 就行”。
我花了一上午读 MongoDB 的文档,然后得出了一个让我自己都有点意外的结论:MongoDB 很好,但不适合我这个场景。
为什么这么说?
- Schema 灵活是优势,但我用不上:我的数据结构是固定的——短链接码、长 URL、时间戳、点击数。不会突然加字段,也不需要嵌套文档。MongoDB 的灵活性在这里变成了”用大炮打蚊子”。
- 水平扩展是亮点,但还太早:MongoDB 的分片(Sharding)确实比 MySQL 的分库分表优雅得多。但我现在才 1 万条数据,谈分片有点像”刚学会走路就在研究马拉松”。
- 事务支持是短板:MongoDB 4.0 之后支持了多文档事务,但性能开销不小。短链接创建的核心流程——检查短码唯一性 + 插入——需要事务保障,这方面 MySQL 的 InnoDB 更成熟。
- 成本介于 MySQL 和 Redis 之间:阿里云 MongoDB 最低配置大约 ¥60/月。
说白了,MongoDB 的核心优势(灵活 Schema、水平扩展)在我的场景里发挥不出来,而它的短板(事务成熟度)恰恰是我需要的。技术选型不是选”最好的”,而是选”最合适的”。
我算了一笔账
作为一个还在亏钱的独立项目,每一分钱都要精打细算。我打开阿里云的价格计算器,认真算了一下:
| 方案 | 配置 | 月费用 | 年费用 | 说明 |
|---|---|---|---|---|
| MySQL | 1 核 1GB 基础版 | ¥50 | ¥600 | 主存储,够用 |
| Redis | 256MB 标准版 | ¥80 | ¥960 | 缓存层 |
| MongoDB | 1 核 1GB 基础版 | ¥60 | ¥720 | 文档存储 |
| MySQL + Redis | 两个搭配使用 | ¥130 | ¥1560 | 主存储 + 缓存 |
注意,这些都是最低配置的价格。如果数据量增长,费用会同步上涨。
我当时的服务器是一台 1 核 2G 的 ECS(¥40/月),加上域名和其他杂七杂八的费用,每月固定成本大概 ¥80。如果只用 MySQL,总成本变成 ¥130/月;如果 MySQL + Redis 一起上,就变成 ¥210/月。
¥210/月,一年就是 ¥2520。这个项目目前零收入。
但换个角度想:如果因为数据库选型不当导致服务挂了,用户流失的代价可能远不止 ¥2520。省钱的前提是先把事做对。
我的最终选择:MySQL + Redis
综合所有考虑,我选择了 MySQL 作为主存储,Redis 作为缓存层。
这个决定不是拍脑袋做的。我把三种方案的优劣势放在一起对比:
| 维度 | MySQL | Redis | MongoDB |
|---|---|---|---|
| 数据持久性 | ★★★★★ | ★★★ | ★★★★ |
| 查询性能 | ★★★ | ★★★★★ | ★★★★ |
| 事务支持 | ★★★★★ | ★★ | ★★★ |
| 运维复杂度 | ★★★ | ★★★ | ★★★ |
| 成本(最低) | ¥50/月 | ¥80/月 | ¥60/月 |
| 适合场景 | 主存储 | 缓存 | 日志/分析 |
然后我把它们组合成最终方案:
写入流程(创建短链接):
用户请求 → MySQL 写入(持久化)→ Redis 缓存(加速后续读取)
读取流程(跳转访问):
用户请求 → Redis 查询(快路径)→ 命中则直接返回
→ 未命中 → MySQL 查询 → 写入 Redis → 返回MySQL 负责什么:
- 持久化存储所有短链接映射
- 保证数据一致性(唯一索引、事务)
- 支持复杂查询(用户维度统计、管理后台)
Redis 负责什么:
- 缓存热点短链接的映射关系
- 点击计数(异步累加,定期同步到 MySQL)
- 接口限流计数器
import redis
import hashlib
class CachedURLShortener:
def __init__(self, mysql_conn, redis_conn):
self.db = mysql_conn
self.cache = redis_conn
self.CACHE_TTL = 3600 # 缓存 1 小时
def shorten(self, long_url):
"""创建短链接"""
# 1. 检查缓存中是否已存在
cache_key = f"url:long:hash:{self._hash_url(long_url)}"
cached_code = self.cache.get(cache_key)
if cached_code:
return f"https://short.url/{cached_code.decode()}"
# 2. 查询数据库
result = self.db.query(
"SELECT short_code FROM url_mapping WHERE long_url = %s",
(long_url,)
)
if result:
short_code = result[0]['short_code']
# 回填缓存
self.cache.setex(
f"url:long:hash:{self._hash_url(long_url)}",
self.CACHE_TTL,
short_code
)
return f"https://short.url/{short_code}"
# 3. 生成新的短链接
short_code = self._generate_short_code(long_url)
# 4. 存入数据库
self.db.execute(
"INSERT INTO url_mapping (short_code, long_url) VALUES (%s, %s)",
(short_code, long_url)
)
# 5. 写入缓存
self._cache_mapping(short_code, long_url)
return f"https://short.url/{short_code}"
def expand(self, short_code):
"""还原长 URL(读多写少,缓存命中率高)"""
# 1. 先查缓存
cached = self.cache.get(f"url:short:{short_code}")
if cached:
# 异步更新点击统计
self._increment_click_count(short_code)
return cached.decode()
# 2. 查数据库
result = self.db.query(
"SELECT long_url FROM url_mapping WHERE short_code = %s",
(short_code,)
)
if not result:
return None
long_url = result[0]['long_url']
# 3. 写入缓存
self.cache.setex(
f"url:short:{short_code}",
self.CACHE_TTL,
long_url
)
# 4. 记录点击
self._increment_click_count(short_code)
return long_url
def _cache_mapping(self, short_code, long_url):
"""缓存映射关系"""
pipe = self.cache.pipeline()
pipe.setex(f"url:short:{short_code}", self.CACHE_TTL, long_url)
pipe.setex(
f"url:long:hash:{self._hash_url(long_url)}",
self.CACHE_TTL,
short_code
)
pipe.execute()
def _hash_url(self, url):
return hashlib.md5(url.encode()).hexdigest()
def _increment_click_count(self, short_code):
"""异步增加点击计数"""
self.cache.incr(f"url:click:{short_code}")为什么不单用 Redis?
- 数据持久性:RDB/AOF 可能丢失数据,内存故障可能导致数据丢失
- 成本:内存成本远高于磁盘。10 亿条记录需要数百 GB 内存
- 查询能力有限:不支持复杂查询和多条件组合查询
为什么不用 MongoDB?
- 过度设计:短链接是简单的 key-value 映射,不需要文档型的灵活性
- 事务支持弱:核心映射需要强一致性,MongoDB 事务开销大
- 生态成熟度:MySQL 工具链更完善,遇到问题更容易找到解决方案
这个组合方案的成本是 ¥130/月(MySQL ¥50 + Redis ¥80),虽然比单用 MySQL 贵了一倍,但换来了:
- 缓存层的 10 倍读取性能提升
- 数据库的压力大幅降低
- 未来横向扩展的灵活性
¥130/月,买一个能扛住百万级访问量的存储架构。我觉得值。
本章小结
| 组件 | 作用 | 技术选型 | 月成本 |
|---|---|---|---|
| 主存储 | 持久化存储映射关系 | MySQL | ¥50 |
| 缓存层 | 加速读取,减轻 DB 压力 | Redis | ¥80 |
| 计数器 | 异步统计点击数 | Redis | 包含在上面 |
选型的核心思路不是”哪个数据库最好”,而是”哪个组合最适合我的场景”:
- 数据结构固定、一致性要求高 → MySQL
- 读写比极端、需要快速响应 → Redis
- 成本可控、运维可承受 → 最低配置起步,按需升级
练习题
练习 1
假设你的短链接服务突然火了,日访问量从 1 万飙升到 100 万。MySQL + Redis 的方案还能扛住吗?如果扛不住,你会怎么调整?
先算一笔账:
日访问量 100 万 = 平均 12 QPS,峰值可能 120 QPS
MySQL 单机:QPS 1 万左右 → 毫无压力
Redis 单机:QPS 10 万+ → 毫无压力
结论:100 万日访问量,MySQL + Redis 方案完全够用。真正扛不住的阈值大概在:
- 日访问量 1000 万以上(平均 120 QPS,峰值 1200 QPS)
- MySQL 开始出现慢查询
- Redis 内存不够缓存所有热点数据
升级路线:
第一步:优化 MySQL 配置
- 调大
innodb_buffer_pool_size - 开启慢查询日志,优化索引
- 升级到 2 核 4GB 规格(¥200/月)
- 调大
第二步:MySQL 读写分离
- 1 个主库 + 1-2 个从库
- 写走主库,读走从库
- 成本翻倍,但读性能也翻倍
第三步:Redis 集群
- 主从复制 + 哨兵模式
- 保证缓存高可用
- 阿里云 Redis 集群版 ¥500+/月
最终:如果还不够
- 考虑分库分表(按短链接码哈希分片)
- 或者迁移到 TiDB 等 NewSQL 数据库
关键原则:每一级升级都要有明确的触发条件,不要提前优化。
练习 2
设计 Redis 缓存的 Key 命名规范和过期策略。考虑:不同类型的数据应该有不同的过期时间。
Key 命名规范:
# 命名格式:业务:类型:标识符
# 短链接码 → 长 URL
"url:short:{short_code}"
例如:url:short:a1b2c3
# 长 URL 哈希 → 短链接码(去重用)
"url:long:hash:{md5_prefix}"
例如:url:long:hash:abc123def456
# 点击计数器
"url:click:{short_code}"
例如:url:click:a1b2c3
# 用户短链接列表
"user:urls:{user_id}"
例如:user:urls:12345过期策略:
# 分层过期策略
CACHE_TTL = {
'short_to_long': 3600, # 短→长:1 小时(高频访问)
'long_to_short': 86400, # 长→短:24 小时(去重用,低频)
'click_count': 0, # 点击计数:永不过期(需要持久化)
'user_urls': 1800, # 用户列表:30 分钟
}策略说明:
- 短→长映射:1 小时 — 高频访问,需要快速响应。过期后从数据库重新加载。
- 长→短映射:24 小时 — 创建时的去重检查,访问频率低,可以较长过期。
- 点击计数:永不过期 — 需要持久化统计,定期异步批量写入 MySQL。
- 热门链接自动续期 — 每次命中时重置 TTL,冷数据自然过期淘汰。