聊聊我们搜索系统从单体到云原生的踩坑血泪史

杨磊_工程师
2026-07-11 05:57
阅读 327

作者:百度某搜索算法工程师 | 坐标上海 | 正在被Rust折磨中

周五晚上十一点半,我戴着耳机听着落日飞车的《My Jinji》,盯着屏幕上Augment Code帮我自动补全的一段Rust代码,突然觉得有点恍惚。来百度做搜索算法两年了,从刚毕业时连Docker都不会用的愣头青,到现在天天跟K8s、gRPC、消息队列打交道,这中间的酸甜苦辣,真的只有经历过的人才懂。

今天不想聊算法,想跟大家唠唠我们搜索后端架构这两年是怎么一步步从单体演进到云原生的。别急,泡杯咖啡,慢慢看。


那个让我半夜被叫醒的单体应用

故事得从2022年冬天说起。

那时候我刚入职半年,我们搜索团队的核心后端服务还是一个巨无霸式的单体应用——一个Python写的Flask项目,代码量大概有十几万行。你别笑,搜索业务嘛,涉及query理解、召回、排序、摘要生成、结果拼装,全塞在一个服务里,当时觉得"也挺好维护的"。

确实,在项目初期,单体架构有它的好处:部署简单,一个python app.py就完事了;调试方便,所有代码在一个进程里,打个断点从头跟到尾;本地开发也快,不用起一堆微服务。

但是,随着业务量上来,问题就一个接一个地冒出来了。

第一次线上事故:OOM教我做人

2022年12月的一个凌晨三点,我被oncall电话叫醒。电话那头运维大哥的声音很疲惫:"兄弟,搜索服务又挂了,OOM了,你赶紧看看。"

我迷迷糊糊打开电脑,连上跳板机,看了一眼监控:内存使用率飙到了98%,然后进程被Linux的OOM Killer干掉了。

排查了一圈,发现是一个新上的"智能摘要"功能,用了个比较大的NLP模型做推理,每次请求都会在内存里加载模型权重。平时QPS低的时候还好,一旦流量上来,多个请求并发加载模型,内存直接爆了。

# 当时的代码大概长这样,现在看简直想抽自己
@app.route('/api/summary', methods=['POST'])
def generate_summary():
    query = request.json.get('query')
    # 每次请求都重新加载模型,内存直接起飞
    model = load_nlp_model('summary_model_v2')
    result = model.predict(query)
    return jsonify({'summary': result})

当时的解决方案也很粗暴——把模型改成全局单例,服务启动时加载一次:

# 改了一版,好了一点,但治标不治本
summary_model = None

def get_model():
    global summary_model
    if summary_model is None:
        summary_model = load_nlp_model('summary_model_v2')
    return summary_model

@app.route('/api/summary', methods=['POST'])
def generate_summary():
    query = request.json.get('query')
    model = get_model()
    result = model.predict(query)
    return jsonify({'summary': result})

问题是解决了,但这件事让我开始认真思考:我们的架构是不是该改改了?

单体架构的痛,懂的都懂

那次事故之后,我们团队做了一次复盘,把单体架构的问题列了个清单:

问题 具体表现 影响
资源浪费 排序模块需要GPU,但召回模块只需要CPU,部署在一起导致资源利用率低 每月服务器成本多花十几万
发布风险 改了一行摘要生成的代码,需要整个服务重新发布 一个月不敢发版,需求堆积
扩展性差 大促期间召回模块扛不住,但没法单独扩容 只能整体扩容,成本翻倍
技术栈锁定 全用Python写,但有些模块其实用Go或Rust更合适 性能瓶颈无法突破
故障隔离差 一个模块的bug可能导致整个服务挂掉 凌晨三点的oncall电话

说实话,这些问题不是一天两天了,只是之前业务量小的时候还能忍。现在QPS从几千涨到了几万,再不重构,迟早要出大事。


微服务拆分:理想很丰满,现实很骨感

2023年初,领导拍板:架构升级,拆微服务。

