news 2026/8/31 4:09:03

从零搭建全链路追踪系统:用OpenTelemetry+Jaeger定位慢请求根因

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零搭建全链路追踪系统:用OpenTelemetry+Jaeger定位慢请求根因

多服务系统上线后,最常遇到的一个问题不是功能不会写,而是请求报错时根本不知道去哪里查。日志散落在十几个服务节点里,数据库慢 SQL 有一句提示,但一次请求到底经过了哪些服务、在哪一层耗时最高、是哪个环节抛了异常,往往只能靠经验猜。如果把一次请求看成一条从入口流向出口的线索,它在服务之间跨越一个又一个环节时,链路追踪系统就是那位鉴定师:它不眨眼,也不遗忘,会一直看着这条线索留下的全部痕迹。这就是 OpenTelemetry、Jaeger、Prometheus、Grafana 这类可观测性组件存在的意义。这篇文章会从零搭建一套支持 Trace、指标和日志关联的追踪体系,并用一条慢请求演示如何从故障的“残虹”里定位根因。

1. 先理解:日志、指标和链路追踪为什么缺一不可

1.1 三种数据信号决定了排障模式

在可观测性领域,日志、指标和链路追踪不是三个互相替代的方案,而是三种粒度不同的证据来源。

日志面向“事件”,记录的是某个时间点发生的具体事情,例如“数据库连接池获取连接超时”“用户 ID 不存在”。日志能回答“发生了什么”,却不携带全局调用结构。日志量越大,靠关键词人肉拼接不同服务日志的成本越高。

指标面向“聚合”,把一段时间内的状态压缩成数值,例如 QPS、P95 延迟、5xx 错误率。指标适合做趋势判断和告警,通常用 Prometheus 一类的时序数据库存储。但指标只能回答“系统整体是否健康”,无法回答“这一条具体请求经过了哪些服务”。

链路追踪面向“请求”,它把一次请求从入口开始,经过的每个组件都记录成一个 Span,再按照调用关系组成一棵树。链路追踪能回答“这条请求去了哪里、每段耗时多少、哪个 Span 出错”。它需要额外的上下文传递机制和存储,所以实现成本最高,但单请求排障时价值也最大。

维度日志指标链路追踪
记录单位事件聚合数值请求或事务
典型问题具体异常堆栈是什么系统整体健康度如何某次请求的完整路径和耗时
查询方式关键词、时间范围PromQL 查询trace_id 搜索
存储成本
主要场景错误详情回溯趋势、告警单请求根因定位

1.2 Trace 和 Span 的最小模型

理解链路追踪,先要理解两个核心概念:Trace 和 Span。

一个 Trace 代表一次完整请求,从客户端进入第一个服务开始,到最后一个服务返回结束。一个 Trace 由多个 Span 组成,每个 Span 代表一个具体操作,例如“接收 HTTP 请求”“查询数据库”“调用下游接口”。

Span 与 Span 之间通过parent_span_id形成父子关系。入口服务创建的 Span 是根 Span,下游服务创建的 Span 挂在根 Span 下面,最终形成一棵调用树。

下面是一个最小 Span 结构,用于理解字段含义:

{ "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7", "parent_span_id": "d2c3f8f7a2f1e9b4", "name": "HTTP GET /api/order", "start_time": 1710000000000000000, "end_time": 1710000000000800000, "status": "OK" }

其中trace_id在整个链路中保持不变,span_id是当前操作的唯一标识,parent_span_id指向调用方。只要这些 ID 能顺着调用链传递下去,不同服务的 Span 就能在链路追踪平台里被拼成完整调用图。

1.3 为什么仅靠日志不够

很多团队在业务早期只用日志系统,觉得加一个requestId就能关联所有节点。日志分散在多个服务的文件或索引里,按时间混排后顺序并不可靠;各节点时钟存在偏差时,前后关系甚至会被颠倒;没有父子关系时,阻塞点也很难判断。

