一、先聊聊容量规划到底是什么

很多刚接触云原生的朋友,一听到"容量规划"四个字就头大,觉得这是个特别高深的事。其实说白了,容量规划就是回答一个问题:我的系统现在这个规模,下一步需要加多少机器、加多少内存,才能让用户访问起来不卡、不报错,同时又不至于买一堆用不上的服务器浪费钱。

以前我们做单体应用的时候,一台机器扛所有,容量规划相对简单:看看CPU、内存、磁盘的使用率,差不多接近瓶颈了就开始扩容。但是到了云原生时代,服务被拆成几十个甚至上百个小模块,每个模块又有多个副本,从前端入口到数据库之间可能经过几十次调用。这个时候光看服务器的CPU和内存,根本说不清瓶颈到底在哪里。比如用户反馈接口变慢了,你去看所有服务器的负载都是正常的,那问题出在哪儿呢?这时候就需要一个能穿透整个调用链的工具,告诉你请求到底在哪个环节慢了下来,这就是Jaeger要干的事。

云原生系统的容量规划,本质是沿着"用户的请求路径"去找瓶颈,而不是孤立地看每一台机器的指标。如果把容量规划比作做饭,以前的做法是死盯着锅里的菜熟没熟,而用了Jaeger之后,你就能看到从洗菜、切菜、下锅到装盘的每一个环节各自花了多少时间,哪些环节挤在一起堵住了,一目了然。

二、为什么容量规划非要看链路数据

2.1 容量规划的出发点不是"机器",而是"请求"

云原生架构下,每一个用户请求都会在系统内部创建一条完整的调用链。假设一个"查询订单"的接口,前端打到网关,网关调订单服务,订单服务要同时查缓存、查数据库、调物流服务、调用户服务,最后把结果组装起来返回给前端。整个过程可能涉及到十几个服务实例。

如果用户量翻倍,哪些服务先扛不住?绝对不是所有服务一起挂,一定是最先到达瓶颈的那一个。可能是数据库的连接池满了,也可能是某个下游服务响应变慢导致上游线程被占满。只要是链路中的某个节点出问题,整个请求就会跟着变慢。这个时候,如果没有链路数据,你就只能看着整条链路的表现去瞎猜,而有了Jaeger,你可以准确地看到每次请求在每一个服务、每一个操作上分别花了多少毫秒,从而精准定位谁是"堵点"。

2.2 容量规划需要动态视角,不是静态快照

传统容量规划往往依赖监控报表,看的是某个时间段内的平均负载。但是云原生流量的特点是突发性强,比如促销活动、热点事件,流量可能在几分钟内翻十几倍。这时候靠平均值来规划容量完全不靠谱,因为平均值会掩盖峰值瞬间的服务阻塞。

Jaeger记录的是每一次请求的详细数据,包括时间戳、持续时长、调用关系。把一段时间内的所有调用数据汇总起来,你可以清晰地看到流量的潮汐规律——哪些时刻是高峰、高峰持续多久、高峰期间的延迟和错误率变化趋势如何。有了这些数据,容量规划就不只是"按峰值预留资源"这种粗放的做法,而是能做到"按业务节奏动态调整资源",流量高峰前提前扩容,低谷期自动缩容。

三、Jaeger能帮你看到哪些容量信号

在具体讲怎么操作之前,先弄清楚Jaeger能给我们提供哪些有用的信息。Jaeger的核心概念是链路(Trace)和跨度(Span)。一次完整的用户请求对应一条链路,链路中的每一个服务调用、每一个数据库查询、每一次消息发送,都是一个跨度。

第一,跨度的延迟分布。 每个跨度都记录了开始时间和结束时间,所以你能知道某一个服务处理请求的真实耗时。把同一种操作的大量跨度数据做统计分析,比如"订单服务查数据库"这个操作,过去一小时内所有请求的耗时分布,你可以得到P50、P90、P99这样的分位数。P99的意思是一百个请求里,九十九个请求的耗时都小于这个值。容量规划最关注的就是P99,因为尾部延迟决定了用户体验的底线。

