关键决策

开篇

回头看,有 10 个关键决策塑造了今天的系统。每个决策背后都有纠结。

三年前,当我决定构建这个短链接服务时,我以为最难的是技术实现。但真正困难的,是在无数个深夜里做出的那些选择。每一个决策都像是一次赌博——赌错了,可能满盘皆输;赌对了,也不过是继续前行。

这三年,我经历过数据库崩溃的恐慌,也体会过系统平稳运行的满足。每一次选择,都是在当时的信息和约束下,我能做出的最好决定。

让我带你回到那些关键的时刻,看看我是如何纠结、如何选择的。


决策一:SQLite vs MySQL——省钱还是省心

当时的情况

项目刚启动的时候,我口袋里没多少钱。服务器预算每月只有 50 块。

那时候的用户量?说实话,我都不确定会不会有人用。

我的纠结

用 SQLite 吧,省钱。

SQLite 不需要额外的数据库服务器,一个文件就能搞定。对于囊中羞涩的独立开发者来说,简直是天选之子。

但 SQLite 真的能撑住吗?

我查了很多资料。有人说 SQLite 单机能处理 10 万 QPS,有人说并发写入会有锁竞争。我想象着:如果有一天,真的有大量用户同时创建短链接,数据库会不会锁死?

用 MySQL 吧,省心。

MySQL 是成熟的数据库,并发处理能力强。但代价是需要额外的服务器资源,需要更多的运维精力。

每月 50 块的预算,买完应用服务器,剩下的钱够买 MySQL 服务器吗?

我在两个选择之间摇摆了一个星期。

“如果用 SQLite,能省下每月 30 块。一年就是 360 块。”

“但如果遇到并发问题,重构的成本是多少?迁移数据的时间是多少?”

最终让我下定决心的是一个论坛帖子:

“我用 SQLite 起步,用户量到 10 万时遇到性能问题。迁移到 MySQL 花了两周,期间服务不可用。如果重来,我会直接选 MySQL。“

最终选择

我选择了 MySQL。

理由很简单:我不想让数据库成为系统的瓶颈。即使初期多花点钱,也比将来重构划算。

我租了一台最低配置的云服务器,装了 MySQL 5.7。配置能省则省:单实例、每天一次备份、内存参数调到最低。

现在看来

这个决定是对的。

第一年结束时,系统已经有了稳定的流量。如果当初用 SQLite,可能早就遇到并发问题了。

但我也付出了代价:运维成本比预期高。数据库偶尔会挂,需要手动重启。

记得有一次,凌晨三点我被告警短信吵醒。数据库 CPU 100%,所有请求都在排队。我登录服务器,发现是一个慢查询导致的。花了半小时才修复。

那一刻我真想:要是用 SQLite,是不是就没这回事了?

但转念一想:如果用 SQLite,可能早就因为并发问题崩溃了,连被吵醒的机会都没有。

如果重来一次,我可能会选择 SQLite 起步,等用户量上来再迁移。毕竟,premature optimization is the root of all evil。

核心权衡:初期成本 vs 长期可扩展性


决策二:MD5 vs 自增 ID——冲突的教训

当时的情况

确定了数据库之后,下一个问题是:短码怎么生成?

我最初的想法很简单:用 MD5。把长 URL 哈希一下,取前 8 位,不就是短码了吗?

我的纠结

MD5 方案看起来很完美:

  • 相同的 URL 总是生成相同的短码
  • 无需存储映射关系
  • 实现简单,几行代码搞定

我甚至已经写好了代码:

import hashlib
def generate_short_code(url):
    return hashlib.md5(url.encode()).hexdigest()[:8]

但在上线前,我突然想到了一个问题:冲突怎么办?

MD5 取前 8 位,总共 16^8 = 42 亿种可能。根据生日悖论,当 URL 数量达到约 6.5 万时,冲突概率就达到 50%。

6.5 万个 URL!对于一个小项目来说,这看起来很多。但如果真的做大了呢?