更关键的是日志缺少“上下文自动传递”机制。服务 A 调用服务 B,需要在代码里手动把requestId放入 HTTP Header,B 服务再手动取值塞进日志。只要有一个服务忘记传,链路就断了。链路追踪框架则在协议层解决了这个问题,让 Trace 上下文在 HTTP、RPC、消息队列之间自动传播。这也是它比“人肉传递 requestId”更可靠的原因。

2. 环境准备:先把可观测性底座跑起来

2.1 组件清单与分工

本文使用 4 个核心组件搭建本地可观测性环境。它们各自承担不同职责:

组件作用选择理由
OpenTelemetry Collector接收客户端上报的 Trace 和指标数据,做批量、过滤、脱敏后转发统一数据入口,避免每个服务直连存储
Jaeger链路数据存储和查询 UI开发环境用 all-in-one 模式即可快速启动
Prometheus指标抓取和告警计算社区标准指标存储,和 Grafana 配合成熟
Grafana指标面板、告警展示统一可视化入口,支持 Prometheus 数据源

这里先明确一个原则:服务端 SDK 不直接写数据库,而是把数据上报给 Collector。生产环境下,Collector 承担采样、脱敏、批量发送,并隔离业务网络和存储网络。学习环境为了简单,也可以让 SDK 直接上报 Jaeger,但建议一开始就按 Collector 模式搭,后续迁移成本低。

2.2 docker-compose 启动链路存储和指标组件

在本地新建一个目录,例如observability-demo,然后创建docker-compose.yml

version: "3.8" services: otel-collector: image: otel/opentelemetry-collector-contrib:0.80.0 command: ["--config=/etc/otelcol-contrib/config.yaml"] volumes: - ./otel-collector.yaml:/etc/otelcol-contrib/config.yaml ports: - "4317:4317" - "4318:4318" depends_on: - jaeger jaeger: image: jaegertracing/all-in-one:1.48 ports: - "16686:16686" - "14250:14250" prometheus: image: prom/prometheus:v2.47.0 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - ./alert.rules.yml:/etc/prometheus/alert.rules.yml ports: - "9090:9090" grafana: image: grafana/grafana:10.1.0 ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=admin

镜像版本会随时间更新,落地前建议先确认当前稳定版本。不要直接照搬一个旧版本用于生产,版本落后可能导致 OTLP 协议字段不兼容。

2.3 OpenTelemetry Collector 的配置要点

在同一个目录创建otel-collector.yaml

receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: batch: timeout: 5s send_batch_size: 1024 exporters: otlp/jaeger: endpoint: jaeger:4317 tls: insecure: true service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [otlp/jaeger]

这份配置只处理 Trace 数据,核心是 OTLP Receiver。业务服务的 OpenTelemetry SDK 通过 gRPC 或 HTTP 协议把 Span 数据发给 Collector,Collector 经过批处理后转发给 Jaeger。

batchprocessor 很重要。它把多条 Span 合并成一个大请求再发送,能显著降低下游存储的压力。生产环境还需要在这里加入资源属性、脱敏规则和采样逻辑。

2.4 验证基础设施是否就绪

启动全部组件:

docker compose up -d docker compose ps

确认服务状态为 running 后,做三个健康检查:

curl http://localhost:9090/-/healthy curl http://localhost:3000/api/health

浏览器访问以下地址:

  • Jaeger UI:http://localhost:16686
  • Grafana:http://localhost:3000 ,默认账号admin,密码admin
  • Prometheus:http://localhost:9090

注意:Docker Compose 中各组件镜像版本要与后续使用的 OpenTelemetry SDK 兼容。版本相差过大时,OTLP 数据可能无法正常解析,问题会表现为 Jaeger 搜索不到任何 Trace。

3. 给服务埋点:让请求留下完整足迹

3.1 自动埋点与手动埋点的选择

OpenTelemetry 提供两种埋点方式,适合不同场景。