第二,链路中的依赖关系。 Jaeger的依赖分析功能,可以自动统计服务之间的调用关系和调用频次。比如A服务调B服务每天多少次、每秒多少次,平均耗时多少。这能帮你算出当上游流量增长时,下游服务会额外承受多大的压力,从而为每一个下游服务预留合适的资源。

第三,错误率和超时信息。 每个跨度可以记录错误状态,通过按服务、按接口聚合错误率,你可以找出那些对资源消耗特别大、又容易失败的调用。这些调用往往会在系统压力升高时率先雪崩,容量规划时必须重点考虑它们的冗余度。

四、实施前的准备工作:选好技术栈和数据采集方案

这一节开始进入实际操作。我们全程使用Python作为技术栈做示例演示,因为Python写起来直白,看例子的时候不需要再花精力去理解语法。实际生产环境你完全可以用Java、Go、Node.js等任何主流语言,道理是一模一样的。

首先,你需要一个已经部署好的Jaeger后端。最简单的方式是用Docker在本机先跑起来:

  
  # 拉取镜像并启动Jaeger AllInOne
  docker run -d --name jaeger \
    -e COLLECTOR_ZIPKIN_HOST_PORT=:9411 \
    -p 6831:6831/udp \
    -p 16686:16686 \
    jaegertracing/all-in-one:latest

启动之后,在浏览器打开 http://localhost:16686 就能看到Jaeger自带的UI界面。生产环境中,你通常会把Jaeger部署在Kubernetes集群内部,接收所有服务上报的链路数据,但本机跑一个AllInOne版本足够我们学习和验证概念。

然后,在Python服务里要安装Jaeger的客户端库。我们使用最主流的 jaeger-client

  
  # 在虚拟环境中安装jaeger客户端
  pip install jaeger-client
  # 同时安装opentracing的python封装,它提供了标准化的埋点接口
  pip install opentracing

这里说明一下:Opentracing是一套链路追踪的标准接口,Jaeger是这个标准的一种实现。我们用标准的接口写埋点代码,以后就算公司把链路追踪系统换成别的,代码改动也会很小。

五、动手埋点:接入Jaeger跟踪每一次请求

5.1 一个最基础的接入示例

下面这个例子演示了如何在Python的Web服务中,使用Jaeger追踪每个HTTP请求从进入到返回的完整过程。技术栈:Python 3.9 + Flask + jaeger-client。


  # flask_jaeger_demo.py
  # 技术栈:Python 3.9 + Flask + jaeger-client
  
  from flask import Flask
  import opentracing
  import time
  from jaeger_client import Config
  
  app = Flask(__name__)
  
  # 创建一个全局的Tracer实例,用它的start_active_span方法创建链路
  # service_name表示这个服务在Jaeger UI上显示的名称
  tracer = None
  
  def init_tracer():
      # 配置Jaeger连接信息
      config = Config(
          config={
              'sampler': {
                  # const表示全部采样,type为0表示全部记录
                  # 生产环境通常会设成probabilistic概率采样,后面会细说
                  'type': 'const',
                  'param': 1,
              },
              # 本地没有部署agent时,直接指向collector的HTTP端口
              'local_agent': {
                  'reporting_host': 'localhost',
                  'reporting_port': 5775,
              },
              # 日志打印,方便调试
              'logging': True,
          },
          # 每个服务要有一个独一无二的服务名
          service_name='order-api'
      )
      return config.initialize_tracer()
  
  @app.route('/api/order/<order_id>')
  def get_order(order_id):
      # 开启一个span,span名称用接口路径,方便在UI上按接口筛选
      with tracer.start_active_span('GET /api/order/{id}') as scope:
          # 给当前span打上业务标签,后续能按标签做统计
          scope.span.set_tag('http.method', 'GET')
          scope.span.set_tag('order.id', order_id)
  
          # 模拟查数据库的耗时操作
          time.sleep(0.05)
  
          # 再模拟一次调用本地缓存的耗时操作
          time.sleep(0.02)
  
          return {'status': 'ok', 'order_id': order_id}
  
  if __name__ == '__main__':
      tracer = init_tracer()
      app.run(port=5001)

