数据库选型

链接数突破 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 不支持 WHEREORDER BYJOIN。如果我要查”某个用户创建的所有短链接”,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、水平扩展)在我的场景里发挥不出来,而它的短板(事务成熟度)恰恰是我需要的。技术选型不是选”最好的”,而是选”最合适的”。


我算了一笔账

作为一个还在亏钱的独立项目,每一分钱都要精打细算。我打开阿里云的价格计算器,认真算了一下:

方案配置月费用年费用说明
MySQL1 核 1GB 基础版¥50¥600主存储,够用
Redis256MB 标准版¥80¥960缓存层
MongoDB1 核 1GB 基础版¥60¥720文档存储
MySQL + Redis两个搭配使用¥130¥1560主存储 + 缓存

注意,这些都是最低配置的价格。如果数据量增长,费用会同步上涨。

我当时的服务器是一台 1 核 2G 的 ECS(¥40/月),加上域名和其他杂七杂八的费用,每月固定成本大概 ¥80。如果只用 MySQL,总成本变成 ¥130/月;如果 MySQL + Redis 一起上,就变成 ¥210/月。

¥210/月,一年就是 ¥2520。这个项目目前零收入。

但换个角度想:如果因为数据库选型不当导致服务挂了,用户流失的代价可能远不止 ¥2520。省钱的前提是先把事做对。


我的最终选择:MySQL + Redis

综合所有考虑,我选择了 MySQL 作为主存储,Redis 作为缓存层

这个决定不是拍脑袋做的。我把三种方案的优劣势放在一起对比:

维度MySQLRedisMongoDB
数据持久性★★★★★★★★★★★★
查询性能★★★★★★★★★★★★
事务支持★★★★★★★★★★
运维复杂度★★★★★★★★★
成本(最低)¥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?

  1. 数据持久性:RDB/AOF 可能丢失数据,内存故障可能导致数据丢失
  2. 成本:内存成本远高于磁盘。10 亿条记录需要数百 GB 内存
  3. 查询能力有限:不支持复杂查询和多条件组合查询

为什么不用 MongoDB?

  1. 过度设计:短链接是简单的 key-value 映射,不需要文档型的灵活性
  2. 事务支持弱:核心映射需要强一致性,MongoDB 事务开销大
  3. 生态成熟度: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 内存不够缓存所有热点数据

升级路线

  1. 第一步:优化 MySQL 配置

    • 调大 innodb_buffer_pool_size
    • 开启慢查询日志,优化索引
    • 升级到 2 核 4GB 规格(¥200/月)
  2. 第二步:MySQL 读写分离

    • 1 个主库 + 1-2 个从库
    • 写走主库,读走从库
    • 成本翻倍,但读性能也翻倍
  3. 第三步:Redis 集群

    • 主从复制 + 哨兵模式
    • 保证缓存高可用
    • 阿里云 Redis 集群版 ¥500+/月
  4. 最终:如果还不够

    • 考虑分库分表(按短链接码哈希分片)
    • 或者迁移到 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. 短→长映射:1 小时 — 高频访问,需要快速响应。过期后从数据库重新加载。
  2. 长→短映射:24 小时 — 创建时的去重检查,访问频率低,可以较长过期。
  3. 点击计数:永不过期 — 需要持久化统计,定期异步批量写入 MySQL。
  4. 热门链接自动续期 — 每次命中时重置 TTL,冷数据自然过期淘汰。