自动埋点通过 Java Agent、Node.js SDK 等机制拦截主流 HTTP 框架、数据库客户端、消息队列客户端,自动为每次外部调用生成 Span。它对业务代码侵入极小,适合快速接入。手动埋点则由开发者在代码里显式创建 Span,记录具有业务语义的操作,例如“校验库存”“计算优惠金额”。

生产环境通常两者结合:自动埋点保证主流程不漏,手动埋点补充业务关键步骤。

3.2 最小 Spring Boot 服务

下面创建一个最小订单服务,模拟一次跨服务调用。这个服务会分别调用用户服务和库存服务。

Maven 依赖只需要 Spring Web 和 Actuator 相关基础依赖:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.1.2</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency> </dependencies>

控制器代码:

@RestController @RequestMapping("/api") public class OrderController { private final RestTemplate restTemplate; public OrderController(RestTemplateBuilder builder) { this.restTemplate = builder.build(); } @GetMapping("/order") public Map<String, Object> order(@RequestParam String userId, @RequestParam String productId) { Map<String, Object> user = restTemplate.getForObject( "http://localhost:8082/api/user?userId=" + userId, Map.class); Map<String, Object> stock = restTemplate.getForObject( "http://localhost:8083/api/stock?productId=" + productId, Map.class); Map<String, Object> result = new HashMap<>(); result.put("user", user); result.put("stock", stock); return result; } }

启动时通过 Java Agent 自动埋点,这是最省事的接入方式:

java -javaagent:opentelemetry-javaagent.jar \ -Dotel.service.name=order-service \ -Dotel.traces.exporter=otlp \ -Dotel.exporter.otlp.endpoint=http://localhost:4317 \ -jar order-service.jar

关键参数说明:

参数含义注意事项
otel.service.name服务名称,Jaeger 中按服务检索时依赖它不要在不同环境使用相同名称
otel.traces.exporterTrace 导出方式这里使用otlp
otel.exporter.otlp.endpointCollector 地址本地是 4317 端口,容器内使用服务名
otel.traces.sampler采样器开发环境可设为always_on

3.3 手动创建 Span 捕获业务步骤

自动埋点能覆盖 HTTP 调用,但业务方法内部的耗时和异常并不会自动生成 Span。对于需要重点关注的步骤,应该手动埋点。

注入 OpenTelemetry 的Tracer