运行这个服务之后,每访问一次接口,Jaeger UI的"Search"页面就能看到一条名为"GET /api/order/{id}"的链路记录,展开可以看到这个请求内部两个模拟操作分别耗时多少。

5.2 多服务透传:带上完整链路信息进行跨服务调用

上面只是单服务内部的链路追踪,但云原生系统的核心特征是服务间互相调用。链路头信息要在服务间传递,才能把一次完整的用户请求串成一条线。下面演示服务A调用服务B时如何传递上下文。技术栈:Python 3.9 + Flask + requests + jaeger-client。


  # service_a.py
  # 技术栈:Python 3.9 + Flask + requests + jaeger-client
  # 服务A:接收前端请求,然后调用服务B获取用户信息
  
  from flask import Flask, request
  import opentracing
  import requests
  import time
  from jaeger_client import Config
  
  app = Flask(__name__)
  tracer = None
  
  def init_tracer():
      config = Config(
          config={
              'sampler': {'type': 'const', 'param': 1},
              'local_agent': {'reporting_host': 'localhost', 'reporting_port': 5775},
              'logging': True,
          },
          service_name='frontend-api'
      )
      return config.initialize_tracer()
  
  @app.route('/get_user/<user_id>')
  def get_user(user_id):
      # 当前HTTP请求如果是由上游服务转发过来的,Jaeger会自动从请求头中提取链路上下文
      # 但为了演示跨服务传递的完整过程,我们手动获取从header中解析出的span上下文
      with tracer.start_active_span('GET /get_user') as scope:
          # 模拟一些本地耗时,比如拼接数据
          time.sleep(0.01)
  
          # 注入当前span的上下文到HTTP请求头中
          # 这样服务B的Jaeger客户端就能解析出这是同一个链路的延续
          headers = {}
          # 核心代码:把当前span context注入到headers对象里
          tracer.inject(
              span_context=scope.span.context,
              format=opentracing.Format.HTTP_HEADERS,
              carrier=headers
          )
  
          # 调用下游服务,带上注入好的headers
          resp = requests.get('http://localhost:5002/api/user/' + user_id, headers=headers)
          return resp.json()
  
  if __name__ == '__main__':
      tracer = init_tracer()
      app.run(port=5001)


  # service_b.py
  # 技术栈:Python 3.9 + Flask + requests + jaeger-client
  # 服务B:用户信息服务,接收服务A的调用,并作为下游服务返回数据
  
  from flask import Flask, request
  import opentracing
  import time
  from jaeger_client import Config
  
  app = Flask(__name__)
  tracer = None
  
  def init_tracer():
      config = Config(
          config={
              'sampler': {'type': 'const', 'param': 1},
              'local_agent': {'reporting_host': 'localhost', 'reporting_port': 5775},
              'logging': True,
          },
          service_name='user-service'
      )
      return config.initialize_tracer()
  
  @app.route('/api/user/<user_id>')
  def get_user_info(user_id):
      # 从HTTP头中提取由上游服务注入的链路上下文
      # 提取出来的上下文会被Jaeger自动关联到当前的span
      with tracer.start_active_span('GET /api/user') as scope:
          # 关键操作:从HTTP请求头中解析链路上下文
          # 这一步让当前的span自动成为上游span的子span,串成完整链路
          span_ctx = tracer.extract(
              format=opentracing.Format.HTTP_HEADERS,
              carrier=request.headers
          )
  
          # 如果上游成功传递了上下文,这里就可以设置父span
          # 注意:start_active_span默认会从当前scope中找父span,这里手动传入
          # 为了让例子更简洁,这里不深剖自动化逻辑,实际场景直接从request取headers交给tracer即可
  
          time.sleep(0.03)
          return {'user_id': user_id, 'name': '张三'}
  
  if __name__ == '__main__':
      tracer = init_tracer()
      app.run(port=5002)

