从单机到分布式:一个AI应用三年的架构演进史

本文记录一个AI写作辅助产品从Day 1到日活10万的架构演进过程。三次大重构,每次都是被业务逼着走的。不是最佳实践,是真实经历。

阶段一:单体应用(0-1000用户)

架构

最初就是一台服务器跑所有东西:

复制代码
用户 → Nginx → FastAPI(单进程) → OpenAI API
                    │
                    └─ SQLite(存对话记录)

代码结构也很简单:

python

复制代码
# app.py - 一个文件搞定
from fastapi import FastAPI
from openai import OpenAI

app = FastAPI()
client = OpenAI(api_key="your-key")

@app.post("/chat")
async def chat(prompt: str):
    response = await client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}]
    )
    return {"text": response.choices[0].message.content}

这个阶段的特点

  • 没有缓存:每次请求都调API,相同问题也重新生成
  • 没有队列:请求同步处理,用户等着
  • 没有监控:出问题靠用户投诉
  • 数据库用SQLite:单文件,够用

暴露的问题

用户量到500左右时,开始出现两个问题:

问题1:API成本失控

很多用户问的是相似问题("帮我润色这段话""改写得更正式"),每次都调GPT-4o,月成本8000元。

问题2:高峰期响应慢

晚上8-10点是高峰,请求堆积,单进程处理不过来,响应时间从2秒飙到15秒。

阶段二:加缓存和队列(1000-1万用户)

架构变更

复制代码
用户 → Nginx → FastAPI(多worker) → Redis缓存 → 命中则直接返回
                    │                        ↓ 未命中
                    ├─ Celery队列 → OpenAI API
                    │
                    └─ PostgreSQL(换掉SQLite)

三个关键改动:

改动1:语义缓存

不是简单的key-value缓存,而是语义缓存------意思相近的请求复用结果:

python

复制代码
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity

class SemanticCache:
    """基于向量相似度的缓存"""
    
    def __init__(self, redis_client, embedder):
        self.redis = redis_client
        self.embedder = embedder  # 用轻量模型做embedding
    
    async def get(self, prompt: str, threshold=0.92):
        """查找语义相似的缓存"""
        query_vec = await self.embedder.embed(prompt)
        
        # 从redis拿所有缓存的向量
        keys = self.redis.keys("cache:*")
        for key in keys:
            cached = self.redis.hgetall(key)
            cached_vec = np.frombuffer(cached["vector"], dtype=np.float32)
            
            similarity = cosine_similarity([query_vec], [cached_vec])[0][0]
            if similarity > threshold:
                return cached["response"]  # 命中
        
        return None
    
    async def set(self, prompt: str, response: str):
        """写入缓存"""
        vec = await self.embedder.embed(prompt)
        key = f"cache:{hash(prompt)}"
        self.redis.hset(key, mapping={
            "prompt": prompt,
            "response": response,
            "vector": vec.tobytes(),
            "created_at": time.time()
        })
        self.redis.expire(key, 3600)  # 1小时过期

效果:缓存命中率35%,API成本降了30%。

改动2:异步队列

长文本生成走队列,用户先拿到任务ID,完成后回调通知:

python

复制代码
from celery import Celery

celery = Celery("tasks", broker="redis://localhost:6379")

@app.post("/chat/async")
async def chat_async(prompt: str):
    # 先查缓存
    cached = await cache.get(prompt)
    if cached:
        return {"status": "done", "text": cached}
    
    # 进队列
    task = celery.send_task("generate", args=[prompt])
    return {"status": "processing", "task_id": task.id}

@celery.task
def generate(prompt):
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}]
    )
    text = response.choices[0].message.content
    
    # 写缓存
    cache.set(prompt, text)
    
    return text

效果:高峰期不再堆积,用户体验从"等15秒"变成"立即返回processing,30秒后拿结果"。

改动3:PostgreSQL + 连接池

SQLite扛不住并发写。换PostgreSQL,加连接池:

python

复制代码
from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    "postgresql+asyncpg://user:pass@localhost/db",
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True
)

这个阶段踩的坑

坑:Celery worker内存泄漏

Celery默认不复用连接,每个任务都新建OpenAI客户端,长时间运行后内存暴涨。解决方案:

python