我想象着那个场景:用户 A 创建了一个短链接,用户 B 也创建了同一个短码——但指向不同的 URL。系统该怎么办?

另一种方案是自增 ID。每条记录一个 ID,从 1 开始递增,然后用 Base62 编码转换成短码。

但自增 ID 也有问题:

  • 可预测:用户知道 ID 是连续的,可以遍历所有短链接
  • 分布式困难:多服务器时,ID 会冲突

一位从业十年的工程师在博客里写道:

“我们试过哈希方案,冲突问题让我们吃了大亏。后来改用自增 ID,虽然要处理分布式问题,但至少不会有冲突。”

这句话让我下定决心。

最终选择

我选择了自增 ID + Base62 编码。

冲突的代价太高了。自增 ID 虽然有可预测的问题,但可以通过其他方式缓解。

Base62 编码让短码更短、更友好。100 万的 ID 编码后只有 4 位:4c92。

def base62_encode(num):
    alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
    if num == 0:
        return alphabet[0]
    base62 = ''
    while num > 0:
        num, remainder = divmod(num, 62)
        base62 = alphabet[remainder] + base62
    return base62

现在看来

这个决定避免了潜在的冲突问题,但带来了另一个问题:分布式 ID 生成。

当系统扩展到多台服务器时,自增 ID 就不够用了。我后来引入了 Snowflake 算法,才解决这个问题。

如果重来一次,我会从一开始就使用分布式 ID 生成器。虽然复杂一点,但避免了后续的重构。

核心权衡:实现简单 vs 冲突风险


决策三:301 vs 302——统计 vs 性能

当时的情况

第一个用户反馈来了:“为什么我的短链接跳转后,浏览器地址栏还是短域名?”

我检查了代码,发现用的是 301 永久重定向。

我的纠结

301 是永久重定向。浏览器会缓存这个重定向关系,下次直接跳转到目标 URL,不再请求短链接服务。

好处是性能更好,减少一次请求。坏处是无法统计后续点击。

302 是临时重定向。浏览器每次都会请求短链接服务。

好处是可以统计每次点击。坏处是每次都要请求服务器,增加压力。

我纠结了很久。

从技术角度,301 更优雅。但从产品角度,302 更实用。点击统计是核心功能之一,用户需要知道有多少人点击了他们的链接。

我找来几个朋友,问他们的意见。

做产品的朋友说:“肯定要 302。没有统计数据,用户怎么知道链接效果?”

做运维的朋友说:“301 更好。少一次请求,服务器轻松很多。“

最终选择

我选择了 302。

理由很现实:点击统计是我们的核心卖点。如果为了性能牺牲这个功能,产品就失去了竞争力。

@app.route('/<short_code>')
def redirect(short_code):
    url = get_url(short_code)
    if url:
        record_click(short_code)
        return redirect(url, code=302)
    return 'Not found', 404

现在看来

这个决定是正确的。

很多用户告诉我们,点击统计是他们选择我们的重要原因。如果当初用了 301,这些用户可能就流失了。

但我也付出了代价:服务器压力比预期大。高峰期,重定向接口占了 80% 的流量。

记得有一次大促,流量突然暴涨。服务器 CPU 飙升,响应时间从 10ms 变成 100ms。我紧急扩容,才扛过去。

后来我引入了多级缓存,才缓解了这个问题。

如果重来一次,我可能会提供一个选项:让用户选择 301 或 302。不同的场景,不同的需求。

核心权衡:统计能力 vs 跳转性能


决策四:本地缓存 vs Redis——简易 vs 专业

当时的情况

系统运行半年后,数据库开始吃不消了。每次重定向都要查数据库,数据库的 CPU 经常飙到 90%。

我的纠结

加缓存是必然的。但用什么缓存?

用本地缓存吧,简单。就是应用内存里的一个字典,访问速度极快。

cache = {}
def get_url(short_code):
    if short_code in cache:
        return cache[short_code]
    url = database.get_url(short_code)
    cache[short_code] = url
    return url