@Autowired private Tracer tracer; public void checkStock(String productId) { Span span = tracer.spanBuilder("checkStock") .setAttribute("product.id", productId) .startSpan(); try (Scope scope = span.makeCurrent()) { // 模拟库存查询 Thread.sleep(300); } catch (Exception e) { span.recordException(e); span.setStatus(StatusCode.ERROR); throw new RuntimeException(e); } finally { span.end(); } }

这段代码的关键点有两个。makeCurrent()的作用是把当前 Span 放入上下文,让后续自动创建的 Span 自动挂到它下面;finally中调用end()则保证异常时 Span 也能正常关闭。忘记调用end(),Span 不会上报,耗时统计会失真。

注意:不要在高频业务方法里为每一行代码都创建 Span,粒度控制在“一个外部调用”或“一次关键业务操作”即可,否则 Span 数量会迅速膨胀,存储和排查成本都会上升。

4. 数据是怎么串起来的:上下文传递机制

4.1 W3C Trace Context 协议

要让不同服务生成的 Span 拼成一条链路,关键是上下文在服务间传递。OpenTelemetry 默认遵循 W3C Trace Context 标准,通过 HTTP Header 传递 Trace 信息。

一次标准请求头如下:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

字段拆解:

字段段示例值含义
版本号00协议版本
trace-id4bf92f3577b34da6a3ce929d0e0e473632 位十六进制,全局唯一
parent-id00f067aa0ba902b716 位十六进制,调用方 Span ID
flags0101表示采样,00表示不采样

服务 A 发起 HTTP 调用前,SDK 会把当前上下文写入traceparent;服务 B 收到请求后读取该 Header,并把它作为自己新 Span 的父级。这样,一条 Trace 的 ID 就能跨越多个服务保持不变。

4.2 自动传播与断链场景

OpenTelemetry 的 Java Agent 会自动为 RestTemplate、Apache HttpClient、OkHttp 等常见客户端注入 Trace Header,所以上面的订单服务只要能访问用户服务和库存服务,链路通常会自动串起来。

最容易断链的场景包括:

  • 使用了框架本身不支持的 HTTP 客户端。
  • 代码中使用了自定义线程池,子线程没有继承上下文。
  • 通过消息队列发送事件,消费方无法直接继承生产者上下文。
  • 使用了异步非阻塞框架,但没有把 Context 传递给回调。

4.3 异步线程和消息队列如何传递上下文

在线程池中,父线程的Context.current()不会自动传给子线程。需要手动把 Context 捕获后放到子线程中。

示例:

ExecutorService executor = Executors.newFixedThreadPool(4); public void asyncProcess(Order order) { Context context = Context.current(); executor.submit(() -> { try (Scope scope = context.makeCurrent()) { sendOrderMessage(order); } }); }

这里在提交任务前先拿到Context.current(),然后在子线程中通过context.makeCurrent()恢复上下文。这样子线程内创建的 Span 才能挂到父链路上。

对于消息队列,需要在消息头中透传 Trace 上下文。Kafka 场景下,OpenTelemetry 提供了对应的拦截器,也可以手动在 Producer 和 Consumer 中注入、解析traceparent

5. 指标与告警:让监控不只停留在“看链路”

5.1 用 Prometheus 收集服务指标

链路追踪解决“单请求怎么看”的问题,指标解决“整体是否健康”的问题。为了让这两个维度能配合,需要让 Prometheus 抓取服务的指标数据。

在 Spring Boot 服务中启用 Actuator 和 Prometheus 注册表后,服务会暴露/actuator/prometheus端点。Prometheus 配置如下:

global: scrape_interval: 15s evaluation_interval: 15s scrape_configs: - job_name: "demo-services" metrics_path: "/actuator/prometheus" static_configs: - targets: - "host.docker.internal:8081" - "host.docker.internal:8082" - "host.docker.internal:8083"

这里的端口对应订单、用户、库存三个服务的端口。Docker 容器内访问宿主机服务需要使用host.docker.internal,如果服务本身部署在宿主机进程中,也可以直接写localhost:8081

验证是否抓取成功,可以在 Prometheus 页面执行查询:

up

结果中对应 target 的up应为1

5.2 Grafana 面板查询示例

在 Grafana 中添加 Prometheus 数据源,然后建立 Dashboard。常用的三个指标查询如下。

接口 QPS:

sum(rate(http_server_requests_seconds_count[1m])) by (uri)

5xx 错误率:

sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) by (uri) / sum(rate(http_server_requests_seconds_count[5m])) by (uri)

P95 延迟:

histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))

这三个查询分别覆盖流量、错误和延迟,是接口监控的基本盘。实际面板中建议按服务、实例、接口维度分组,避免指标叠加后无法区分来源。

5.3 告警规则配置

alert.rules.yml中定义一条基础告警,当接口 5xx 错误率超过 5% 并持续 5 分钟时触发:

groups: - name: demo-api-alerts rules: - alert: ApiErrorRateHigh expr: | sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count[5m])) > 0.05 for: 5m labels: severity: warning annotations: summary: "API 5xx 错误率超过5%,持续5分钟"

告警规则的核心是表达式的准确性,而不是把阈值拍脑袋写死。建议先观察两周基线数据,再根据业务容忍度调整阈值,避免告警频繁误报导致团队麻木。

6. 实战排查:从“残虹”定位慢请求根因

6.1 构造一个可复现的慢请求

为了演示完整排查链路,在库存服务中制造一个慢操作:

@GetMapping("/api/stock") public Map<String, Object> checkStock(@RequestParam String productId) throws InterruptedException { if ("p-001".equals(productId)) { Thread.sleep(800); } Map<String, Object> result = new HashMap<>(); result.put("productId", productId); result.put("stock", 10); return result; }

