问题出现
小王把他的短链接分享到了微博。
一个科技博主的转发,让他的 SaaS 产品页面一夜之间涌入了上万人。每一个访客都要经过我的短链接服务跳转——我的那台 1 核 2G 的小服务器,突然开始承载它从未见过的流量。
我盯着监控面板,看着数字一路飙升:
时间线:
- 第1周:23 个用户,156 个短链接,日访问量 43 次
- 第2周:89 个用户,1,245 个短链接,日访问量 1,234 次
- 第3周:234 个用户,5,678 个短链接,日访问量 6,789 次
- 第4周:567 个用户,12,345 个短链接,日访问量 15,678 次
增长速度:
- 用户增长:25 倍
- 短链接增长:79 倍
- 日访问量:43 → 15,678(365 倍)
一个月前,我还觉得这个项目没人用。现在,567 个用户每天都在依赖它。
这本该让我开心。但手机不停震动的通知告诉我——好戏才刚刚开始。
凌晨 2:17,第一条告警弹了出来:
[2024-02-15 02:17:33] ERROR: Database connection pool exhausted
[2024-02-15 02:17:33] ERROR: Unable to acquire database connection within 30s
[2024-02-15 02:17:34] ERROR: Request timeout: GET /d7f3b2
[2024-02-15 02:17:35] ERROR: Request timeout: GET /a3f8c2
[2024-02-15 02:17:35] ERROR: Request timeout: GET /b7d9e1
...
我立刻打开服务逻辑,心跳加速——
落地思路
- 输入是一次跳转请求,服务要从连接池借一个连接去查短码;池是固定大小的,借不到就排队。
- 借连接有 30 秒上限,超时就记 ERROR 并返回失败,而不是无限等待把请求挂死。
- 连接用完必须在 finally 里归还,否则池会被逐渐耗尽,最后连新请求都借不到。
问题很清楚:SQLite 默认只允许 1 个写入连接。当并发请求达到上百个时,大量请求在排队等锁:
落地思路
- SQLite 用的是库级文件锁:一个写事务会把整个库锁住,读写互相阻塞。
- 跳转是读、点击统计是写,两条路径撞在一起就是「有人点击,其他人就等」。
- 连接池救不了 SQLite:池化只能复用连接,不能让一个文件同时被多个写事务修改。
- 所以结论不是「加连接池」,而是「换掉这个存储引擎」。
我没有犹豫。凌晨 2:30,我开始做一件从来没在生产环境做过的事——数据库迁移。
SQLite → PostgreSQL。手心全是汗。
落地思路
- 迁移分五步走:导出 SQLite → 转换字段类型 → 导入 PostgreSQL → 改连接配置 → 校验数据。
- 导入前先建好
urls表和idx_short_code索引,避免上线后再补索引时全表锁。- 切换放在凌晨低峰窗口;旧 SQLite 文件先只读保留一段时间,确认新库数据无误再删。
凌晨 4 点,迁移完成。监控面板上的错误日志停止了滚动。
| 方案 | 最大连接数 | 并发处理能力 | 响应时间 |
|---|---|---|---|
| SQLite(无连接池) | 1 | 1 QPS | 不稳定 |
| SQLite(有连接池) | 1 | 1 QPS | 仍然差 |
| PostgreSQL | 100 | 1000+ QPS | 稳定 |
我瘫在椅子上,看着监控曲线恢复平稳。那一刻我明白了一件事:SQLite 是个好数据库,但它不是为这个场景设计的。
刚搞定数据库,新的问题又来了。
用户反馈”短链接打开变慢了”。我查了监控数据,整个人都不好了:
落地思路
- 监控面板按 P50 / P99 分位看响应时间,P99 从 123ms 涨到 3.5 秒,比平均值更能暴露长尾。
- 告警先按 P99 设阈值:超过 1 秒就告警,避免平均值把长尾问题盖过去。
- 每次告警同时打出当时的 QPS、错误率和慢请求样本,用来区分「变慢了」还是「变少了」。
P99 从 123ms 涨到 3.5 秒。在用户看来,就是”点了没反应”。
我给每个重定向请求加了计时分析:
落地思路
- 每个跳转请求分段计时:解析短码、查库、写日志、返回 302,各自记录耗时。
- 汇总时看各段耗时占比,能直接看出瓶颈落在哪一步,而不是只知道「整体慢」。
- 只采样输出,避免给每个请求打全量日志,把磁盘再撑爆一次。
90% 的时间花在数据库查询上。数据量从几百条涨到了一万多条,每次查询都在做全表扫描。
我打开数据库检查索引:
数据设计要点
urls表用short_code作为查询入口,字段类型和唯一约束决定了跳转查询能不能走索引。- 这里要读的是执行计划结论:有没有命中
idx_short_code、扫描行数是否可控、是否出现全表扫描。
一个 CREATE INDEX 就能解决的事:
数据设计要点
- 索引服务于高频查询,重点关注
idx_short_code。- 查询目标是用短码快速找到目标链接、状态或统计结果,避免在跳转路径上做大范围扫描。
- 这里要读的是执行计划结论:有没有命中索引、扫描行数是否可控、是否出现全表扫描。
跑了一遍性能测试,看到结果的那一刻,我长出一口气:
落地思路
- 用 EXPLAIN 对比加索引前后的执行计划,确认走的是
idx_short_code而不是全表扫描。- 压测口径与线上一致:同一批短码、同样的并发数,只切换索引这一个变量。
- 目标是响应时间回落到 P99 百毫秒级,让跳转重新变成一次「无感」的点击。
一个索引,19.5 倍提升。这个教训我记一辈子——任何经常出现在 WHERE 子句里的字段,都必须建索引。
我刚准备去补个觉,运维同学(其实就是我自己)发来消息:
磁盘空间告警:
- 总容量:100GB
- 已使用:87GB
- 可用空间:13GB
- 使用率:87%
预计:3 天后磁盘将满!
我把磁盘占用拆开算了一遍:
落地思路
- 先按目录逐层看占用:/var/log、数据库文件目录、应用临时文件各占多少。
- 用 du -sh 排出最大的几个目录,再逐个下钻到具体文件,而不是凭感觉猜。
- 结论要落到「哪个文件在长、长多快」,才能决定是轮转、压缩还是直接删。
日志 45 GB。每次短链接访问我都写一条详细日志,每天 15,678 次访问 × 约 200 字节 ≈ 3 MB/天。积少成多,一个月下来就爆了。
解决方案很直接——日志轮转 + 压缩:
落地思路
- 按大小轮转:单个日志文件写到阈值就改名归档(.1、.2),仅保留最近的若干份。
- 归档后用 gzip 压缩,文本日志压缩比很高,压缩后仍可按需回查。
- 兜底用 logrotate 的 copytruncate,避免进程持有文件句柄时轮转失败导致日志丢失。
磁盘使用率从 87% 降到了 34%。这个问题的教训是:日志不是免费的,它是隐形成本最高的存储消耗。
如果说前面三个问题只是”系统不好用”,那这个就是”系统不可信”。
一个用户发来了愤怒的邮件:
我创建的短链接和别人的重复了!
我的短链接:https://short.url/a3f8c2 指向:https://mywebsite.com/page1
但打开后跳转到:https://otherwebsite.com/page2
请尽快处理!
我的心一下子提到了嗓子眼。短链接指向错误页面——这比宕机还严重。用户信任一旦崩塌,就再也回不来了。
我马上检查短码生成逻辑:
落地思路
- 检查项:短码长度、字符集来源、是否做过唯一性校验、冲突后走哪条分支。
- 关键结论是这一版只取了哈希的前几位,等于把完整的哈希空间砍到极小一段。
- 只要做了截断,就必须在数据库层有唯一约束兜底,不能只靠应用层「查一下再插入」。
问题出在这里:MD5 前 6 位只有 16^6 = 1677 万种组合。在 12,345 个链接的规模下,冲突已经出现了。
我跑了个测试:
落地思路
- 用真实的长 URL 集合跑生成逻辑,统计出现重复短码的比例,而不是估算。
- 结果换算到线上规模:12,345 条链接下的冲突率就是万分之一量级。
- 结论是冲突概率与「条数²/组合数」相关,条数每翻一倍,冲突率就翻四倍。
万分之二的冲突率,听起来不高。但对于一个短链接服务来说,一次冲突就是一次事故。
我花了一整天重写短码生成方案,最终选择了自增 ID + Base62:
落地思路
- 短码来源从「哈希截断」换成「自增 ID 转 Base62」,取值范围变成连续且不重复。
- 落库时靠
uk_short_code唯一索引兜底:冲突由数据库直接拒绝,不会出现两条记录同码。- 冲突率随记录数平方增长,但 6 位 Base62 有 568 亿组合,在当前量级上可以忽略。
自增 ID 保证唯一,Base62 保证短码紧凑。6 位 Base62 可以表示 568 亿个链接,足够了。
优点一目了然:
- 完全唯一,不可能有冲突
- 短码更短,6 位就够了
- 生成速度极快,不需要计算哈希
那天晚上,我给受影响的用户逐一发了道歉邮件。这是关系到用户信任的问题,不能出半点差错。
周四下午,服务突然完全无响应。
不是慢,是完全挂了。
我 SSH 到服务器,查看系统日志:
[2024-02-15 16:45:23] ERROR: Out of memory
[2024-02-15 16:45:23] ERROR: Killed (OOM Killer)
OOM Killer——Linux 内核在内存耗尽时强制杀掉进程的自救机制。我成了被杀掉的那个。
罪魁祸首很快找到了:
落地思路
- 罪魁祸首是进程内那份永不淘汰的 Python 字典:每个访问过的短码都会一直留在内存里。
- 短码基数随访问量单调增长,而字典没有容量上限,只增不减。
- 对照 Redis 的 maxmemory 策略,缺的不是「更多内存」而是「淘汰规则」——没有淘汰就等于没有上限。
算了笔账:
落地思路
- 账要算到单条常驻开销:短码字符串加上字典条目开销,再乘以记录条数,得出常驻内存的量级。
- 再乘上「每条短链平均被访问多少次」,说明缓存本该只为热点短码常驻。
- 结论是内存增长与「短码总数」相关,而与 QPS 无关——扩容和限流都救不了它。
解决方案很明确:用专业的缓存服务替代 Python 字典。
我引入了 Redis,带 LRU(最近最少使用)淘汰策略:
落地思路
- 缓存键用短码本身,值存可跳转的目标地址和状态,命中后一次往返就能拿到跳转目标。
- 显式设置 maxmemory 和 allkeys-lru:内存到上限时淘汰最久未使用的键,而不是写入失败。
- 兜底是「缓存只读不写主」:Redis 不可用时直接降级查库,跳转只是变慢,不会失败。
Redis 的好处是:内存用满时自动淘汰最冷的数据,不会无限制增长。同时缓存命中时响应时间从毫秒级降到微秒级。
重启服务后,我盯着内存监控看了整整一个小时,确认曲线稳住了才敢移开眼睛。
周五晚上,我坐在电脑前,把这周经历的所有问题列了一遍:
- SQLite 扛不住并发 → 换了 PostgreSQL
- 数据库查询太慢 → 加了索引
- 日志吃掉磁盘 → 做了日志轮转
- 短码冲突导致数据错误 → 重写了短码生成
- 内存溢出搞挂服务 → 引入了 Redis
每个问题都是临时救火,但系统已经变成了一堆补丁。我需要重新梳理一下整体架构:
┌─────────────────────────────────────────────────────────┐
│ 负载均衡器 │
│ (Nginx/HAProxy) │
└───────────────────────┬─────────────────────────────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌───────▼───────┐ ┌────▼────┐ ┌───────▼───────┐
│ Web 服务器1 │ │Web 服务器2│ │ Web 服务器3 │
│ (Flask/Gunicorn) │ │ │
└───────┬───────┘ └────┬────┘ └───────┬───────┘
│ │ │
└───────────────┼───────────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌───────▼───────┐ ┌────▼────┐ ┌───────▼───────┐
│ Redis 缓存 │ │Redis 缓存│ │ Redis 缓存 │
│ (主从复制) │ │(从节点) │ │ (从节点) │
└───────┬───────┘ └────┬────┘ └───────┬───────┘
│ │ │
└───────────────┼───────────────┘
│
┌───────────────┴───────────────┐
│ │
┌───────▼────────┐ ┌────────▼────────┐
│ PostgreSQL │ │ 消息队列 │
│ (主从复制) │ │ (日志收集) │
└────────────────┘ └─────────────────┘
技术栈全面升级:
| 组件 | 旧方案 | 新方案 | 原因 |
|---|---|---|---|
| 数据库 | SQLite | PostgreSQL | 支持更多并发连接 |
| 缓存 | Python dict | Redis | 专业缓存方案 |
| Web 服务器 | Flask dev | Gunicorn | 生产级服务器 |
| 负载均衡 | 无 | Nginx | 分散请求压力 |
| 日志 | 文件直接写入 | 消息队列 + 异步 | 提升性能 |
| 监控 | 无 | Prometheus | 实时监控系统 |
周末,我跑了一轮完整的性能测试,对比优化前后的数据:
落地思路
- 压测口径固定:同一批短码、同样的并发模型,优化前后各跑一轮。
- 平均响应时间和 QPS 要一起看——只降延迟但吞吐没上去,说明瓶颈只是被挪了位置。
- 结果与线上监控对齐才算数,避免压测环境比生产环境更理想。
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 平均响应时间 | 2345 ms | 45 ms | 52 倍 |
| QPS | 8 | 427 | 53 倍 |
| 成功率 | 78.5% | 99.9% | +27% |
总结一下这一个月踩过的坑和填过的坑:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 连接耗尽 | SQLite 并发限制 | 切换到 PostgreSQL + 连接池 |
| 响应变慢 | 缺少索引 | 添加索引 + 使用 Redis 缓存 |
| 磁盘告急 | 日志太大 | 日志轮转 + 压缩 |
| 短码冲突 | MD5 哈希冲突 | 使用自增 ID + Base62 |
| 内存溢出 | 无限制缓存 | 使用 Redis + LRU 策略 |
系统的演进路线也清晰了:
阶段1(单机):
Flask + SQLite + 本地缓存
阶段2(优化):
Flask + PostgreSQL + Redis + 连接池
阶段3(分布式):
负载均衡 + 多台服务器 + 主从复制
这一周我瘦了 3 斤,但系统终于稳住了。
这一个月的救火经历,让我学到了几件事:
- 提前规划:不要等问题出现再处理。如果我从一开始就用 PostgreSQL,第一个告警根本不会发生。
- 监控是生命线:没有监控,我根本不知道系统出了什么问题。告警让我能在用户投诉前发现问题。
- 逐步优化:一次解决一个问题。同时改太多东西,出了 bug 都不知道是哪个改动引起的。
- 性能测试不能省:如果不是跑了对比测试,我不会有底气说”系统稳住了”。
但事情还没有结束。流量还在涨,用户开始抱怨短链接打开速度不够快。我需要更快的跳转速度……
问题1
如果你的服务突然被某个热门新闻引用,流量瞬间增长 100 倍,你会怎么做?
提示
考虑以下几个方面:
- 快速扩容:云服务自动伸缩
- 限流保护:防止系统被冲垮
- 降级服务:保证核心功能(跳转),暂停非核心功能(统计)
- CDN 加速:减轻源站压力
问题2
如何设计一个高可用的短链接系统?单机房扛不住时怎么办?
提示
考虑以下几个方面:
- 多机房部署(异地多活)
- 数据库主从复制
- 缓存集群
- 自动故障转移
- 定期备份
练习1
为什么从 SQLite 切换到 PostgreSQL 能大幅提升并发性能?我凌晨两点做这个决策时,心里其实也没底。
参考答案
关键差异:
-
连接模型
- SQLite:文件级锁,写入时锁定整个数据库
- PostgreSQL:MVCC(多版本并发控制),读写不冲突
-
并发能力
- SQLite:1 个写入连接 + 少量读取连接
- PostgreSQL:数百个并发连接
-
性能差异
SQLite: - 写入 QPS:约 100 - 读取 QPS:约 1,000 PostgreSQL: - 写入 QPS:约 10,000 - 读取 QPS:约 100,000 -
适用场景
- SQLite:嵌入式应用、单用户工具、开发测试
- PostgreSQL:Web 应用、高并发系统、生产环境
为什么一开始不直接用 PostgreSQL? 因为在当时,我根本没想过这个项目会有 500 多个用户。技术选型总是要权衡”现在需要什么”和”未来可能需要什么”。
练习2
Redis 缓存为什么能提升性能?缓存穿透、缓存击穿、缓存雪崩分别是什么?
参考答案
Redis 缓存优势:
- 速度快:内存操作,比数据库快 100-1000 倍
- 支持高并发:单机支持 10 万+ QPS
- 丰富的数据结构:String、Hash、List 等
三大缓存问题:
缓存穿透:查询不存在的数据,每次都穿透到数据库。
落地思路
- 穿透的本质是「查询不存在的数据」,请求每次都绕过缓存打到数据库。
- 兜底手段:先按短码格式校验(长度、字符集不合规则直接拒绝),再对查不到的短码缓存一个空值。
- 空值也要设短 TTL,避免真实链接后来被创建时,缓存里的「不存在」长期挡住它。
缓存击穿:热点数据过期瞬间,大量请求同时打到数据库。
落地思路
- 击穿是「热点 key 恰好过期」,同一瞬间大量请求同时落到数据库。
- 兜底手段:热点短码的缓存 TTL 加随机偏移,或者过期后用互斥锁只放一个请求回源。
- 其余请求等待重建结果,避免把数据库当成第二个缓存来打。
缓存雪崩:大量缓存同时过期,数据库压力骤增。
落地思路
- 雪崩是「大量 key 同时过期」,缓存整体失效,流量整体压向数据库。
- 兜底手段:TTL 加随机抖动,或分批预热,避免全站缓存同一时刻集体到期。
- 另一层兜底是回源限流:缓存大面积未命中时主动降级,保护数据库不被冲垮。
这三个问题我后来都遇到过。缓存击穿最阴险——平时一切正常,某个热点链接过期的那一秒,系统突然就扛不住了。
(下一节:自定义短链接)
发现内容有问题?
告诉我们哪里需要改进。