测试时,响应时间从 50ms 降到了 1ms。效果惊人。

但本地缓存有问题:多服务器时不共享,服务器重启会丢失。

我想象着:如果有两台服务器,用户访问服务器 A 创建的短链接,但请求到了服务器 B,缓存就没有了。

用 Redis 吧,专业。

Redis 支持分布式,多服务器可以共享。但需要额外的服务器资源,增加网络延迟,运维复杂度也增加。

我查了 Redis 的资料,发现它不仅仅是缓存:

  • 可以用来做分布式锁
  • 可以用来做计数器
  • 可以用来做队列

这些功能我将来可能都用得上。

但我又担心:引入 Redis 会不会增加太多复杂度?我现在连 MySQL 都还没搞利索,再加一个 Redis,能维护过来吗?

最终选择

我选择了 Redis。

理由是多服务器扩展。虽然现在只有一台服务器,但我想为未来做准备。

我租了一台 Redis 云服务,每月 20 块。迁移过程很顺利。

import redis
redis_client = redis.Redis(host='redis.example.com', port=6379)

def get_url(short_code):
    url = redis_client.get(f'url:{short_code}')
    if url:
        return url.decode()
    url = database.get_url(short_code)
    if url:
        redis_client.setex(f'url:{short_code}', 3600, url)
    return url

上线后,数据库 CPU 从 90% 降到了 20%。效果立竿见影。

现在看来

这个决定是正确的。

第二年,我们扩展到了三台服务器。如果没有 Redis,缓存同步会是个大问题。

但我也发现,本地缓存并没有完全废弃。我采用了多级缓存策略:

  • L1:本地缓存(进程内)
  • L2:Redis 缓存(共享)
  • L3:数据库(持久化)

这个架构的性能比单纯用 Redis 更好。L1 缓存命中时,响应时间不到 1ms。

如果重来一次,我会从一开始就设计多级缓存。但当时的我,连单级缓存都没想明白。

核心权衡:实现简单 vs 分布式能力


决策五:单机 vs 分布式——够用就好 vs 未雨绸缪

当时的情况

第一年结束,系统运行平稳。每天几十万的重定向请求,数据库和缓存都能扛住。

有人问我:“什么时候考虑分布式?“

我的纠结

上分布式吧,未雨绸缪。

分布式架构可以水平扩展,理论上可以支撑无限流量。但分布式是有代价的:

  • 复杂度指数级增长
  • 需要解决一致性问题
  • 需要处理网络分区
  • 调试困难

我看过太多分布式系统的坑:数据不一致、网络分区、死锁、调试困难……

不上分布式吧,够用就好。

现在的架构简单清晰:一台应用服务器、一台数据库服务器、一个 Redis 实例。维护成本低,出了问题也容易定位。

如果真的流量大增,再加机器也不迟。

我想起一句话:“Don’t scale until you need to.”

但我也担心:等需要的时候再加,会不会太晚?重构的成本会不会更高?

我找来一个做过分布式系统的朋友,问他:“你觉得我应该什么时候上分布式?”

他说:“等你真的需要的时候。”

“那怎么判断需不需要?”

“当单机性能到极限,或者运维成本超过分布式成本时。“

最终选择

我选择了单机架构,但预留扩展空间。

具体来说:

  • 应用层无状态,可以水平扩展
  • 数据库读写分离,可以加从库
  • 缓存用 Redis,支持集群

我特意把应用设计成无状态的。所有状态都放在 Redis 和数据库里,应用服务器不存任何状态。

这样,将来需要扩展时,直接加机器就行,不用改代码。

现在看来

这个决定是明智的。

第二年流量确实增长了,但还没有到必须分布式的程度。我们加了一台应用服务器,用 Nginx 做负载均衡,就解决了问题。

数据库方面,我们做了读写分离。写操作走主库,读操作走从库。性能提升明显。