复制代码
# celery配置:每个worker处理100个任务后重启
celery.conf.worker_max_tasks_per_child = 100

阶段三:微服务化(1万-10万用户)

架构变更

复制代码
                    ┌─ API网关(认证/限流/路由)
                    │
用户 → Nginx ───────┤
                    ├─ 写作服务(核心对话)
                    ├─ 摘要服务(长文本摘要)
                    ├─ 审核服务(内容安全)
                    ├─ 计费服务(用量统计)
                    └─ 监控服务(指标采集)
                          │
                          ├─ Redis集群
                          ├─ PostgreSQL主从
                          └─ 消息队列(Kafka)

为什么拆服务

单体应用在1万用户时遇到了三个瓶颈:

  1. 部署耦合:改一个功能要重启整个应用,影响所有用户
  2. 资源竞争:文本生成是CPU密集型,审核是IO密集型,混在一起互相影响
  3. 团队协作:3个开发改同一个代码库,冲突频繁

拆分原则

不是按"技术层"拆(前端/后端/数据库),而是按业务能力拆:

服务 职责 技术栈
写作服务 核心对话生成 Python + FastAPI
摘要服务 长文本压缩 Python + Celery
审核服务 内容安全检查 Go(高性能)
计费服务 Token统计+账单 Java + Spring

服务间通信

同步调用(gRPC):写作服务调用审核服务,需要实时结果

python

复制代码
# 写作服务 → 审核服务
import grpc

async def check_content(text):
    async with grpc.aio.insecure_channel('review:50051') as channel:
        stub = ReviewStub(channel)
        result = await stub.Check(ContentRequest(text=text))
        return result.safe

异步消息(Kafka):写作服务发事件给计费服务,不需要等待

python

复制代码
# 写作服务发事件
from kafka import KafkaProducer

producer = KafkaProducer(bootstrap_servers='kafka:9092')

def emit_usage_event(user_id, model, tokens):
    producer.send('usage_events', json.dumps({
        "user_id": user_id,
        "model": model,
        "tokens": tokens,
        "timestamp": time.time()
    }).encode())

API网关设计

网关承担认证、限流、路由三个职责:

python

复制代码
class APIGateway:
    """统一网关入口"""
    
    def __init__(self):
        self.routes = {
            "/chat": "writing-service:8001",
            "/summary": "summary-service:8002",
            "/usage": "billing-service:8003",
        }
        self.rate_limiter = SlidingWindowLimiter(
            window=60, max_requests=30
        )
    
    async def handle(self, request):
        # 1. 认证
        token = request.headers.get("Authorization")
        user = await self.authenticate(token)
        if not user:
            return Response(401, "未授权")
        
        # 2. 限流
        if not self.rate_limiter.allow(user.id):
            return Response(429, "请求过于频繁")
        
        # 3. 路由
        service = self.routes.get(request.path)
        if not service:
            return Response(404, "路径不存在")
        
        # 4. 转发
        response = await self.forward(service, request, user)
        
        # 5. 记录
        self.log_request(user, request, response)
        
        return response

这个阶段踩的坑

坑1:分布式事务

用户调用写作服务扣了Token,但计费服务没收到事件(Kafka消费失败),导致账目不平。

解决方案:最终一致性 + 对账机制。每天凌晨跑对账任务,比对写作服务日志和计费服务记录,修复差异。

坑2:服务雪崩

审核服务挂了,写作服务等待超时,线程池耗尽,整个系统瘫痪。

解决方案:熔断+降级。审核服务超时时,降级为关键词过滤:

python

复制代码
async def safe_generate(text):
    try:
        # 正常流程:调用审核服务
        safe = await asyncio.wait_for(
            review_service.check(text),
            timeout=2
        )
        if not safe:
            return "内容不合规"
    except asyncio.TimeoutError:
        # 降级:用本地关键词过滤
        if contains_unsafe_keywords(text):
            return "内容不合规"
    
    # 审核通过,继续生成
    return await writing_service.generate(text)

坑3:日志分散

每个服务有自己的日志,排查问题时要翻5个服务的日志。

解决方案:统一链路追踪。每个请求分配trace_id,所有服务日志带这个ID:

python

复制代码
import uuid
from contextvars import ContextVar

trace_id_var: ContextVar[str] = ContextVar("trace_id")

