完整架构

开篇

三年了。

我翻出了第一天画的架构图——一个框。里面写着「Flask + SQLite」,旁边画了个箭头指向「用户」。就这么简单。

现在再看,已经是一个复杂的分布式系统了。

那时候的我,根本不知道什么是负载均衡,不知道缓存应该怎么设计,更不知道数据库分片是什么。我只是想做一个能用的短链接服务,让别人分享链接时不再那么丑陋。

三年,1095 天,从一台服务器到分布式架构。每一步,都是被逼出来的。


架构演进回顾

第 1 天:一切的开始

┌─────────────────────────────┐
│     单台服务器              │
│  ┌───────────────────────┐  │
│  │  Flask + SQLite       │  │
│  └───────────────────────┘  │
└─────────────┬───────────────┘


         ┌─────────┐
         │  用户   │
         └─────────┘

技术栈: Flask + SQLite,一台服务器(2 核 4G,¥200/月)

代码:

from flask import Flask, redirect
import sqlite3

app = Flask(__name__)
database = sqlite3.connect('shortener.db')

@app.route('/<short_code>')
def redirect_url(short_code):
    url = database.execute(
        'SELECT original_url FROM urls WHERE short_code = ?',
        (short_code,)
    ).fetchone()
    
    if url:
        database.execute(
            'INSERT INTO clicks (short_code, clicked_at) VALUES (?, ?)',
            (short_code, datetime.now())
        )
        database.commit()
        return redirect(url[0])
    return 'Not found', 404

问题: 数据库查询慢、点击统计同步影响性能、单点故障

数据: 日均点击 100 次,短链 50 条,响应时间 200ms


第 2 周:第一次优化

┌─────────────────────────────┐
│     单台服务器              │
│  ┌───────────────────────┐  │
│  │  Flask + Redis        │  │
│  │       ↓               │  │
│  │     SQLite            │  │
│  └───────────────────────┘  │
└─────────────┬───────────────┘


         ┌─────────┐
         │  用户   │
         └─────────┘

变化: 加了 Redis 缓存热点短链,缓存命中率 80%,响应时间从 200ms 降到 50ms

为什么加 Redis?

那天晚上,我收到第一封用户邮件:「你的服务好慢啊,等半天才能跳转。」

我查了日志,发现有几个短链被频繁访问。每次都要查数据库,确实慢。

于是我在凌晨两点装上了 Redis:

@app.route('/<short_code>')
def redirect_with_cache(short_code):
    cached_url = redis_client.get(f'url:{short_code}')
    if cached_url:
        return redirect(cached_url)
    
    url = database.execute(
        'SELECT original_url FROM urls WHERE short_code = ?',
        (short_code,)
    ).fetchone()
    
    if url:
        redis_client.setex(f'url:{short_code}', 3600, url[0])
        return redirect(url[0])
    return 'Not found', 404

代价: 多了一台 Redis 服务器(¥100/月),但响应时间降到了 50ms,值了。


第 2 个月:数据库迁移

┌───────────────────────────┐
│    应用服务器             │
│  Flask + Redis            │
└─────────┬─────────────────┘


┌───────────────────────────┐
│    MySQL 服务器           │
│  (urls + clicks 表)       │
└───────────────────────────┘

变化: SQLite → MySQL,第一次数据库迁移通宵完成

为什么要迁移?

第 60 天,我的服务有了第一个「大客户」——一个技术博客,每天能带来 5000 次点击。

那天下午,数据库锁死了。

SQLite 是文件锁,写操作会阻塞所有读操作。点击统计是写操作,重定向是读操作。结果就是:有人点击,其他人就等。

我在群里求助,有人告诉我:「上 MySQL 吧,SQLite 扛不住的。」

迁移过程:导出 SQLite → 转换格式 → 导入 MySQL → 修改代码 → 凌晨 3 点切换完成

代价: 又多了一台 MySQL 服务器(¥300/月),但再也不怕数据库锁死了。


第 6 个月:第一次扩容

              ┌─────────────┐
              │    用户     │
              └──────┬──────┘


          ┌──────────────────┐
          │  Nginx 负载均衡   │
          └────────┬─────────┘

     ┌─────────────┼─────────────┐
     │             │             │
     ▼             ▼             ▼