如果一开始就搞分布式,可能会陷入过度设计的陷阱。微服务、服务发现、配置中心……这些东西会增加多少复杂度?

我记得有一次,有个候选人来面试。他之前在一家大厂做分布式系统,讲了很多高大上的架构。

我问他:“你们为什么要搞这么复杂?”

他说:“其实很多时候没必要。但大厂有 KPI,要做技术储备。”

我笑了。至少我没有 KPI 压力,可以按需设计。

核心权衡:当前简单 vs 未来扩展


决策六:Snowflake vs Redis INCR——复杂 vs 简单

当时的情况

当我们扩展到三台应用服务器时,ID 冲突问题出现了。

三台服务器都在生成自增 ID,不可避免地会产生重复。

我的纠结

用 Redis INCR 吧,简单。

Redis 是单线程的,INCR 操作是原子的。所有服务器都从 Redis 获取 ID,保证唯一性。

next_id = redis.incr('global_id')

一行代码搞定。

但 Redis INCR 有问题:Redis 成为单点,每次生成 ID 都要网络请求,性能受限。QPS 大概 5 万左右。

用 Snowflake 吧,复杂但强大。

Snowflake 是 Twitter 开源的分布式 ID 生成算法。每台服务器独立生成 ID,不会冲突。

Snowflake ID 结构:41 位时间戳 + 5 位数据中心 ID + 5 位机器 ID + 12 位序列号。

但 Snowflake 实现复杂:需要处理时钟回拨,需要管理机器 ID 分配,需要处理序列号溢出。

我看了 Snowflake 的源码,发现有一个致命问题:时钟回拨。如果服务器时间突然回退,生成的 ID 可能和之前的重复。

最终选择

我选择了 Snowflake。

理由是性能和可靠性。

Redis INCR 虽然简单,但每次生成 ID 都要访问 Redis。当 ID 生成量大时,Redis 会成为瓶颈。

Snowflake 在本地生成 ID,几乎没有延迟。性能是 Redis INCR 的几十倍。

而且 Snowflake 不依赖外部服务,可靠性更高。即使 Redis 挂了,ID 生成也不受影响。

import time

class SnowflakeIDGenerator:
    def __init__(self, worker_id):
        self.worker_id = worker_id
        self.sequence = 0
        self.last_timestamp = -1
    
    def generate_id(self):
        timestamp = int(time.time() * 1000)
        if timestamp == self.last_timestamp:
            self.sequence = (self.sequence + 1) & 4095
            if self.sequence == 0:
                while timestamp <= self.last_timestamp:
                    timestamp = int(time.time() * 1000)
        else:
            self.sequence = 0
        self.last_timestamp = timestamp
        id = (timestamp << 22) | (self.worker_id << 12) | self.sequence
        return id

现在看来

这个决定是正确的。

Snowflake 的性能确实出色。单台服务器每秒可以生成 400 万 ID,完全满足需求。

但 Snowflake 也不是完美的。我们遇到过时钟回拨问题:服务器时间突然回退,导致生成的 ID 重复。

那是个凌晨,监控系统报警:ID 重复。我发现 NTP 服务把时间校准了,导致回拨了几毫秒。

我紧急修复,加了时钟回拨检测。后来我们把 NTP 服务配置得更保守,问题才解决。

如果重来一次,我可能会先用 Redis INCR 过渡,等 ID 生成成为瓶颈再换 Snowflake。

核心权衡:实现简单 vs 生成性能


决策七:自建审核 vs 第三方——成本 vs 效果

当时的情况

有用户举报了:有人用我们的短链接传播不良内容。

我们立刻下架了相关链接,但问题已经造成。媒体开始报道,用户开始质疑我们的审核能力。

我的纠结

自建审核系统吧,可控。

自建审核系统可以完全掌控:审核规则自己定、审核流程自己设计、审核结果自己决定。

但自建审核系统成本高:

  • 需要开发审核接口
  • 需要训练识别模型
  • 需要人工审核团队

