1. 先搞清楚要解决的问题:内存版只是演示,落库才是正经事
做过链路追踪的同学大概都有这么一段经历:一条docker run把openzipkin/zipkin拉起来,浏览器打开 9411 端口,页面上看到几条彩色的 trace 就觉得很爽,然后兴冲冲地接进测试环境。结果第二天早上再看,页面干干净净——昨天的链路一条都不剩。这不是 Bug,是 Zipkin 默认的mem存储本来就把数据放在 JVM 堆里,进程一重启,全部归零。这种"内存版"只适合验证采集链路是否通,一旦涉及回溯历史问题、对比发版前后的耗时变化、排查"昨天下午三点那次超时",就必须把数据落到外部存储。这篇文章要讲的就是:用 Docker 把 Zipkin 拉起来,并且把 trace 数据真正持久化到 MySQL 和 Elasticsearch 两个后端,包含版本选型、建表脚本、编排文件、双写方案和一堆踩过的坑。
先明确适合谁看。如果你只是想知道 Zipkin 界面长什么样,装个内存版就够了,后面这些环境变量和 DDL 对你来说是负担。但如果你所在的团队已经在用 Spring Cloud、Dubbo 或者 OpenTelemetry 体系,需要一个能自己掌控、数据不出内网、能长期保留调用链的追踪后端,那这篇的每一步你大概都会用上。内容偏实操,默认读者对 Docker、MySQL 和 ES 有基本概念,但不需要你事先了解 Zipkin 的存储细节。
MySQL 和 ES 这两个后端,定位完全不一样,选型前最好先想明白自己要什么。下面这张表是我自己评估时的结论,实际项目里也基本照着这个思路定:
| 维度 | MySQL 后端 | Elasticsearch 后端 |
|---|---|---|
| 写入吞吐 | 一般,单机几千 span/s 就吃力 | 强,适合高并发写入 |
| 查询灵活性 | 只能按 traceId、服务名、时间等固定维度 | 支持复杂聚合、模糊匹配、全文检索 |
| 依赖图(Dependencies) | 需要额外跑定时任务 | 同样需要额外跑定时任务 |
| 运维成本 | 低,大多数团队已经有 MySQL | 高,需要独立的 ES 集群和资源规划 |
| 数据保留 | 靠自己写定时清理 SQL | 可用索引生命周期管理,按天滚动删除 |
| 适用规模 | 中小规模、日请求百万级以内 | 中大规模、需要多维分析和长期存储 |
我的建议很直接:团队规模不大、日常排查为主,MySQL 足够;如果要做服务依赖分析、要看 P99 曲线、要保留三个月以上的数据,直接上 ES,别在 MySQL 上硬扛。也有团队两套都留——MySQL 存近几天的热数据用于快速定位,ES 存全量用于分析。第 6 章会专门讲双写的几种做法。
2. 环境准备与版本选型,别在第一步就埋雷
2.1 镜像版本矩阵:Zipkin、MySQL、ES 的三角关系
这一步最容易翻车。Zipkin 的 ES 存储实现跟 ES 版本绑得很紧,版本对不上会直接启动失败,报错信息还特别晦涩,比如Unsupported Elasticsearch version或者连接成功但索引创建不出来。我踩过一次:用openzipkin/zipkin:2.23去连 ES 8,容器起来十几秒就退出,日志里只有一句版本不兼容。
稳妥的组合是这样:
| Zipkin 版本 | 推荐 ES 版本 | 推荐 MySQL 版本 | 备注 |
|---|---|---|---|
| 2.23.x / 2.24.x | 7.10 ~ 7.17 | 5.7 / 8.0 | 生产环境最常见,文档最全 |
| 3.x | 8.x | 8.0 | 需要处理 ES 8 默认开启的安全认证 |
| 2.21.x 及更早 | 6.x ~ 7.x | 5.7 / 8.0 | 不建议新项目使用 |
我个人的默认选择是openzipkin/zipkin:2.24.3配elasticsearch:7.17.10,这个组合在多个环境里跑了大半年没出过幺蛾子。ES 8 不是不能用,但它的xpack.security默认开启,你得么关掉安全插件,要么配证书和账号密码,多出来的工作量对内部追踪系统来说意义不大。真要上 ES 8,建议直接配 Zipkin 3.x,别用 2.x 硬凑。
MySQL 这边,5.7 和 8.0 都能用,8.0 要注意默认的utf8mb4和sql_mode更严格,官方 DDL 里有些写法在新版本会给出警告,后面第 4 章会说。另外 MariaDB 10.x 也兼容,但表结构里的ROW_FORMAT=COMPRESSED在部分 MariaDB 版本上行为有差异,追求省心就用官方 MySQL 镜像。
提示:选版本时不要只看"最新",要看三者的兼容矩阵。我的经验是先定 ES 版本(因为升级 ES 成本最高),再根据 ES 版本反推 Zipkin 版本,MySQL 放最后考虑。
2.2 目录与网络规划,一次规划好省得后面改
Docker 部署最怕的就是随手docker run,跑起来之后发现数据卷没挂、网络不通、端口跟宿主机上已有的服务冲突。我习惯在开工前先把目录结构定下来,哪怕只是个测试环境:
zipkin-demo/ ├── docker-compose.yml ├── mysql/ │ └── init/ │ └── 01-zipkin-schema.sql ├── es/ │ └── data/ └── zipkin/ └── data/网络方面,用自定义 bridge 网络而不是默认的bridge,理由是默认网络里的容器没法用服务名互相解析,而 Zipkin 连 MySQL 和 ES 时最方便的方式就是写容器名。自定义网络下,Zipkin 的MYSQL_HOST直接写mysql、ES_HOSTS写http://elasticsearch:9200就行,不用去关心宿主机的 IP 漂移。
docker network create zipkin-net端口规划上,Zipkin 对外只暴露 9411 一个端口就够了。9411 同时承载 UI 和 HTTP 上报接口(/api/v2/spans),9410 是 gRPC 和部分 HTTP 采集端口,如果客户端用的是 gRPC 上报才需要额外映射。很多教程里把两个都映射出来,实际上大部分 Spring Cloud 项目走的是 HTTP,9411 足够。
注意:如果宿主机上已经有服务占了 9411,改映射的时候记得同步改客户端的
base-url,这个漏改的排查成本不低,现象是上报静默失败,日志里啥也没有。
3. 先用内存模式把链路跑通,再谈持久化
3.1 一条命令拉起最小可用服务
我坚持的做法是分两步走:先跑内存版验证采集链路,再切存储。理由很简单——如果一上来就配持久化,出了问题是采集端没发、还是存储端没收,你根本分不清。内存版跑通意味着"客户端到 Zipkin 的链路是好的",后面出问题就只需要怀疑存储那一层。
docker run -d \ --name zipkin-mem \ --network zipkin-net \ -p 9411:9411 \ -e STORAGE_TYPE=mem \ openzipkin/zipkin:2.24.3STORAGE_TYPE这个环境变量是整个持久化方案的核心开关,取值可以是mem、mysql、elasticsearch、cassandra3。不写默认就是mem。启动后访问http://宿主机IP:9411,能看到 Zipkin 的查询页面就说明服务正常。
3.2 采集端接入:Spring Boot 2.x 和 3.x 的写法不一样
后端起来了,得有数据喂进去。这块因为 Spring Boot 大版本换代,配置方式差得挺远,很多人拿老教程配新项目,折腾半天没数据。
Spring Boot 2.x 走的是 Sleuth + Spring Cloud Sleuth Zipkin 这套:
spring: application: name: order-service sleuth: sampler: probability: 1.0 zipkin: base-url: http://192.168.1.100:9411/ sender: type: web依赖是spring-cloud-starter-sleuth加spring-cloud-starter-zipkin。probability: 1.0表示全采样,测试环境用 1.0 方便看全貌,生产环境绝对不能这么配,第 8 章会算这笔账。
Spring Boot 3.x 已经移除 Sleuth,改用 Micrometer Tracing:
management: tracing: sampling: probability: 1.0 zipkin: tracing: endpoint: http://192.168.1.100:9411/api/v2/spans依赖需要io.micrometer:micrometer-tracing-bridge-brave和io.zipkin.reporter2:zipkin-reporter-brave。注意这里的 endpoint 要写完整路径/api/v2/spans,跟 2.x 的base-url不一样,2.x 的客户端会自动拼路径。这个差异坑过不少人,配错了表现是 UI 上一条数据都没有,但应用日志里也不会报错。
其他语言体系的接入方式:Go 用openzipkin/zipkin-go,Python 用py_zipkin,Node 用zipkin-javascript-opentracing,Java 非 Spring 项目用 Brave 或 OpenTelemetry SDK。所有客户端的本质都是往POST /api/v2/spans发一段 JSON 数组,格式是 Zipkin V2 规范。
3.3 上报格式长什么样,出问题时能自己抓包看
理解上报格式对排查很有帮助。一条 trace 上报上去长这样(截取单个 span):
[ { "traceId": "5b8a1c3d9e7f4a2b", "id": "9e7f4a2b5b8a1c3d", "name": "get /api/order/{id}", "timestamp": 1715678400000000, "duration": 128000, "localEndpoint": { "serviceName": "order-service", "ipv4": "10.0.0.12" }, "tags": { "http.method": "GET", "http.status_code": "200" } } ]几个关键字段:traceId是整条链路的唯一标识,id是当前 span 的标识,parentId指向上一级。timestamp是微秒级时间戳,duration也是微秒。Zipkin 存的全部是微秒,客户端如果传了毫秒,UI 上的耗时就会显示成 0 或者离谱的数值——这是自研客户端常犯的错。
实操心得:采集端接不上的时候,先在 Zipkin 容器里
curl -X POST -H 'Content-Type: application/json' -d @span.json http://localhost:9411/api/v2/spans手动灌一条,能灌进去说明服务端没问题;灌进去查不到,那问题一定在存储配置上。
4. 持久化到 MySQL:从建表到验证一条龙
4.1 官方 DDL 有三个地方要留意
Zipkin 的 MySQL 存储不会自动建表,必须手动导入 schema。官方脚本在 Zipkin 源码仓库的zipkin-storage/mysql-v1/src/main/resources/mysql.sql,但网上一堆转帖的脚本是残缺的,导完启动就报Table 'zipkin.zipkin_spans' doesn't exist。完整结构是三张表:zipkin_spans、zipkin_annotations、zipkin_dependencies。
CREATE DATABASE IF NOT EXISTS zipkin DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci; USE zipkin; CREATE TABLE IF NOT EXISTS zipkin_spans ( `trace_id_high` BIGINT NOT NULL DEFAULT 0, `trace_id` BIGINT NOT NULL, `id` BIGINT NOT NULL, `name` VARCHAR(255) NOT NULL, `parent_id` BIGINT, `debug` BIT(1), `start_ts` BIGINT, `duration` BIGINT, PRIMARY KEY (`trace_id_high`, `trace_id`, `id`) ) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8 COLLATE utf8_general_ci; CREATE INDEX `trace_id` ON zipkin_spans (`trace_id_high`, `trace_id`); CREATE INDEX `name` ON zipkin_spans (`name`); CREATE INDEX `start_ts` ON zipkin_spans (`start_ts`); CREATE TABLE IF NOT EXISTS zipkin_annotations ( `trace_id_high` BIGINT NOT NULL DEFAULT 0, `trace_id` BIGINT NOT NULL, `span_id` BIGINT NOT NULL, `a_key` VARCHAR(255) NOT NULL, `a_value` BLOB, `a_type` INT NOT NULL, `a_timestamp` BIGINT, `endpoint_ipv4` INT, `endpoint_ipv6` BINARY(16), `endpoint_port` SMALLINT, `endpoint_service_name` VARCHAR(255), PRIMARY KEY (`trace_id_high`, `trace_id`, `span_id`, `a_key`, `a_timestamp`) ) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8 COLLATE=utf8_general_ci; CREATE INDEX `trace_id` ON zipkin_annotations (`trace_id_high`, `trace_id`, `span_id`); CREATE INDEX `endpoint_service_name` ON zipkin_annotations (`endpoint_service_name`); CREATE TABLE IF NOT EXISTS zipkin_dependencies ( `day` DATE NOT NULL, `parent` VARCHAR(255) NOT NULL, `child` VARCHAR(255) NOT NULL, `call_count` BIGINT, `error_count` BIGINT, PRIMARY KEY (`day`, `parent`, `child`) ) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8 COLLATE=utf8_general_ci;第一处要注意的是ROW_FORMAT=COMPRESSED。这要求 MySQL 的innodb_file_per_table是开启的(5.7 之后默认开启),某些云厂商的托管 MySQL 会禁用压缩行格式,导入时会报Unknown storage engine或Row size too large。真遇到了把COMPRESSED改成DYNAMIC也能跑,只是存储空间会大一些。
第二处是字符集。官方用utf8,在 MySQL 8.0 里这等价于utf8mb3,会有一堆 deprecation 警告。你可以改成utf8mb4,但zipkin_annotations的主键里a_key是VARCHAR(255),utf8mb4 下最长占 1020 字节,加上其他字段,索引长度还在 InnoDB 的 3072 字节上限内,实际测试没问题。不过为了避免不必要的风险,我倾向于保持官方写法不动。
第三处是 MySQL 8.0 的sql_mode。如果开了ONLY_FULL_GROUP_BY和严格模式,Zipkin 内部生成的某些统计 SQL 可能报错,建议给 zipkin 用的库单独放一个实例或 schema,把sql_mode调成宽松模式。
注意:DDL 里的
CREATE INDEX语句如果在重复执行时会报索引已存在,所以初始化脚本最好放在 MySQL 的/docker-entrypoint-initdb.d/目录下,这个目录只在数据目录为空时执行一次,天然幂等。
4.2 编排文件与关键环境变量
把 MySQL 和 Zipkin 串起来,docker-compose.yml大概这样写:
version: "3.7" services: mysql: image: mysql:8.0 container_name: zipkin-mysql restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: rootpwd MYSQL_DATABASE: zipkin MYSQL_USER: zipkin MYSQL_PASSWORD: zipkinpwd TZ: Asia/Shanghai command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_general_ci - --innodb_file_per_table=1 volumes: - ./mysql/data:/var/lib/mysql - ./mysql/init:/docker-entrypoint-initdb.d:ro networks: - zipkin-net zipkin: image: openzipkin/zipkin:2.24.3 container_name: zipkin restart: unless-stopped ports: - "9411:9411" environment: STORAGE_TYPE: mysql MYSQL_HOST: mysql MYSQL_TCP_PORT: 3306 MYSQL_DB: zipkin MYSQL_USER: zipkin MYSQL_PASS: zipkinpwd MYSQL_MAX_CONNECTIONS: 50 JAVA_OPTS: "-Xms512m -Xmx1g" depends_on: - mysql networks: - zipkin-net networks: zipkin-net: external: true环境变量里几个值得单独说:
STORAGE_TYPE=mysql是总开关,写成mysql就会加载 MySQL 存储实现。MYSQL_HOST用容器服务名最省事。如果你的 MySQL 在宿主机或远程,就填 IP。MYSQL_MAX_CONNECTIONS默认是 10,压测时容易成为瓶颈。按你预估的写入并发调,一般 30~50 就够。这个参数直接决定 HikariCP 连接池的大小。JAVA_OPTS里给堆内存设个上限。Zipkin 本身不重,1G 堆能撑住大部分场景,但要记得容器本身的内存限制要比堆大 30% 左右。
还有一种写法是用MYSQL_JDBC_URL直接给完整 JDBC 串,好处是能加时区、SSL 之类的参数:
environment: STORAGE_TYPE: mysql MYSQL_JDBC_URL: "jdbc:mysql://mysql:3306/zipkin?autoReconnect=true&useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai"我偏向这种写法,因为 MySQL 8.0 的驱动对serverTimezone比较敏感,不配的话有可能出现时间戳整体偏移 8 小时的现象——start_ts存的是微秒时间戳,理论上不受时区影响,但如果驱动在序列化时做了转换,UI 上的时间轴就会错乱。
4.3 验证数据真的写进去了
容器都起来之后,先看 Zipkin 日志有没有StorageComponent初始化成功的字样。然后用采集端跑几次请求,接着直接查库:
USE zipkin; SELECT COUNT(*) FROM zipkin_spans; SELECT trace_id, name, start_ts, duration FROM zipkin_spans ORDER BY start_ts DESC LIMIT 10;能查到数据,说明写入链路通了。再回到 UI 上按服务名查,能查到,说明读取链路也通了。
还有一步容易漏:依赖图(Dependencies 标签页)默认是空的。这不是配置问题,是 Zipkin 的设计——依赖关系数据由独立的任务zipkin-dependencies定期计算并写入,跟 trace 存储是分开的。MySQL 和 ES 都需要这个任务,MySQL 版的用法:
docker run --rm \ --network zipkin-net \ -e STORAGE_TYPE=mysql \ -e MYSQL_HOST=mysql \ -e MYSQL_USER=zipkin \ -e MYSQL_PASS=zipkinpwd \ -e MYSQL_DB=zipkin \ openzipkin/zipkin-dependencies:2.24.3用 cron 每天凌晨跑一次就行,跑完之后把当天的 span 聚合成zipkin_dependencies表里的记录。这个任务的资源占用不小,它会把当天的数据全部拉出来算,所以别跟业务高峰抢资源。
实操心得:
zipkin-dependencies跑完之后如果依赖图还是空,先确认它跑的日期跟你有数据的日期是不是同一批。它默认处理"昨天"的数据,本地测试刚灌进去的数据是今天的,自然算不出来,加个-e DATE=2024-05-14就能指定日期。
5. 持久化到 ES:索引策略比参数更重要
5.1 ES 单节点部署的几个必调参数
ES 在 Docker 里跑,有两个地方不处理根本起不来或者跑一会儿就挂。第一个是vm.max_map_count,宿主机的内核参数,必须调到 262144:
sysctl -w vm.max_map_count=262144 echo "vm.max_map_count=262144" >> /etc/sysctl.conf第二个是 JVM 堆。容器默认会按宿主机内存比例分配,宿主机内存大的时候 ES 会一口气吃掉大半,最后被 OOM Killer 干掉。显式设置ES_JAVA_OPTS,一般给容器内存的一半,不超过 31G(超过 31G 会禁用指针压缩,反而更慢)。测试环境 512M 就够:
elasticsearch: image: elasticsearch:7.17.10 container_name: zipkin-es restart: unless-stopped environment: discovery.type: single-node ES_JAVA_OPTS: "-Xms512m -Xmx512m" xpack.security.enabled: "false" TZ: Asia/Shanghai ulimits: memlock: soft: -1 hard: -1 volumes: - ./es/data:/usr/share/elasticsearch/data networks: - zipkin-netdiscovery.type=single-node是单节点必须的,不写的话 ES 会尝试组建集群然后一直卡在选举阶段。7.x 里xpack.security.enabled默认是 false,可以不写;8.x 里默认是 true,必须显式关掉,否则 Zipkin 连上去会返回 401。
还有一个常见坑是数据目录权限。ES 容器内是以elasticsearch用户(uid 1000)运行的,宿主机挂载的目录如果是 root 建的,容器会报AccessDeniedException。解决办法是先chown -R 1000:1000 ./es/data,或者干脆不挂载、用命名卷。
5.2 Zipkin 的 ES 环境变量:索引、分片、副本
ES 版本搞定之后,Zipkin 这边的配置反而不复杂:
zipkin: image: openzipkin/zipkin:2.24.3 container_name: zipkin restart: unless-stopped ports: - "9411:9411" environment: STORAGE_TYPE: elasticsearch ES_HOSTS: "http://elasticsearch:9200" ES_INDEX: zipkin ES_DATE_SEPARATOR: "-" ES_INDEX_SHARDS: 3 ES_INDEX_REPLICAS: 1 ES_HTTP_LOGGING: BASIC JAVA_OPTS: "-Xms512m -Xmx1g" depends_on: - elasticsearch networks: - zipkin-net逐个解释为什么这么配:
ES_HOSTS要带http://前缀。这个跟很多中间件不一样,我见过配成elasticsearch:9200然后一直连不上的。多个节点用逗号分隔,Zipkin 会做负载均衡。
ES_INDEX是索引前缀,默认zipkin。Zipkin 建的不是单一索引,而是按天滚动:zipkin-span-2024-05-14、zipkin-dependency-2024-05-14。ES_DATE_SEPARATOR控制日期分隔符,用-是为了跟 ES 的日期数学表达式兼容,方便后面做 ILM。
ES_INDEX_SHARDS默认 5,ES_INDEX_REPLICAS默认 1。单节点 ES 必须把 replicas 设成 0,否则每个索引都是 yellow 状态,看着像出问题了。分片数按每天的数据量估:单分片 30~50GB 是经验上限,日志类数据量不大时 1~3 个分片足够,分片太多反而拖慢查询。
ES_HTTP_LOGGING设成BASIC能把 ES 的请求日志打出来,排查连接问题时非常有用,调通之后可以关掉省日志量。
注意:
ES_INDEX_SHARDS和ES_INDEX_REPLICAS只对之后新建的索引生效,已经建好的索引不会变。改完参数之后,如果想验证,可以等到第二天看新索引的 setting,或者手动删掉当天索引让 Zipkin 重建(会丢当天数据)。
5.3 索引模板与生命周期
Zipkin 启动时会自动往 ES 写一个索引模板(template),名字类似zipkin,定义了分片、副本、字段映射。你不需要手动建索引,但需要确认模板写入成功:
curl -s "http://localhost:9200/_template/zipkin?pretty"如果返回 404,说明 Zipkin 没权限或者连接有问题,索引会被当成动态映射创建,字段类型可能不对,后面查询会出幺蛾子。
真正需要你操心的是清理。ES 不会自动删数据,索引会一天一个地堆下去,几天就把磁盘撑满。生产上要用 ILM(Index Lifecycle Management)做滚动和删除,或者用最简单的定时脚本按天删:
#!/bin/bash # 删除 30 天前的 zipkin 索引 CUTOFF=$(date -d '30 days ago' +%Y-%m-%d) curl -s -X DELETE "http://elasticsearch:9200/zipkin-span-${CUTOFF}" -o /dev/null -w "%{http_code}\n" curl -s -X DELETE "http://elasticsearch:9200/zipkin-dependency-${CUTOFF}" -o /dev/null -w "%{http_code}\n"放到宿主机的 cron 里每天跑。用 ILM 更优雅,但需要手动把 Zipkin 建的索引套进 policy,操作繁琐,小集群直接删索引更省事。
6. 双后端共存:MySQL 和 ES 同时写的三种路径
STORAGE_TYPE只能填一个值,Zipkin 原生不支持配置两个存储后端。但实际项目里确实有这种需求:MySQL 给业务方做快速排障的界面查,ES 给数据团队做耗时分析。可行的路径有三条。
6.1 客户端双上报
最直接的办法是在采集端配两个 reporter,一条发 Zipkin-A 一条发 Zipkin-B。以 Spring Boot 3.x 为例,可以自定义SpanHandler把同一个 span 同时交给两个 zipkin reporter:
@Bean public SpanHandler dualReporter() { Sender senderA = URLConnectionSender.create("http://zipkin-mysql:9411/api/v2/spans"); Sender senderB = URLConnectionSender.create("http://zipkin-es:9411/api/v2/spans"); AsyncReporter<Span> reporterA = AsyncReporter.create(senderA); AsyncReporter<Span> reporterB = AsyncReporter.create(senderB); return CompositeSpanHandler.create(reporterA, reporterB); }这种方式的好处是不用额外部署组件,坏处是客户端要改代码、要感知后端地址,而且上报流量翻倍。服务数量一多,配置管理会很烦。
6.2 中间层分发:Collector 或消息队列
更干净的做法是让应用只发一次,由中间层转发。两种常见形态:
一种是引入 OpenTelemetry Collector,配置两个 exporter,一个zipkinexporter 指向 MySQL 版,另一个指向 ES 版。客户端改成发 OTLP 到 Collector,Collector 负责扇出。这种方案灵活,可以在 Collector 里做采样、打标签、过滤,代价是多一个组件要运维。
另一种是走消息队列。Zipkin 支持从 Kafka 或 RabbitMQ 消费 span(STORAGE_TYPE=kafka或者作为 collector 的输入源),部署两个 Zipkin 实例,一个消费后写 MySQL,一个消费后写 ES。客户端只往 Kafka 发一份数据。这种方式吞吐最好,适合已经有机队规模的公司,但组件链路变长,排查问题时要多看一层。
6.3 三种方案怎么选
| 方案 | 客户端改动 | 额外组件 | 吞吐能力 | 适合场景 |
|---|---|---|---|---|
| 客户端双上报 | 大,每个服务改配置 | 无 | 一般,受客户端资源限制 | 服务少、临时过渡 |
| Collector 扇出 | 中,改成 OTLP 上报 | OpenTelemetry Collector | 好 | 已有 OTel 体系 |
| 消息队列分发 | 中,改成发 MQ | Kafka / RabbitMQ | 最好 | 大规模、已有 MQ 基础设施 |
我自己的选择顺序是:先从单后端开始跑,等真正出现"必须两个都留"的需求再上 Collector 扇出。很多团队一开始就想搞双写,结果两套都配得半拉子,反而增加了排查难度。先跑通一套,把采样、清理、监控都磨顺了,再加第二套。
7. 踩坑记录与排查速查表
7.1 MySQL 侧的高频问题
现象:Zipkin 启动后秒退,日志里有Communications link failure。九成是网络问题。先确认 Zipkin 容器能不能解析mysql这个主机名,用docker exec -it zipkin sh -c "ping mysql"试一下。如果用了自定义网络但容器没加入同一个网络,解析就会失败。
现象:能连上,但报Table 'zipkin.zipkin_spans' doesn't exist。初始化脚本没执行。/docker-entrypoint-initdb.d/只在数据目录为空时执行,如果你的./mysql/data里已经有数据,脚本会被跳过。要么清空数据目录重来,要么手动docker exec -i mysql mysql -uroot -p < 01-zipkin-schema.sql。
现象:数据写进去了,但 UI 上查不到。看start_ts字段的值。如果查出来是个十位数(秒级)或者十三位数(毫秒级),说明客户端传的时间戳单位不对,Zipkin 存的是微秒。UI 查询默认按时间倒序,时间戳偏移会导致数据落在"未来"或"很久以前",界面上就看不到。
现象:写入一段时间后连接池耗尽,报Connection is not available。把MYSQL_MAX_CONNECTIONS调大,同时确认 MySQL 的max_connections足够。另外注意长时间运行的zipkin-dependencies任务也会占连接,别把连接数卡得刚好。
7.2 ES 侧的高频问题
现象:报Unsupported Elasticsearch version或者连接超时。版本不匹配,回第 2 章看矩阵表,换镜像版本。
现象:启动正常,索引创建不出来,日志里有 403。ES 8 的 xpack security 开着,要么关掉,要么给 Zipkin 配账号密码(ES_USERNAME、ES_PASSWORD)。
现象:索引一直是 yellow。单节点配了 replicas=1,把ES_INDEX_REPLICAS设成 0。
现象:写入正常,查询很慢或者超时。单分片数据量太大,或者分片数太多。用_cat/indices?v看每个索引的 doc 数和存储大小,超过 50GB 就要考虑减少单日数据量(降采样)或者增加分片。
现象:容器反复重启,日志里有max virtual memory areas vm.max_map_count [65530] is too low。宿主机的内核参数没调,按 5.1 节处理。
7.3 数据链路类问题速查
这张表是我自己排障时用的,按"从客户端到存储"的顺序列:
| 检查点 | 怎么验 | 典型问题 |
|---|---|---|
| 客户端是否上报 | 抓包或开 DEBUG 日志看有没有 POST 到 9411 | 采样率为 0、endpoint 配错 |
| 上报协议是否正确 | POST /api/v2/spans返回 202 | 路径写成/api/v1/spans |
| Zipkin 是否收到 | Zipkin 日志里看collector相关记录 | 端口没映射、防火墙 |
| 存储是否写入 | 直接查库/查索引 | 建表缺失、索引创建失败 |
| 查询是否正常 | UI 按 traceId 搜 | 时间范围没选对、服务名拼错 |
| 依赖图是否有 | 看zipkin_dependencies表或zipkin-dependency-*索引 | 定时任务没跑 |
实操心得:排障时最有效的一招是"降级验证"——把
STORAGE_TYPE临时改回mem,如果内存模式下能看到数据,说明采集链路完全没问题,问题百分之百在存储层。这个动作能砍掉一半的排查范围。
8. 生产化之前必须算清楚的三笔账
8.1 采样率:全采样会把你打爆
测试环境probability: 1.0无所谓,生产环境这么配是灾难。算一笔账:假设日均 200 万次请求,每次请求平均产生 12 个 span,全采样就是每天 2400 万条 span。每条 span 在 MySQL 里带上注解平均占 800 字节到 1KB,一天的存储就是 20GB 以上,一个月 600GB。这还没算zipkin_annotations表,那张表的行数是 span 的数倍。
合理的采样率一般按"每天能接受的存储量"倒推。如果预算每天 5GB,按每条 1KB 算,就是 500 万条 span,除以(200 万请求 × 12 span)≈ 20%。所以 10%~20% 是个常见区间。
采样策略上,probability是固定比例采样,简单但会漏掉低频接口的异常。更好的做法是尾部采样——先全量收集,再在 Collector 层按规则决定留哪些(比如所有带 error 的、P99 以上的、指定用户的全部保留)。OpenTelemetry Collector 的tail_samplingprocessor 支持这种玩法,代价是需要缓冲,资源开销更高。
8.2 数据清理:不清理迟早磁盘告警
MySQL 侧,Zipkin 不会自动删数据,得自己写。核心是删zipkin_spans和zipkin_annotations里start_ts过旧的行:
-- 保留 7 天,分批删避免长事务锁表 DELETE FROM zipkin_annotations WHERE trace_id IN ( SELECT trace_id FROM ( SELECT trace_id FROM zipkin_spans WHERE start_ts < UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 7 DAY)) * 1000000 LIMIT 10000 ) t );一定要分批,一次删几百万行会把 InnoDB 的 undo log 撑爆,还会阻塞写入。建议写成脚本循环执行,每批 5000~10000 行,批间 sleep 几百毫秒。
ES 侧用索引删除,按天删整个索引,比 delete by query 快几个数量级,这也是 ES 方案在长期存储上的优势之一。
8.3 资源与监控:给 Zipkin 本身也加上观测
Zipkin 暴露了 Prometheus 格式的指标,在 9411 端口的/metrics。加上-e METRICS_TYPE=prometheus就能被采集。几个关键指标值得盯:
| 指标 | 含义 | 告警建议 |
|---|---|---|
zipkin_collector_spans_dropped_total | 被丢弃的 span 数 | 持续大于 0 说明存储跟不上 |
zipkin_collector_messages_total | 收到的 span 总数 | 突降说明客户端出问题 |
zipkin_storage_*_latency | 存储层延迟 | P99 超过阈值告警 |
zipkin_collector_bytes_total | 网络吞吐 | 用来估容量 |
spans_dropped_total是最该盯的一个。Zipkin 在写入压力大时会丢弃 span 而不是阻塞,保证自身不垮,但这也意味着数据静默丢失。这个值一旦持续增长,先看存储层的写入延迟,再看是不是 MySQL 的连接池或磁盘 IO 到顶了。
容器资源限制上,Zipkin 给 1~2 核、1~2G 内存通常够用;MySQL 按数据量给,日增 20GB 的库建议至少 4 核 8G 加 SSD;ES 是三者里最吃资源的,堆内存给到容器内存的一半,磁盘用 SSD,单节点至少 4 核 8G 起步。
最后一件事,Zipkin 的 UI 是只读的,没有任何权限控制,暴露到公网等于把内部服务拓扑全公开了。要么只在内网访问,要么在前面挂一层带认证的反向代理。我见过有团队直接把 9411 映射到公网 IP,后来发现有人拿它扫内部服务名,这个口子必须堵。
我自己在多套环境里配下来,最省心的组合始终是"先在内存模式下把链路跑通,再切 MySQL 或 ES,最后补上采样和清理"。跳过验证直接上持久化,出问题时面对的是黑盒;跳过清理直接上生产,三个月后就得面对磁盘告警。这两步看起来慢,实际上省掉的是后面几倍的排障时间。