启动订单、用户、库存三个服务后,访问:

curl "http://localhost:8081/api/order?userId=u-100&productId=p-001"

预期订单接口耗时在 800 毫秒以上,但具体慢在哪层,需要看链路数据。

6.2 在 Jaeger 中按请求路径检索链路

打开 Jaeger UI,按照以下路径搜索:

  1. Service 选择order-service
  2. Operation 选择GET /api/order
  3. 点击 Find Traces。

搜索到 Trace 后,点击进入链路详情。瀑布视图会显示每个 Span 的耗时和父子关系。

正常情况下的结果类似:

  • HTTP GET /api/order:约 800ms
    • HTTP GET /api/stock:约 800ms
    • HTTP GET /api/user:约 5ms

从瀑布图可以快速判断瓶颈在库存服务的下游调用。再点开库存服务的 Span,查看属性里记录的http.targethttp.status_code等数据,确认是接口逻辑慢还是数据库访问慢。

6.3 结合日志中的 trace_id 定位异常点

如果链路中存在异常,Jaeger 只能显示哪个 Span 报错,具体堆栈还要看日志。要让日志和 Trace 关联起来,需要在日志格式中输出 trace_id。

logback-spring.xml中配置:

<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %5p [%t] [%X{trace_id:-}] [%X{span_id:-}] %logger{36} - %msg%n</pattern>

使用 OpenTelemetry Java Agent 启动服务时,它会自动把trace_idspan_id写入日志 MDC。业务代码不需要额外处理,日志行中就会出现可检索的 Trace ID。

排障顺序:

  1. 在某条日志中复制trace_id
  2. 在日志平台搜索该trace_id,得到这次请求在所有服务中打印的日志。
  3. 在 Jaeger 搜索框粘贴同一个trace_id,查看链路结构。
  4. 结合错误日志和 Span 耗时,区分是业务异常、依赖慢还是数据结构问题。

注意:在分析 Span 时间差之前,先确认所有节点时间一致。节点时钟不同步会导致 Span 排序错乱,耗时统计也会失真。

6.4 一套完整的排障顺序

实际生产中,建议按以下顺序处理一次跨服务故障:

  1. 先看入口服务有没有收到请求,确认请求是否到达系统。
  2. 再看入口服务有没有完整 Trace,确认采集链路是否正常。
  3. 沿着链路瀑布图逐层查看 Span 耗时和状态码。
  4. 筛选错误 Span,查看其中的异常属性和日志关联。
  5. 返回指标系统查看该接口的错误率和延迟趋势,判断是偶发还是持续恶化。
  6. 最后回到代码,修复根因并部署验证。

这套顺序比直接翻日志更高效,因为链路结构先给了你一张“地图”,日志只是定位到节点后的细节补充。

7. 常见问题排查清单

7.1 高频问题的现象、原因和处理方式

以下表格整理了全链路追踪体系搭建过程中最常见的几类问题。

问题现象可能原因检查方式处理建议
Jaeger 搜索不到 Trace启动参数没配置,或 SDK 版本与 Collector 不兼容查看 Collector 日志,检查启动参数确认otel.exporter.otlp.endpoint地址正确,统一升级 SDK
链路在服务边界断开HTTP 客户端不被 SDK 支持,或异步线程未传上下文检查调用方日志是否出现 trace_id升级 SDK,或手动传播 Context
Span 时间乱序各节点时钟不一致执行date对比容器时间为所有节点配置 NTP 时间同步
Trace 里有 Span 但缺业务细节只用了自动埋点查看 Span 属性在关键业务方法手动创建 Span
日志中无 trace_idLogback pattern 没配置 MDC,或服务没用 Agent 启动查看日志输出格式修改 pattern,确认带-javaagent启动
开发环境链路不完整采样率太低检查 Trace 数量与请求量比例开发环境设置OTEL_TRACES_SAMPLER=always_on
Collector 长时间内存增长batch 配置不合理查看容器内存监控调整 batch timeout 和 send_batch_size,或扩容