我估算了一下,自建审核系统至少需要 2 个开发人员开发 1 个月、1 个算法工程师训练模型、3 个审核人员 7x24 小时轮班。每月成本至少 10 万。

用第三方审核吧,省心。

第三方审核服务成熟,效果好。但代价是按调用量收费、数据要传给第三方、依赖外部服务。

我调研了几家第三方服务商:

  • 阿里云内容安全:0.5 元/千次
  • 腾讯云内容安全:0.4 元/千次
  • 百度内容安全:0.3 元/千次

我们每天创建 10 万个短链接,每月就是 300 万次。按 0.3 元/千次计算,每月 900 块。

这个数字还能接受。

但问题是:第三方审核的准确率如何?误杀率如何?

我测试了几家,发现效果参差不齐。有些明显的不良内容没识别出来,有些正常内容被误杀。

最终选择

我选择了混合方案:第三方初审 + 人工复审。

具体来说:

  • 所有新创建的短链接,先过第三方审核
  • 第三方判断可疑的,转人工复审
  • 用户举报的,优先处理

这个方案平衡了成本和效果。

我接入了阿里云内容安全,同时建了一个审核后台,供人工审核使用。

现在看来

这个决定是合理的。

第三方审核拦截了 90% 的不良内容,人工复审处理剩下的 10%。效果不错,成本也可控。

但我也遇到了问题:第三方服务偶尔会挂。有一次,阿里云内容安全接口超时,导致大量短链接创建失败。

我紧急加了降级策略:第三方服务不可用时,先放行,后续异步审核。

这个策略有风险,但没办法。业务不能停。

如果重来一次,我可能会更早建立用户举报机制。很多不良内容都是用户先发现的,审核系统反而滞后。

核心权衡:控制力 vs 成本效率


决策八:免费 vs 付费——用户增长 vs 收入

当时的情况

系统运行一年后,有了一定的用户基础。但收入几乎为零。

服务器成本每月几千块,全靠我自己贴钱。

我的纠结

继续免费吧,用户增长。

免费策略可以快速获客,建立品牌。等用户量大了,再考虑变现。

但免费有代价:服务器成本持续增加、没有收入难以持续、用户质量参差不齐。

开始收费吧,有收入。

收费可以覆盖成本,甚至盈利。但代价是:用户增长放缓、部分用户流失、需要完善的付费功能。

我纠结了很久。

免费用户说:“这么好的服务,为什么收费?”

付费潜力用户说:“如果收费,有什么高级功能?”

我夹在中间,左右为难。

我调研了竞品:

  • bit.ly:免费版有限制,付费版功能多
  • tinyurl:完全免费,靠广告
  • 短链服务 A:免费 + 付费混合

看起来,混合模式是主流。

最终选择

我选择了分级收费模式:

  • 免费版:基础功能,有限制
  • 个人版:每月 9.9 元,更多功能
  • 团队版:每月 99 元,协作功能
  • 企业版:定制价格,专属服务
功能免费版个人版团队版企业版
短链接数量100 个1000 个10000 个无限
点击统计7 天90 天1 年永久
自定义短码10 个/月100 个/月无限
API 访问

这个模式兼顾了用户增长和收入。

现在看来

这个决定是正确的。

收费后,收入覆盖了成本,还有盈余。用户也没有大量流失,因为免费版依然可用。

但我也遇到了问题:付费转化率低。100 个免费用户里,只有 1-2 个会付费。

我尝试了很多方法提高转化率:免费试用付费功能、推送个性化优惠、优化付费页面。效果有一些,但不明显。

如果重来一次,我可能会更早开始收费。免费用户养成习惯后,更难转化。

但当时的我,没有这个底气。用户量不够,收费怕吓跑人。

核心权衡:用户规模 vs 商业可持续性


决策九:MySQL 分区 vs 分库分表——何时拆分

当时的情况

第二年结束,数据量达到了 5 亿条。