┌─────────┐  ┌─────────┐  ┌─────────┐
│服务器 1  │  │服务器 2  │  │服务器 3  │
│Flask+R  │  │Flask+R  │  │Flask+R  │
└────┬────┘  └────┬────┘  └────┬────┘
     └────────────┼────────────┘


          ┌───────────────┐
          │ MySQL 主从     │
          └───────────────┘

变化: 3 台应用服务器 + Nginx 负载均衡,MySQL 主从复制(一主两从)

为什么要扩容?

第 180 天,我的服务有了 10 万条短链,日均点击 10 万次。

那天晚上,服务器 CPU 飙到 100%。我收到告警短信时,正在吃晚饭。

赶回电脑前,发现一台服务器扛不住了。

「加机器吧。」我对自已说。

upstream backend {
    server 192.168.1.10:5000;
    server 192.168.1.11:5000;
    server 192.168.1.12:5000;
}

server {
    listen 80;
    location / {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

代价: 服务器从 2 台变成 5 台,月成本¥1000,但能扛住 10 万 QPS 了。


第 1 年:CDN + 分布式 ID

              ┌─────────────┐
              │    用户     │
              └──────┬──────┘


          ┌──────────────────┐
          │ CDN + 智能 DNS    │
          └────────┬─────────┘

     ┌─────────────┼─────────────┐
     │             │             │
     ▼             ▼             ▼
┌─────────┐  ┌─────────┐  ┌─────────┐
│ 北京节点 │  │ 上海节点 │  │ 广州节点 │
│ Nginx+  │  │ Nginx+  │  │ Nginx+  │
│ Flask   │  │ Flask   │  │ Flask   │
└────┬────┘  └────┬────┘  └────┬────┘
     └────────────┼────────────┘


          ┌───────────────┐
          │ Snowflake ID  │
          │  MySQL 分片    │
          └───────────────┘

变化: 接入 CDN,实现 Snowflake 分布式 ID 生成器,数据库开始分库分表

为什么需要分布式 ID?

第 365 天,我的服务有了 100 万条短链。

问题出现了:自增 ID 在多服务器环境下会冲突。服务器 A 生成 ID=1001,服务器 B 也生成 ID=1001。两条不同的短链,同一个 ID。数据乱了。

class Snowflake:
    def __init__(self, machine_id):
        self.machine_id = machine_id
        self.sequence = 0
        self.last_timestamp = -1
    
    def next_id(self):
        timestamp = int(time.time() * 1000)
        if timestamp == self.last_timestamp:
            self.sequence = (self.sequence + 1) & 0xFFF
        else:
            self.sequence = 0
        self.last_timestamp = timestamp
        
        return ((timestamp - 1288834974657) << 22) | \
               (self.machine_id << 12) | self.sequence

代价: CDN 费用¥500/月,但全国用户访问速度都快了。


第 3 年:完整的微服务架构

这就是现在的架构。

三年,1095 天,从一台服务器到分布式系统。每一步,都是被问题逼出来的。


最终架构详解

                            ┌─────────────┐
                            │    用户     │
                            └──────┬──────┘


                  ┌─────────────────────────────┐
                  │      CDN + 智能 DNS          │
                  │   (按地域返回最近的服务器)    │
                  └─────────────┬───────────────┘

          ┌─────────────────────┼─────────────────────┐
          │                     │                     │
          ▼                     ▼                     ▼
  ┌───────────────┐     ┌───────────────┐     ┌───────────────┐
  │   北京地域     │     │   上海地域     │     │   广州地域     │
  │ ┌───────────┐ │     │ ┌───────────┐ │     │ ┌───────────┐ │
  │ │  Nginx×2  │ │     │ │  Nginx×2  │ │     │ │  Nginx×2  │ │
  │ └─────┬─────┘ │     │ └─────┬─────┘ │     │ └─────┬─────┘ │
  │ ┌─────┴─────┐ │     │ ┌─────┴─────┐ │     │ ┌─────┴─────┐ │
  │ │ API 网关   │ │     │ │ API 网关   │ │     │ │ API 网关   │ │
  │ └─────┬─────┘ │     │ └─────┬─────┘ │     │ └─────┬─────┘ │
  │ ┌─────┴─────────────────────────────┴─────┐ │
  │ │      应用服务器集群                      │ │
  │ │  北京:4 台  上海:3 台  广州:3 台          │ │
  │ └───────────────────┬─────────────────────┘ │
  └─────────────────────┼───────────────────────┘

        ┌───────────────┼───────────────┐
        │               │               │
        ▼               ▼               ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Redis 集群   │ │MySQL 分片   │ │  Kafka      │
│ 2 主 2 从     │ │ 2 主 2 从×2  │ │  2 节点      │
└─────────────┘ └─────────────┘ └─────────────┘

        ┌───────────────┼───────────────┐
        │               │               │
        ▼               ▼               ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│   ELK       │ │ Prometheus  │ │  告警系统    │
│  日志收集    │ │ + Grafana   │ │   (钉钉)    │
└─────────────┘ └─────────────┘ └─────────────┘

核心组件详解

1. 负载均衡层

Nginx 集群(6 台,每地域 2 台)

upstream backend {
    server 192.168.1.10:5000 weight=3;
    server 192.168.1.11:5000 weight=2;
    server 192.168.1.12:5000 weight=1;
    check interval=3000 rise=2 fall=3 timeout=1000;
}

server {
    listen 443 ssl http2;
    ssl_certificate /etc/nginx/ssl/cert.pem;
    ssl_certificate_key /etc/nginx/ssl/key.pem;
    
    location / {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_connect_timeout 10ms;
        proxy_read_timeout 100ms;
    }
}

关键特性: 加权轮询、健康检查、HTTP/2 支持、SSL 终止


2. API 网关层

class APIGateway:
    def __init__(self):
        self.rate_limiter = RedisRateLimiter()
        self.authenticator = JWTAuthenticator()
        self.circuit_breaker = CircuitBreaker()
    
    async def handle_request(self, request):
        # 1. 限流检查
        if not self.rate_limiter.is_allowed(
            request.client_ip, limit=1000, window=1):
            return {'error': 'Rate limit exceeded'}, 429
        
        # 2. 鉴权(仅管理 API)
        if request.path.startswith('/api/'):
            if not self.authenticator.validate(request.token):
                return {'error': 'Unauthorized'}, 401
        
        # 3. 熔断检查
        if self.circuit_breaker.is_open('backend'):
            return {'error': 'Service unavailable'}, 503
        
        # 4. 路由到后端
        try:
            response = await self.proxy_to_backend(request)
            self.circuit_breaker.record_success('backend')
            return response
        except Exception as e:
            self.circuit_breaker.record_failure('backend')
            return {'error': 'Internal error'}, 500

限流策略: 全局(10 万 QPS)、用户级、IP 级三级限流


3. 应用服务层

重定向服务(核心路径优化)

class RedirectService:
    def __init__(self):
        self.local_cache = LRUCache(maxsize=10000)
        self.redis_client = RedisCluster(nodes=['redis1', 'redis2'])
    
    async def handle_redirect(self, short_code, request):
        # L1: 本地缓存(~1ms)
        url = self.local_cache.get(short_code)
        if url:
            await self._record_click_async(short_code, request)
            return RedirectResponse(url)
        
        # L2: Redis 缓存(~5ms)
        url = await self.redis_client.get(f'url:{short_code}')
        if url:
            self.local_cache.set(short_code, url)
            await self._record_click_async(short_code, request)
            return RedirectResponse(url)
        
        # L3: 数据库(~50ms)
        url = await self._get_from_database(short_code)
        if url:
            await self.redis_client.setex(f'url:{short_code}', 3600, url)
            self.local_cache.set(short_code, url)
            await self._record_click_async(short_code, request)
            return RedirectResponse(url)
        
        return NotFoundResponse()
    
    async def _record_click_async(self, short_code, request):
        click_event = {
            'short_code': short_code,
            'timestamp': int(time.time() * 1000),
            'ip': request.client_ip,
            'user_agent': request.headers.get('User-Agent'),
        }
        await self.kafka_producer.send('clicks', click_event)

性能优化对比:

优化阶段P50 延迟P99 延迟QPS
初始版本200ms1000ms1000
+Redis 缓存20ms100ms10000
+ 本地缓存5ms50ms50000
+ 异步处理2ms20ms100000

4. 缓存层

Redis 集群架构

class RedisClusterManager:
    def __init__(self):
        self.hot_cache = RedisCluster(
            nodes=[{'host': 'redis1', 'port': 6379},
                   {'host': 'redis2', 'port': 6379}],
            password=os.getenv('REDIS_PASSWORD'),
        )
    
    def get_url(self, short_code):
        url = self.hot_cache.get(f'url:{short_code}')
        if url:
            self.hot_cache.zincrby('url:access_count', 1, short_code)
            return url
        return None
    
    def warm_up_cache(self, top_n=10000):
        popular_codes = self.hot_cache.zrevrange(
            'url:access_count', 0, top_n - 1)
        pipe = self.hot_cache.pipeline()
        for code in popular_codes:
            pipe.get(f'url:{code}')
        return dict(zip(popular_codes, pipe.execute()))

缓存策略: L1 本地缓存(1 万条,5 分钟)、L2 Redis 缓存(100 万条,1 小时)、L3 数据库


5. 数据层

MySQL 分片集群

class ShardedDatabaseManager:
    def __init__(self):
        self.shards = {
            'a-m': MySQLConnection('shard1.example.com'),
            'n-z': MySQLConnection('shard2.example.com')
        }
    
    def get_shard(self, short_code):
        first_char = short_code[0].lower()
        if first_char in 'abcdefghijklm':
            return self.shards['a-m']
        return self.shards['n-z']
    
    def create_short_url(self, short_code, original_url):
        shard = self.get_shard(short_code)
        return shard.execute(
            'INSERT INTO urls (short_code, original_url, created_at) VALUES (?, ?, ?)',
            (short_code, original_url, datetime.now())
        )

分片策略: 按短码首字母分片(2 个分片),实现简单,适合短码随机分布场景


6. 消息队列

Kafka 异步处理

class ClickEventProcessor:
    def __init__(self):
        self.consumer = KafkaConsumer(
            'clicks',
            bootstrap_servers=['kafka1:9092', 'kafka2:9092'],
            group_id='click-processor'
        )
    
    def process_clicks(self):
        for message in self.consumer:
            event = json.loads(message.value)
            self.clickhouse.insert('clicks', event)
            redis_client.incr(f'click:{event["short_code"]}')
            self.elasticsearch.index('clicks', event)

数据与成本

三年后的数据

业务数据:

  • 短链总数:1000 万条
  • 日均点击:500 万次
  • 峰值 QPS:8000
  • 付费用户:500 个
  • 月收入:¥50000

性能数据:

  • P50 延迟:5ms
  • P99 延迟:50ms
  • 缓存命中率:95%
  • 可用性:99.9%

成本分析

服务器成本:

应用服务器(10 台):
  北京:4 台 × ¥400 = ¥1600
  上海:3 台 × ¥400 = ¥1200
  广州:3 台 × ¥400 = ¥1200
  小计:¥4000/月

Redis 集群(4 台):2 主 2 从 × ¥500 = ¥2000/月
MySQL 集群(4 台):2 主 2 从 × ¥600 = ¥2400/月
Kafka(2 台):2 节点 × ¥400 = ¥800/月
监控/日志(2 台):ELK + Prometheus × ¥400 = ¥800/月

服务器总成本:¥10000/月

其他成本:

带宽:3 个地域 × 50Mbps × ¥13/Mbps = ¥2000/月
CDN:500GB 流量 × ¥2/GB = ¥1000/月
域名和 SSL:¥100/月

其他总成本:¥3100/月

总成本:¥13100/月

利润:

月收入:¥50000
月成本:¥13100
月利润:¥36900
利润率:74%

终于盈利了。


尾声

回头看,每一个架构决策都是在压力下做出的。

  • 加 Redis,是因为用户抱怨慢
  • 迁 MySQL,是因为数据库锁死了
  • 上负载均衡,是因为 CPU 飙到 100%
  • 做分布式 ID,是因为 ID 冲突了
  • 接 CDN,是因为全国用户都说卡

但正是这些压力,逼出了最好的设计。

如果一开始就设计一个完美的分布式系统,我可能永远都不会开始。

先跑起来,再优化。遇到问题,解决问题。

这三年,我学到的最重要的事:架构不是一开始设计出来的,是一步步演进出来的。

现在,这个系统每天处理 500 万次点击,服务 500 个付费用户。它不完美,但它能用,它稳定,它在赚钱。

这就够了。