说干就干。我们先把整个搜索系统按业务域拆成了几个核心服务:

  • Query理解服务:负责分词、意图识别、query改写
  • 召回服务:倒排索引检索、向量召回
  • 排序服务:粗排、精排、重排
  • 摘要服务:结果摘要生成
  • 聚合网关:结果拼装、格式化输出

技术栈也做了统一规划:网关层用Go(高并发场景Go确实香),核心算法服务继续用Python(毕竟我们的模型和工具链都是Python生态),数据密集型的服务考虑用Java。

拆分过程中的坑

拆分本身不难,难的是拆分之后带来的一堆新问题。

第一个坑:服务间通信

一开始我们用了RESTful API做服务间调用,毕竟Python写起来最方便。但很快就发现,搜索链路对延迟太敏感了,一个搜索请求要经过五六个服务,每次HTTP调用的序列化和反序列化开销加起来,延迟直接多了几十毫秒。

后来换成了gRPC,用Protocol Buffers做序列化,延迟降下来不少。这里分享一个我们用的proto定义:

syntax = "proto3";

package search.recall;

service RecallService {
    rpc BatchRecall(RecallRequest) returns (RecallResponse);
}

message RecallRequest {
    string query = 1;
    int32 top_k = 2;
    map<string, string> filters = 3;
    repeated string recall_sources = 4; // 支持多路召回
}

message RecallResponse {
    repeated DocItem items = 1;
    int64 cost_time_us = 2; // 方便做链路追踪
    string debug_info = 3;
}

message DocItem {
    string doc_id = 1;
    float score = 2;
    string source = 3;
    map<string, string> extra = 4;
}

第二个坑:分布式事务

这个真的是血泪教训。有一次我们发现搜索结果里出现了"幽灵数据"——用户搜一个query,明明召回了结果,但排序服务返回空。排查了半天,发现是召回服务和排序服务之间的数据不一致。召回服务把结果写到了Redis缓存里,但排序服务读的时候,缓存刚好过期了。

最后我们的解决方案是:搜索链路不走分布式事务(太重了),而是通过链路追踪+降级策略来保证最终一致性。每个请求带一个trace_id,全链路打日志,出了问题能快速定位。同时关键数据做本地缓存+远程缓存的双层保障。

# 我们封装的一个带链路追踪的Redis客户端
import redis
import time
import uuid
from contextlib import contextmanager

class TracedRedisClient:
    def __init__(self, host, port, db=0):
        self.client = redis.Redis(host=host, port=port, db=db)
    
    @contextmanager
    def traced_operation(self, operation_name):
        trace_id = str(uuid.uuid4())[:8]
        start = time.time()
        try:
            yield self.client
        finally:
            cost = (time.time() - start) * 1000
            # 上报到我们的监控系统
            report_metric(f'redis.{operation_name}.cost', cost)
            if cost > 50:  # 超过50ms的慢查询要告警
                logger.warning(f"Slow redis operation: {operation_name}, cost: {cost}ms, trace: {trace_id}")

第三个坑:服务治理

服务拆多了之后,服务发现、负载均衡、熔断降级、限流这些都得搞。我们当时选了Consul做服务发现,Envoy做Sidecar代理。说实话,这套组合配置起来真的头大,光是Envoy的yaml配置就写了好几百行。

后来上了Istio,用Service Mesh的思路来管理服务间通信,才算把这块理顺了。但这是后话了。


容器化:从"在我机器上能跑"到"在哪都能跑"

微服务拆完之后,下一个问题就是部署。

以前单体应用的时候,运维大哥在一台物理机上部署一个Python进程,配个Nginx反代,完事。现在拆成了十几个服务,每个服务还要多实例部署做高可用,手动部署根本不现实。

Docker初体验

2023年中期,我们开始全面Docker化。说实话,刚开始大家都不太习惯,尤其是我们算法团队的同学,以前pip install一把梭,现在要写Dockerfile,要关心基础镜像大小,要处理依赖冲突。

这里分享一个我们Python服务的Dockerfile,踩了不少坑才优化成这样:

# 多阶段构建,减小最终镜像大小
FROM python:3.10-slim as builder

WORKDIR /app

# 先复制依赖文件,利用Docker缓存层
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# 最终运行镜像
FROM python:3.10-slim

WORKDIR /app

# 创建非root用户,安全最佳实践
RUN useradd -m -r appuser && chown -R appuser:appuser /app
USER appuser

# 只复制安装好的依赖,不复制编译工具
COPY --from=builder /install /usr/local
COPY --chown=appuser:appuser . .

# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
    CMD python -c "import requests; requests.get('http://localhost:8080/health')"

EXPOSE 8080

# 用gunicorn跑,别直接用flask run
CMD ["gunicorn", "--config", "gunicorn.conf.py", "app:create_app()"]

有个小插曲:当时有个同事写的Dockerfile,基础镜像用的python:3.10(不带slim),打出来的镜像2.3GB。部署的时候拉镜像慢得要死,发布一次要等十几分钟。后来改成多阶段构建+slim基础镜像,镜像大小压到了300MB左右,发布速度快了好几倍。

Kubernetes:真正的云原生

Docker解决了"环境一致性"的问题,但服务编排还得靠Kubernetes。

2023年下半年,我们全面迁移到了K8s。说实话,K8s的学习曲线是真的陡。什么Pod、Deployment、Service、Ingress、ConfigMap、Secret、HPA、PVC……概念多到爆炸。我记得刚开始学的时候,买了一本《Kubernetes in Action》,看了两个月才算入门。

但一旦上手了,是真的香。分享几个我们实际用到的配置:

# 排序服务的Deployment配置
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ranking-service
  labels:
    app: ranking
    version: v2
spec:
  replicas: 6
  strategy:
    rollingUpdate:
      maxSurge: 2
      maxUnavailable: 1
    type: RollingUpdate
  selector:
    matchLabels:
      app: ranking
  template:
    metadata:
      labels:
        app: ranking
    spec:
      containers:
      - name: ranking
        image: registry.baidu.com/search/ranking:v2.3.1
        resources:
          requests:
            cpu: "2"
            memory: "4Gi"
            nvidia.com/gpu: "1"  # 排序需要GPU
          limits:
            cpu: "4"
            memory: "8Gi"
            nvidia.com/gpu: "1"
        ports:
        - containerPort: 8080
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5
        env:
        - name: MODEL_VERSION
          valueFrom:
            configMapKeyRef:
              name: ranking-config
              key: model_version
        volumeMounts:
        - name: model-storage
          mountPath: /data/models
      volumes:
      - name: model-storage
        persistentVolumeClaim:
          claimName: model-pvc
---
# HPA自动扩缩容
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: ranking-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: ranking-service
  minReplicas: 4
  maxReplicas: 12
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Pods
    pods:
      metric:
        name: http_requests_per_second
      target:
        type: AverageValue
        averageValue: "1000"

去年双11的时候,HPA帮了大忙。流量高峰来的时候,排序服务自动从4个Pod扩到了10个,扛住了3倍的QPS增长。流量下去之后又自动缩回来,省了不少资源成本。当时看着监控面板上Pod数量自动变化的曲线,真的有一种"科技改变生活"的感动。


云原生进阶:可观测性与CI/CD

上了K8s之后,架构层面的事情基本搞定了,但云原生不只是容器编排,可观测性持续交付同样重要。

可观测性三板斧:日志、指标、链路追踪

微服务架构下,出了问题排查起来比单体复杂太多了。一个搜索请求可能经过七八个服务,光看单个服务的日志根本定位不了问题。

我们搭了一套完整的可观测性体系:

  • 日志:ELK(Elasticsearch + Logstash + Kibana),所有服务的日志统一收集,支持全文检索
  • 指标:Prometheus + Grafana,监控服务的各种指标(QPS、延迟、错误率、资源使用率等)
  • 链路追踪:Jaeger,每个请求带一个trace_id,可以在Jaeger里看到完整的调用链路