单表查询开始变慢,即使有索引,某些查询也要几秒钟。

我的纠结

用 MySQL 分区吧,简单。

MySQL 分区是在数据库层面做的,应用层感知不到。实现简单,迁移成本低。

但 MySQL 分区有局限:单实例瓶颈还在、分区数量有限、运维复杂。

用分库分表吧,彻底。

分库分表可以水平扩展,理论上没有上限。但代价是:应用层要改代码、需要中间件支持、迁移成本高。

我调研了分库分表中间件:

  • ShardingSphere:功能强大,学习曲线陡
  • MyCAT:轻量级,功能有限
  • 自研:灵活,但开发成本高

我估算了一下,分库分表的迁移成本:开发时间 2-3 个月、测试时间 1 个月、迁移时间 1 周(可能需要停机)。

这个成本太高了。

但如果不拆分,系统撑不了多久。

最终选择

我选择了 MySQL 分区过渡,准备未来分库分表。

具体来说:

  • 按创建时间分区,每月一个分区
  • 历史数据归档到冷存储
  • 预留分库分表接口

分区后,查询性能提升明显。但我知道,这只是暂时的。

现在看来

这个决定是务实的。

分区解决了眼前的问题,为分库分表争取了时间。

但分区也有问题:跨分区查询慢,运维复杂。

我们计划在第三年进行分库分表改造。按短码首字母分片,6 个分片,每个分片独立数据库。

如果重来一次,我可能会更早规划分库分表。但当时的数据量,确实没必要。

核心权衡:短期解决 vs 长期规划


决策十:自运维 vs 云服务——控制权 vs 效率

当时的情况

第三年,团队扩大了。有专职运维,有开发,有产品。

但运维工作依然繁重:服务器监控、故障处理、备份恢复……

我的纠结

继续自运维吧,控制力强。

自运维可以完全掌控:配置自己定、优化自己做、问题自己排查。

但自运维成本高:需要专职运维人员、故障要自己处理、精力分散。

用云服务吧,效率高。

云服务省心:自动扩缩容、自动备份、专业运维团队。

但云服务有代价:成本可能更高、厂商锁定、控制权减弱。

我算了一笔账:

自运维成本:运维人员工资每月 3 万 + 服务器成本每月 1 万 = 每月 4 万。

云服务成本:云数据库每月 2 万 + 云缓存每月 5 千 + 云服务器每月 1 万 = 每月 3.5 万。

云服务反而便宜?

最终选择

我选择了混合模式:核心自运维,非核心云服务。

具体来说:

  • 应用服务器:云服务(弹性伸缩)
  • 数据库:自运维(核心数据)
  • 缓存:云服务(Redis 云)
  • 存储:云服务(对象存储)

这个模式平衡了控制权和效率。

现在看来

这个决定是合理的。

云服务确实省心很多。应用服务器自动扩缩容,大促时不用手动加机器。

但核心数据库还是自运维,因为数据太重要,不敢完全交给云。

如果重来一次,我可能会更早拥抱云服务。自运维的隐性成本很高,不仅仅是钱。

核心权衡:控制权 vs 运维效率


总结

没有一个决策是完美的,每个都有取舍。

系统设计的本质就是在约束条件下做最优选择。

这 10 个决策,塑造了今天的系统。它们不是最好的选择,但是当时我能做出的最好选择。

回头看,有些决定现在看是错的。但站在当时的角度,那已经是我能想到的最优解。

这或许就是工程的魅力:没有标准答案,只有权衡和取舍。

如果你也在做系统设计,记住:

  1. 不要追求完美:完美是优秀的敌人
  2. 数据驱动决策:用数据说话,不是直觉
  3. 预留扩展空间:今天的简单,可能是明天的负担
  4. 接受不完美:每个选择都有代价

愿你做出的每个决策,都能在回头看时,少一点遗憾。


下一节

在最后一节《设计原则》中,我们将总结从实战中提炼的系统设计核心原则。

👉 继续阅读:设计原则