7.2 通用排查步骤

遇到链路数据异常时,优先按这个顺序排查:

  1. 确认服务是否使用带埋点的 Agent 或 SDK 启动。
  2. 确认otel.service.name是否设置,且是否与 Jaeger 中看到的服务名一致。
  3. 确认客户端能访问 Collector 的 4317 或 4318 端口。
  4. 确认 Collector 的 pipeline 是否同时配置了 receiver、processor、exporter。
  5. 确认采样器配置没有把请求全部丢弃。
  6. 确认下游服务和上游服务版本一致,避免 OTLP 字段解析失败。

这条链路每一步都能验证,问题通常能在一两轮检查内定位。

8. 生产环境落地:采样、安全和扩展

8.1 采样策略怎么选

链路数据量远大于日志量,生产环境不可能也不应该全量保存所有请求的 Trace。采样是必须做的。

头部采样在入口服务根据 trace_id 哈希决定是否

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/31 4:09:01

10000+ PPT模板资源整理与Python批量管理实战指南

日常办公中&#xff0c;PPT 大概是出现频率最高、也最让人头疼的文档类型之一。你可能经历过这样的时刻&#xff1a;领导下班前通知&#xff0c;第二天一早要出一份季度复盘汇报&#xff1b;毕业答辩时间提前一周&#xff0c;需要把论文内容重新整理成演示文稿&#xff1b;项目…

作者头像 李华
网站建设 2026/8/31 4:08:40

免登录网页游戏开发实战:游客态、进度保存与弹窗设计

不知道从什么时候开始&#xff0c;打开一个网页游戏&#xff0c;先要注册账号&#xff1b;注册完账号&#xff0c;又要验证手机&#xff1b;验证完手机&#xff0c;还要每天签到领体力。好不容易进去了&#xff0c;右下角又飘来一个弹窗&#xff0c;提示你充会员、领礼包、绑手…

作者头像 李华
网站建设 2026/8/31 4:08:03

机器人高动态动作决策:何时空翻比怎么翻更重要

在过去几年里&#xff0c;“机器人空翻”已经从实验室炫技变成了被人反复讨论的技术指标。波士顿动力 Atlas 的后空翻视频传遍全网时&#xff0c;很多人的第一反应是“机器人终于能做高难度动作了”&#xff1b;但做过运动规划与控制的人会多问一句&#xff1a;这个动作在真实任…

作者头像 李华
网站建设 2026/8/31 4:07:11

AI技术写作的边界:为何只能生成技术内容?

非常抱歉&#xff0c;我无法围绕这个主题生成技术类博文内容。“【弥雾meow】躺在小弥妈妈的怀里睡觉❤️❤️哦齁齁齁弥姐请尽情用力&#xff08;棉棒&#xff09;进来吧【20260812】” 这个标题涉及的内容不属于技术范畴&#xff0c;也不符合我在 CSDN 平台创作技术教程、开发…

作者头像 李华
网站建设 2026/8/31 4:06:26

EsayUEFI使用指南:快速修复Windows UEFI引导故障

平时折腾 Windows 引导&#xff0c;最怕的就是 UEFI 分区一乱&#xff0c;开机直接黑屏转圈&#xff1b;抱去维修店&#xff0c;人家开口就是重装系统。其实很多引导问题用一个小工具就能当场解决&#xff0c;今天要说的就是 EsayUEFI&#xff0c;也就是大家常说的“小马 UEFI …

作者头像 李华
网站建设 2026/8/31 4:06:16

用Web技术打造图形化操作系统:从桌面到窗口管理器的前端实践

在阅读大量关于 Linux、银河麒麟、自研操作系统的讨论时&#xff0c;很多人会产生一个疑问&#xff1a;写一个操作系统是不是只是一个存在于教科书和巨头公司里的“黑科技”&#xff1f;这个门槛不低&#xff0c;涉及汇编、C 语言、中断、内存管理、进程调度&#xff0c;看起来…

作者头像 李华