这里有个小技巧:我们在每个服务的中间件里自动注入trace_id,业务代码完全不用关心:

# 基于Flask的链路追踪中间件
import uuid
from flask import request, g
import requests as http_requests

class TracingMiddleware:
    def __init__(self, app, service_name):
        self.app = app
        self.service_name = service_name
    
    def __call__(self, environ, start_response):
        # 从上游请求中获取trace_id,没有就生成一个新的
        headers = dict(environ.items())
        trace_id = headers.get('HTTP_X_TRACE_ID', str(uuid.uuid4())[:16])
        
        # 存到Flask的g对象里,业务代码可以通过g.trace_id获取
        with self.app.request_context(environ):
            g.trace_id = trace_id
            g.service_name = self.service_name
            g.start_time = time.time()
        
        return self.app(environ, start_response)

# 在gRPC客户端调用时自动传递trace_id
class TracedGrpcStub:
    def __init__(self, channel, service_name):
        self.channel = channel
        self.service_name = service_name
    
    def _get_metadata(self):
        trace_id = getattr(g, 'trace_id', 'unknown')
        return [
            ('x-trace-id', trace_id),
            ('x-caller', self.service_name),
        ]
    
    def call(self, method, request, timeout=5):
        metadata = self._get_metadata()
        start = time.time()
        try:
            response = method(request, timeout=timeout, metadata=metadata)
            cost = (time.time() - start) * 1000
            report_metric(f'grpc.call.success', 1, tags={'method': method._method})
            return response
        except Exception as e:
            cost = (time.time() - start) * 1000
            report_metric(f'grpc.call.error', 1, tags={'method': method._method, 'error': type(e).__name__})
            raise

CI/CD:让发布不再痛苦

以前单体应用的时候,发布是个大工程。要挑流量低的时候(通常是凌晨),运维手动操作,一不小心还会搞出事故。

现在上了云原生,我们用GitLab CI + ArgoCD搭了一套GitOps的持续交付流程。代码合并到main分支后,自动触发CI流水线:

# .gitlab-ci.yml
stages:
  - lint
  - test
  - build
  - deploy

variables:
  DOCKER_REGISTRY: registry.baidu.com/search
  IMAGE_NAME: ranking-service

code_lint:
  stage: lint
  image: python:3.10-slim
  script:
    - pip install flake8 mypy
    - flake8 . --max-line-length=120
    - mypy --ignore-missing-imports .
  only:
    - merge_requests

unit_test:
  stage: test
  image: python:3.10-slim
  script:
    - pip install -r requirements-test.txt
    - pytest tests/ --cov=app --cov-report=xml
  coverage: '/TOTAL.*\s+(\d+\.\d+)%/'
  only:
    - merge_requests

build_image:
  stage: build
  image: docker:20.10
  services:
    - docker:20.10-dind
  script:
    - docker build -t $DOCKER_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA .
    - docker push $DOCKER_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA
    # 同时打一个latest tag用于开发环境
    - docker tag $DOCKER_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA $DOCKER_REGISTRY/$IMAGE_NAME:latest
    - docker push $DOCKER_REGISTRY/$IMAGE_NAME:latest
  only:
    - main

deploy_staging:
  stage: deploy
  image: bitnami/kubectl:latest
  script:
    - kubectl set image deployment/ranking-service ranking=$DOCKER_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA -n staging
    - kubectl rollout status deployment/ranking-service -n staging --timeout=300s
  environment:
    name: staging
  only:
    - main

deploy_production:
  stage: deploy
  image: bitnami/kubectl:latest
  script:
    - kubectl set image deployment/ranking-service ranking=$DOCKER_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA -n production
    - kubectl rollout status deployment/ranking-service -n production --timeout=600s
  environment:
    name: production
  when: manual  # 生产环境手动触发,安全第一
  only:
    - main

现在发布变成了日常操作,一天发好几次都不怕。灰度发布、蓝绿部署、自动回滚,这些以前想都不敢想的功能,现在都是标配。


一些反思和展望