跑起来之后,在Jaeger UI里搜索服务名"frontend-api",点开任意一条链路,你会看到两个服务各自的span上下层级关系清晰地连在一起,时间轴上半部分显示frontend-api的耗时,下半部分显示user-service的耗时。整条链路一目了然。

六、从链路数据到容量规划的实战方法

光有数据还不行,关键是怎么从庞杂的链路数据中提取容量决策的依据。

6.1 用延迟分位数识别哪个环节先饱和

在Jaeger的UI里面,选择一个服务,再选择一种操作名(Operation),设置时间范围,系统会自动生成延迟分布图。你要重点看的是P50、P90、P99三条线的变化趋势

举个具体例子:假设"user-service"的P99延迟从平时的20毫秒涨到了80毫秒,而同时这个服务所在Pod的CPU使用率只有30%。这就说明瓶颈不在CPU,而在其他东西——可能是线程池不够、可能是数据库连接池排队、可能是依赖了某个外部API变慢了。这时候你要做的容量规划动作,不是盲目加CPU,而是先优化那个具体的慢操作。如果慢操作是查数据库,那就得给数据库加连接数或者优化SQL。

实际操作中,你可以用Jaeger的API直接拉取时序数据,然后导入到Prometheus里画趋势图。不过这一步不是必须的,日常观察UI界面也足够用。

6.2 用链路依赖图做流量推演

打开Jaeger的"System Architecture"页面,可以看到服务间的调用依赖图,每个服务旁边会标注调用量。这个图对容量规划非常有价值:当你计划做一次大型推广活动、预判流量要涨三倍时,你可以沿着依赖图从入口服务开始,一层一层推算每个下游服务会承担的流量增量。

比如图上显示"frontend-api"每秒调用"user-service"200次,而"user-service"每秒调用"member-service"150次。入口流量涨三倍,大概估算"frontend-api"到600次,"user-service"到450次,"member-service"到大约450次。再对照每个服务当前的容量上限,就能算出哪些服务需要扩容、各扩多少副本。整个过程不需要写一张复杂的容量公式表,跟着链路一张图一张图往下推就行。

6.3 结合压测做容量标定

链路数据结合压测,是容量规划最扎实的做法。用压测工具模拟日常流量、两倍流量、四倍流量,每一档压测的过程中,通过Jaeger观察每个环节的延迟分位数变化。你会看到某些服务在流量到某个临界值后,延迟突然陡增——那个临界值就是这个服务的容量上限。

比如我们用locust向"frontend-api"发压测请求,同时盯着Jaeger UI。当压到每秒500个请求时,"user-service"的P99延迟还是20毫秒,再往上加到每秒700个时,P99突然跳到200毫秒,而CPU还没满。这就说明有一个隐藏的线程池或者连接池到顶了。定位到具体是哪个span变慢,然后去检查那个组件的配置参数,调大之后重新压测,再验证容量是否翻倍。这种"压测-观察-调整-再压测"的循环,就是云原生时代容量规划的日常节奏。

七、掌握采样策略,别让数据淹没存储

7.1 为什么不能100%采样

Jaeger默认配置下,如果设置100%全量采样,在高流量系统里会产生海量链路数据。假设每秒有一万个请求,每条链路有几十个span,一天的存储量会是TB级别,成本极高。容量规划不需要每条请求都记录,统计学上只要样本量足够,分位数就是稳定的。

7.2 适合容量规划的采样策略

推荐使用概率采样,把每个请求被记录的概率设为10%或更高,看你的流量总量有多大。配置方式如下:


  {
    "sampler": {
      "type": "probabilistic",
      "param": 0.1
    },
    "local_agent": {
      "reporting_host": "localhost",
      "reporting_port": 5775
    },
    "logging": false
  }

param为0.1表示10%的请求会被采样记录。如果流量每秒少于100个请求,建议直接用const全量采样,因为数据量不大,全量更精准。