@app.middleware("http")
async def add_trace_id(request, call_next):
    trace_id = request.headers.get("X-Trace-Id") or str(uuid.uuid4())
    trace_id_var.set(trace_id)
    response = await call_next(request)
    response.headers["X-Trace-Id"] = trace_id
    return response

# 日志格式统一带trace_id
logging.basicConfig(format='%(asctime)s [%(trace_id)s] %(message)s')

阶段四:当前的架构(10万+用户)

完整架构

复制代码
                         ┌─ CDN(静态资源)
                         │
用户 → 负载均衡 ─────────┤
                         ├─ API网关集群(3节点)
                         │
                    ┌────┴────┐
                    │         │
              写作服务集群  摘要服务集群
              (5节点)       (3节点)
                    │
              ┌─────┼─────┐
              │     │     │
           Redis  PG主从  Kafka
           集群   (1主2从) (3节点)
                    │
              ┌─────┴─────┐
              │           │
           审核服务    计费服务
           (Go,2节点)  (Java,2节点)
                    │
              ┌─────┴─────┐
              │           │
           监控服务    对账服务
           (定时任务)  (每日凌晨)

关键设计决策

1. 写作服务用多模型路由

不同质量要求的请求走不同模型:

python

复制代码
def select_model(user_tier, prompt):
    """按用户等级和请求复杂度选模型"""
    if user_tier == "free":
        return "gpt-4o-mini"
    if user_tier == "pro":
        if len(prompt) > 2000:
            return "gpt-4o"      # 长文本用强模型
        return "gpt-4o-mini"     # 短文本用便宜模型
    return "gpt-4o"              # 企业版默认强模型

2. 预生成+队列消费

对高频场景(如"润色"),预生成一批结果放队列,用户请求时直接消费:

python

复制代码
# 后台预生成任务
@celery.task
def pre_generate_common_requests():
    common_prompts = get_trending_prompts()  # 从日志挖高频请求
    for prompt in common_prompts:
        result = generate(prompt)
        cache.set(prompt, result, ttl=3600)

3. 多级缓存

复制代码
L1: 进程内缓存(LRU,100条)→ 命中延迟0ms
L2: Redis集群 → 命中延迟1ms
L3: 语义缓存 → 命中延迟5ms
L4: API调用 → 延迟500-2000ms

python

复制代码
class MultiLevelCache:
    async def get(self, prompt):
        # L1
        if prompt in self.l1_cache:
            return self.l1_cache[prompt]
        
        # L2
        result = await self.redis.get(prompt)
        if result:
            self.l1_cache[prompt] = result  # 回填L1
            return result
        
        # L3 语义缓存
        result = await self.semantic_cache.get(prompt)
        if result:
            await self.redis.set(prompt, result)  # 回填L2
            self.l1_cache[prompt] = result        # 回填L1
            return result
        
        return None  # 未命中,走API

架构演进的核心教训

教训1:不要过早优化

阶段一的SQLite和单进程,在1000用户以内完全够用。过早引入Redis、Celery、微服务,只会增加运维负担,拖慢业务迭代。

教训2:监控先行

每次架构升级前,先上监控。没有监控的架构升级是盲飞------你不知道新架构到底比旧的好不好。

python

复制代码
# 最小监控集
metrics = {
    "request_count": Counter("请求总数"),
    "request_latency": Histogram("请求延迟"),
    "error_count": Counter("错误数"),
    "cache_hit_rate": Gauge("缓存命中率"),
    "active_users": Gauge("活跃用户数"),
}

教训3:拆服务要按业务能力

不要按技术层拆("把数据库操作拆出去"),要按业务能力拆("写作"和"审核"是两个独立能力)。技术层拆分只会制造耦合,业务能力拆分才能独立部署。

教训4:降级比高可用更重要

追求100%可用性成本极高。更实际的做法是:每个核心功能都有降级方案。审核服务挂了用关键词过滤,缓存挂了直连API,队列挂了同步处理。用户能接受偶尔变慢,不能接受完全不可用。

总结

三年三次大重构,每次都是被业务增长逼着走的。架构没有好坏,只有合不合适。1000用户时的"烂架构"可能比10万用户时的"好架构"更合理------因为前者能快速迭代,后者虽然稳定但迭代慢。

选架构的唯一标准:当前阶段最简单的能扛住的方案