写了这么多,回头看看,从单体到云原生的演进,其实不只是为了"技术先进",而是业务发展到一定阶段后的必然选择。

架构演进的几个原则

这两年的实践,我总结了几个架构演进的原则:

  1. 不要过度设计:刚开始拆微服务的时候,我们恨不得把每个函数都拆成一个服务。后来发现,太细粒度的拆分反而增加了系统复杂度。合理的粒度应该是"一个独立的业务域"。

  2. 渐进式演进:不要想着一步到位。我们是先拆最痛的模块(摘要服务),验证可行性之后再逐步拆其他模块。

  3. 可观测性先行:在拆服务之前,先把监控和日志体系搭好。不然拆完之后出了问题,你连怎么排查都不知道。

  4. 自动化一切:手动操作是事故的根源。部署、扩缩容、回滚,全部自动化。

最近在折腾的事

说来也巧,最近我在研究Rust,发现它在写高性能后端服务方面确实有优势。我们团队有个小规模的实验项目,用Rust重写了召回服务中的一个向量检索模块,性能比Python版本提升了将近10倍,内存占用降到了原来的1/5。

// 一个简单的向量检索示例,Rust写起来其实也没那么可怕
use std::collections::HashMap;

struct VectorIndex {
    dimension: usize,
    vectors: Vec<Vec<f32>>,
    ids: Vec<String>,
}

impl VectorIndex {
    fn new(dimension: usize) -> Self {
        Self {
            dimension,
            vectors: Vec::new(),
            ids: Vec::new(),
        }
    }

    fn add(&mut self, id: String, vector: Vec<f32>) -> Result<(), String> {
        if vector.len() != self.dimension {
            return Err(format!("Expected dimension {}, got {}", self.dimension, vector.len()));
        }
        self.vectors.push(vector);
        self.ids.push(id);
        Ok(())
    }

    // 暴力搜索,后续可以换成HNSW或IVF
    fn search(&self, query: &[f32], top_k: usize) -> Vec<(String, f32)> {
        let mut distances: Vec<(String, f32)> = self.ids.iter()
            .zip(self.vectors.iter())
            .map(|(id, vec)| {
                let dist = query.iter()
                    .zip(vec.iter())
                    .map(|(a, b)| (a - b).powi(2))
                    .sum::<f32>()
                    .sqrt();
                (id.clone(), dist)
            })
            .collect();
        
        distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
        distances.truncate(top_k);
        distances
    }
}

当然,Rust的学习曲线确实陡,编译器跟我斗智斗勇的日常都快成段子了。但不得不说,写完之后那种"这代码绝对不会出运行时错误"的安全感,是Python给不了的。

另外最近也在关注一些AI辅助编程工具,比如豆包在代码生成方面的能力确实有进步,有时候让它帮忙写个单元测试或者生成个proto文件,能省不少时间。不过复杂业务逻辑还是得自己来,AI目前还做不到完全理解业务上下文。


写在最后

从单体到云原生,这条路走了将近两年。中间踩过无数坑,加过无数班,也被oncall电话叫醒过无数次。但回头看,这些经历真的让我成长了很多。

架构没有银弹,单体有单体的好处,云原生也有云原生的代价。关键是要根据业务的实际情况,选择合适的架构,并且在演进的过程中保持敬畏之心——毕竟线上无小事。

好了,不说了,耳机里的歌放完了,Augment Code帮我把一段Rust代码的borrow checker问题修好了,我得赶紧跑一下测试看看能不能过。

希望这篇文章对正在做架构演进的同学有一些帮助。如果你也有类似的经历或者想法,欢迎在评论区交流。

我们下期见。


P.S. 如果你也是在上海做搜索的同行,有机会可以面基交流一下。我一般周末会在浦东这边的一家咖啡馆写代码,有时候也会去公司加班(租房离公司近的唯一好处就是加班方便,坏处也是加班方便……)

评论 0

最热最新
暂无评论
杨磊_工程师Lv.1
0
影响力
0
文章
0
粉丝