7.3 根据业务重要级别差异化采样

另一个思路是把请求按重要性分流:核心交易链路的请求100%采样,普通查询接口10%采样。这需要在代码里根据请求路径来判断,动态设置采样率。比如下面这个伪代码思路:


  # 技术栈:Python 3.9 + jaeger-client
  # 动态采样率,按路径区分链路重要性
  
  import opentracing
  from jaeger_client import Config
  
  config = Config(
      config={
          'sampler': {'type': 'probabilistic', 'param': 0.01},
          'local_agent': {'reporting_host': 'localhost', 'reporting_port': 5775},
      },
      service_name='order-api'
  )
  tracer = config.initialize_tracer()
  
  def create_span_with_dynamic_sampling(path):
      # 重要的交易接口走全量采样
      if path.startswith('/pay') or path.startswith('/checkout'):
          # 效果等同于把param设为1
          sampler_override = opentracing.SpanContext()
          span = tracer.start_span(path, child_of=sampler_override)
      else:
          # 普通接口走全局10%概率采样
          span = tracer.start_span(path)
      return span

八、除了Jaeger,搭配哪些关联技术让容量规划更完整

Jaeger提供的是链路级的延迟与依赖信息,但容量规划还需要两类数据:资源消耗数据和业务流量数据。

资源消耗数据可以用Prometheus配合Grafana来做。在Kubernetes集群里,Prometheus通过node-exporter采集节点CPU和内存使用率,通过kube-state-metrics采集Pod的副本数和资源配额。把Jaeger导出的延迟分位数和Prometheus采集的资源利用率放到同一个DashBoard上,你就能看到"延迟上涨时资源用到了多少"这种关联图像。

业务流量数据可以通过网关日志或者Kafka的topic积压情况来估计。在上游入口处做好流量统计,把实时QPS与Jaeger链路延迟叠加分析,能推算出延迟指数增长的点位,那个点就是容量规划必须覆盖的临界容量。

下面给一个简单的Python脚本思路,演示如何从Jaeger的API读取延迟数据,结合Prometheus的接口做对比分析。技术栈:Python 3.9 + requests。


  # capacity_analysis.py
  # 技术栈:Python 3.9 + requests
  # 作用:从Jaeger的API拉取P99延迟,从Prometheus拉取CPU使用率,输出到控制台
  
  import requests
  import json
  
  # 从Jaeger查询服务在指定时间窗口内的追踪延迟
  def query_jaeger_p99(service_name, lookback_minutes=30):
      # Jaeger的API接口,按服务名和标签过滤追踪记录
      url = "http://localhost:16686/api/traces"
      params = {
          "service": service_name,
          "lookback": lookback_minutes + "m",
          "limit": 5000  # 取最近5000条,作为估算样本
      }
      resp = requests.get(url, params=params)
      traces = resp.json().get("data", [])
      
      # 提取所有trace的总耗时,按从大到小排序
      durations = []
      for trace in traces:
          # trace的duration单位是微秒,转换为毫秒
          durations.append(trace["duration"] / 1000)
      durations.sort()
      
      # 计算P99:取第99百分位
      if not durations:
          return None
      p99_idx = int(len(durations) * 0.99) - 1
      return durations[p99_idx]
  
  # 从Prometheus拉取某服务的CPU使用率均值
  def query_prometheus_cpu(pod_name):
      # Prometheus的HTTP API,执行PromQL查询语句
      prom_url = "http://localhost:9090/api/v1/query"
      promql = f'avg(rate(container_cpu_usage_seconds_total{{pod="{pod_name}"}}[5m])) * 100'
      resp = requests.get(prom_url, params={"query": promql})
      result = resp.json()["data"]["result"]
      if not result:
          return None
      # 返回百分比值
      return float(result[0]["value"][1])
  
  if __name__ == "__main__":
      # 服务名需要和Jaeger中注册的service_name保持一致
      p99_ms = query_jaeger_p99("user-service", "30m")
      cpu_usage = query_prometheus_cpu("user-service-xxxx")
      print(f"user-service P99延迟: {p99_ms} ms")
      print(f"user-service CPU使用率: {cpu_usage}%")

九、容量规划中的三个常见坑与注意事项

9.1 别忽略缺少链路追踪的历史业务

你的系统里很可能还有一部分老服务,没有接任何链路追踪。这些服务在容量规划的依赖图中是黑洞,你无法判断它们会不会成为瓶颈。解决办法是优先给入口服务和核心数据库操作埋点,其他边缘服务可以逐步接入。在评估容量结果时,要记得给这部分未知区域留出30%的冗余空间。

9.2 注意采样率对P99准确度的影响

当你把采样率从100%降到1%时,P99的置信区间会明显变宽。也就是说,你计算出来的P99可能不太准确。容量规划依赖的延迟指标,建议保持至少10%的采样率,这样才能得到稳定的P99估算值。另外要看大流量时段的数据,流量小的时候样本量不足,P99会偏低,容易低估容量需求。

9.3 链路数据只负责"找问题",不负责"预测未来"

气Native系统的容量规划还需要结合业务增长预期、活动日历、历史同期的数据来设定未来一段时间的流量假设。链路数据告诉你的是今天的容量基线,不能直接推算出三个月后的容量需求。正确的姿势是:用链路数据定基线,用容量推算公式(流量增长率×当前资源上限)来预算未来资源数量,两者一结合才能落地。

十、一个完整的容量规划落地案例

把一个完整的流程串起来。假设我们有一个电商系统,入口是"frontend-api"网关,下游有三个核心服务:"user-service"(用户)、"order-service"(订单)、"inventory-service"(库存)。

某天我们计划做一次大型促销,预估下单流量会变成平时的5倍。按照下面的步骤做容量规划:

  1. 先在Jaeger后台查看最近一周的链路数据,拿到每个服务每天的调用量和P99延迟。假设统计结果是:"frontend-api"日均请求20万次,高峰秒级300QPS;"order-service"高峰150QPS,P99响应时间35毫秒;"inventory-service"高峰130QPS,P99响应时间50毫秒。

  2. 接着查看每条链路中哪些环节占的时长比例最大。发现"order-service"内部一个查数据库的操作占了总时长的70%,这个操作单独P99是25毫秒。

  3. 用压测工具分别对三个服务加压,同时观察Jaeger中各自P99的变化。当请求量到1200QPS时,"inventory-service"的P99延迟从50毫秒跳到400毫秒;当请求量到2000QPS时,"order-service"的连接池开始报错。这样我们就知道了:库存服务大概在1000QPS左右就是危险线,而订单服务的上限大概是1800QPS。

  4. 促销预估5倍流量,"inventory-service"会到650QPS,还没过危险线,但已经很接近了,为了安全起见扩容2个Pod;"order-service"会到750QPS,离1800还很远,暂不扩容。同时对"inventory-service"内部的慢SQL做一次索引优化,把P99降下来扩大容量余量。

  5. 扩容之后重新压测,验证P99恢复到了50毫秒以内,容量规划落地完成。

整个过程中,Jaeger的洞察就是第2和第3步里的延迟分位数和瓶颈定位。没有它,你可能会给所有服务都盲目扩容,花冤枉钱。

十一、总结一下

云原生系统的容量规划,不再是一张静态的服务器配置表,而是一个持续不断、跟随业务动态调优的过程。Jaeger作为链路追踪工具,最大的价值不是告诉你哪台机器负载高,而是告诉你一次用户的请求在系统内部到底经历了什么。通过分析链路延迟的分布数据、服务间的依赖关系、以及不同压力下的性能拐点,你能准确知道容量瓶颈在哪里、哪个服务该扩容、哪个服务需要优化代码而不是加机器。

在实际落地的过程中,建议研发团队主动养成"每次上线新功能都看一眼链路数据"的习惯,把容量隐患消灭在萌芽状态。链路数据既是排障工具,更是容量规划的第一手素材。