做视频点播系统做了三四年,最头疼的往往不是视频转码算法,也不是CDN调度,而是那些“看起来很简单、出问题就全链路崩”的分布式协调问题。比如上线高峰期两台转码机同时消费了同一个任务,比如播放器配置改了十分钟还没拉到新值,比如分片上传遇到节点宕机后不知道哪些片该重传。我是在第二次重构点播系统时彻底接入 Etcd-SDK 的,之后这些问题的定位速度明显上了一个台阶。这篇文章就把我在视频点播项目里用 Etcd-SDK 的经验完整梳理一遍,从选型思路、核心API、落地场景到线上踩坑,适合正在做点播、直播录制、媒体处理这类系统的后端开发或架构同学参考。
1. 为什么视频点播系统会需要Etcd-SDK
很多人一听到 Etcd 就想到 K8s 服务发现,觉得“我又不是做容器平台的,用不上”。这是对 Etcd 最大的误解。视频点播系统本质上是一个“媒资生产 + 分发”的分布式系统,里面有大量需要跨节点达成一致的状态,而这些状态用数据库硬撑会非常痛苦,用 Redis 又缺少一些关键保障,这时候 Etcd 的价值就出来了。
1.1 点播系统里的三类典型分布式问题
先把我项目里真切遇到的三个问题摆出来,大家对照一下自己的系统。
第一类是任务互斥。点播上传完成后要触发转码,转码任务可能是多档位(标清、高清、超清),如果用户传的是一小时的长视频,转码耗时很长。为了提速,我最初用 MySQL 的行锁实现“同一视频同一时间只允许一个转码任务执行”,结果单库扛不住高并发写入,又引入消息队列削峰,可消费者一多就有重复消费。两个转码节点同时拿到同一个任务ID,结果重复转码、重复计费、重复回调。
第二类是元数据一致性。视频的转码状态、分片上传进度、播放器配置、封面图生成状态,这些数据分散在多个服务和多张表里。业务上要求“用户上传完成后能立刻看到‘处理中’,转码完成后立刻变成‘可播放’”。在微服务架构下,如果没有一个可靠的“状态中枢”,就会出现用户看到的状态和自己的操作对不上。
第三类是节点协调与热更新。点播系统有转码节点、截图节点、审核节点、分发节点,这些节点要动态扩缩容。新增一个转码节点后,需要把节点注册到某个地方,让调度模块知道它的处理能力;同时转码模板、水印参数、播放鉴权开关这类配置,如果只能改完代码重新发布,运维成本极高。
MySQL 能解决部分问题,但高并发下锁竞争严重,而且难以提供 TTL 自动过期这类能力;Redis 能解决锁和缓存,但它的分布式锁在极端场景下(GC 停顿、网络分区)有失效风险,而且没有原生的 Watch 机制做事件订阅,配置变更需要轮询,延迟和负载都不理想。最终我选了 Etcd,因为它在一致性、TTL 租约、Watch 监听这三个维度上正好覆盖了上面的问题。
1.2 SDK在技术栈中的定位
引入 Etcd-SDK,并不是要拿它替代数据库,而是把它定位成“分布式协调基础设施层”。在我的架构里,Etcd 承担三类职责:服务注册与发现(转码节点、截图节点的状态上报)、分布式锁与选主(任务调度互斥)、配置中心 + 状态机存储(播放配置、任务状态、分片进度)。
层和层之间的调用关系大概这样:客户端上传视频后,接入层把“待转码任务”写入 Etcd 的某个前缀目录;转码节点启动时通过 Etcd 的 Lease 注册自身节点信息,并且用 Watch 监听任务前缀;转码完成后,节点更新任务状态并触发回调服务。整个过程里,Etcd-SDK 就是上层业务和 Etcd 集群之间的桥,所有 API 调用、连接管理、异常重试都在 SDK 层完成。
所以与其说“ Etcd-SDK 有什么用法”,不如先想清楚:你的点播系统里,哪些状态需要跨节点达成一致?哪些节点需要知道彼此存在?哪些配置需要热更新? 这些问题想清楚了,SDK 用起来就有了目标。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Etcd-SDK 的核心接口与调用逻辑
Etcd 官方仓库提供的 client 库就是我们说的 Etcd-SDK。不同语言的 SDK 虽然包名不同,但核心抽象完全一致:KV、Lease、Watch、Txn 四个对象。下面我用 Java 的 jetcd 和 Go 的 clientv3 双语言对照来讲,视频点播后端最常见的两种技术栈都能覆盖。
2.1 连接初始化与客户端复用
这一步看起来简单,但坑最多。很多人为了省事,每次操作都新建一个 client,这在低并发验证时看不出问题,一旦点播任务量上来,gRPC 连接数会爆增,Etcd 集群的连接数很快被打满。
Java 侧的正确姿势是使用单例客户端:
java复制// jetcd 客户端构建,注意使用单例,避免反复创建连接
Client etcdClient = Client.builder()
.endpoints("http://10.0.0.11:2379", "http://10.0.0.12:2379", "http://10.0.0.13:2379")
.user(ByteSequence.from("root", StandardCharsets.UTF_8))
.password(ByteSequence.from("yourpass", StandardCharsets.UTF_8))
.build();
Go 侧对应的是:
go复制cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"10.0.0.11:2379", "10.0.0.12:2379", "10.0.0.13:2379"},
DialTimeout: 5 * time.Second,
})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
两点提醒。第一,endpoints 一定要配置集群的全部地址,不要只配一个。否则某个节点临时故障时 SDK 无法自动切换到其他节点,整个点播系统会一起抖动。第二,client 要复用,由 Spring 容器或 Go 的全局变量管理生命周期,不要每次请求都 New。
2.2 KV 操作:任务状态与元数据的存取
KV 是使用频率最高的接口。视频点播系统里,一个转码任务的状态字典可以存在 Etcd 里,key 设计成 /vod/task/{taskId}/status,value 存 JSON 或纯文本状态。
Java jetcd 写法:
java复制KV kvClient = etcdClient.getKVClient();
// 写入状态
kvClient.put(
ByteSequence.from("/vod/task/" + taskId + "/status", StandardCharsets.UTF_8),
ByteSequence.from("transcoding", StandardCharsets.UTF_8)
).get();
// 读取状态
GetResponse resp = kvClient.get(
ByteSequence.from("/vod/task/" + taskId + "/status", StandardCharsets.UTF_8)
).get();
Go clientv3 写法:
go复制ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err = cli.Put(ctx, "/vod/task/"+taskId+"/status", "transcoding")
resp, err := cli.Get(ctx, "/vod/task/"+taskId+"/status")
有一个很重要的设计习惯:key 按层级组织,并配合前缀查询。比如一个视频的所有分片可以放在 /vod/file/{fileId}/part/1、/vod/file/{fileId}/part/2 下,查询时只需要 get 前缀 /vod/file/{fileId}/part/ 就能拿到全部分片信息,不需要遍历数据库关联表。Etcd 的 key 是按字典序排列的,这个特性用好了非常省事。
2.3 Lease 租约:节点心跳与临时状态
Lease 是 Etcd 里最“分布式”的机制。它的作用相当于给 key 绑定一个 TTL,到期后 key 自动删除。视频点播场景下,我用它做两件事:注册转码节点的心跳、以及给“正在转码”的任务一个超时保护。
Java jetcd 示例:
java复制Lease leaseClient = etcdClient.getLeaseClient();
// 申请一个10秒的租约
LeaseGrantResponse grantResp = leaseClient.grant(10).get();
long leaseId = grantResp.getID();
// 用租约绑定key
kvClient.put(
ByteSequence.from("/vod/node/" + nodeId + "/alive", StandardCharsets.UTF_8),
ByteSequence.from("online", StandardCharsets.UTF_8),
PutOption.newBuilder().withLeaseId(leaseId).build()
).get();
// 续约:需要后台持续调用keepAlive
leaseClient.keepAlive(leaseId, new Executor() { ... });
这里必须理解 keepAlive 和 grant 的区别:grant 只是申请一个租约,绑定了 key 之后,如果没人续约,租约到期 key 就没了;keepAlive 是持续地告诉 Etcd“我还活着,请续期”。所以节点进程存活期间必须保持续约,进程退出后租约自然过期,节点状态被自动清理。这个机制比手动删除干净得多,因为即使节点被 kill -9,Etcd 也能在几秒内自动发现节点失联。
2.4 Watch 监听:配置热更新与任务派发
Watch 是 Etcd 的推送式监听接口,业务方不必轮询,Etcd 会在 key 变化时主动推送事件。点播系统里我重度依赖这个能力:转码节点 Watch 任务前缀,新的转码任务一写入,节点立刻收到事件,马上拉取任务执行,延迟通常在毫秒级。
Java jetcd 中实现一个简单的 Watch:
java复制Watch watchClient = etcdClient.getWatchClient();
Watch.Watcher watcher = watchClient.watch(
ByteSequence.from("/vod/task/", StandardCharsets.UTF_8),
WatchOption.newBuilder().isPrefix(true).build(),
new Watch.Listener() {
@Override
public void onNext(WatchResponse response) {
for (WatchEvent event : response.getEvents()) {
String changedKey = event.getKeyValue().getKey().toStringUtf8();
String value = event.getKeyValue().getValue().toStringUtf8();
// 收到新任务,触发转码逻辑
dispatchTask(changedKey, value);
}
}
@Override
public void onError(Throwable throwable) { ... }
@Override
public void onCompleted() { ... }
}
);
Go 侧更简洁:
go复制watchChan := cli.Watch(context.Background(), "/vod/task/", clientv3.WithPrefix())
for watchResp := range watchChan {
for _, ev := range watchResp.Events {
fmt.Printf("key:%s value:%s\n", ev.Kv.Key, ev.Kv.Value)
// 处理任务
}
}
Watch 的事件类型有三种:PUT(写入/更新)、DELETE(删除)。在状态机流转里,我通常把“创建任务”和“完成任务”都对应到 PUT 事件,只是 value 不同;只有删除任务时才是 DELETE 事件。这样的好处是,如果某个节点在处理任务时超时了,后续恢复时能通过查询当前 value 继续处理,而不是依赖内存里的状态。
2.5 Txn 事务:分布式锁的正确实现
如果只想做“抢任务”,用 Put 加 if not exist 类似思路也行,但 Etcd 的 Txn 提供了更规范的能力:在一个事务里执行“比较-执行”。
比较常见的是“如果 key 不存在,则写入;如果存在,则失败”。视频点播里我用它做“单视频单转码任务”的幂等保护。Java jetcd 示例:
java复制Transaction txn = kvClient.txn();
// 如果锁key不存在
txn.If(new Cmp(
ByteSequence.from("/vod/lock/" + videoId, StandardCharsets.UTF_8),
Cmp.Op.EQUAL,
ByteSequence.from("", StandardCharsets.UTF_8)
));
// 则写入并设置租约
txn.Then(Op.put(
ByteSequence.from("/vod/lock/" + videoId, StandardCharsets.UTF_8),
ByteSequence.from(nodeId, StandardCharsets.UTF_8),
PutOption.newBuilder().withLeaseId(leaseId).build()
));
txn.Else();
TxnResponse txnResp = txn.commit().get();
if (txnResp.isSuccess()) {
// 拿到了锁
} else {
// 锁被别人持有了
}
Go 侧对应:
go复制resp, err := cli.Txn(ctx).
If(clientv3.Compare(clientv3.CreateRevision("/vod/lock/"+videoId), "=", 0)).
Then(clientv3.OpPut("/vod/lock/"+videoId, nodeId, clientv3.WithLease(leaseID))).
Commit()
注意 Go 里我用的是 CreateRevision == 0 来判断 key 不存在,这是一个常见的技巧:key 没创建过,它的 CreateRevision 就是 0,用这个条件做原子创建非常可靠,比先 Get 再 Put 的“伪原子操作”安全得多。
3. 在点播系统中落地的四个典型场景
有了上面的 API 基础,下面给四个我在真实业务里已经跑通的场景。这些都是可以直接抄作业的设计,不涉及过多业务细节,但结构完整。
3.1 转码任务状态机与节点失联自愈
点播转码链路涉及多个状态:pending(排队)、transcoding(转码中)、completed(完成)、failed(失败)。我用 Etcd 存储状态,用 Lease 解决“节点宕机导致任务卡死”的问题。
具体做法是:调度模块生成任务时,把任务状态写入 /vod/task/{taskId}/status,同时把任务分配给某个转码节点,写入 /vod/task/{taskId}/owner,值为节点 ID,并给 owner 绑定一个 30 秒的租约。转码节点在处理任务期间,持续对该租约续约。如果节点正常完成,就把状态改成 completed,并删除 owner key;如果节点宕机,租约 30 秒后过期,owner key 自动消失。
调度模块 Watch /vod/task/{taskId}/owner 的 DELETE 事件,一旦发现某个任务的 owner 消失了而 status 仍是 transcoding,就说明执行节点挂了,立刻把状态回退到 pending,重新分配。这个方案实现了“节点失联自动恢复”,整个过程不需要额外的故障检测服务,完全依靠 Etcd 的 Lease 机制。
3.2 分片上传进度的断点续传
大视频文件上传时分片数量可能上百。客户端如果传了一半断网,要能从上一次的进度继续而不是重头传。传统做法是服务端维护一个上传进度表,但“记录哪一个分片已上传、哪一个分片校验失败”这种高频变更操作,写数据库压力不小。
我的方案是把分片进度放到 Etcd 里,key 设计为 /vod/upload/{fileId}/part/{partNumber},value 存该分片的 md5 或上传状态。客户端每次上传前,先查询 /vod/upload/{fileId}/part/ 前缀,就能知道哪些分片已经传完了;服务端收到分片后 Put 状态,并用事务 Cmp 保证幂等。
这个方案的额外好处是:查询和处理进度都在同一个服务内完成,通过 Watch 可以实时同步多个上传节点之间的状态。如果用户同时用两个设备上传同一个文件(某些场景允许),两个设备能实时看到对方传了哪些分片,不会出现重复传输。
3.3 播放器配置与转码模板的热更新
点播系统经常需要调整播放器参数,比如起播缓冲时间、拖动 seek 的步长、是否开启弹幕,还有转码模板里的码率、分辨率档位。这些配置如果写在本地配置文件里,每次修改都要发版;如果写在数据库里,各节点感知变更需要轮询,有几十秒的延迟。
我用 Etcd 做配置中心,key 前缀 /config/player/ 和 /config/transcode/,value 存 JSON。所有应用启动时拉取一次配置,然后在后台启动一个 Watch。配置变更后 Etcd 秒级推送给所有节点,节点收到事件后刷新本地缓存。
这里有个细节:Watch 回调里不要直接改业务对象,建议先更新本地缓存,再通过事件总线通知业务模块。因为我遇到过 Watch 回调线程和业务线程同时读写同一个配置对象导致的并发问题,后来统一成“回调只写 ConcurrentHashMap,业务只读缓存”的模式,没有再生过问题。
3.4 多节点抢占转码任务的分布式锁
点播集群的转码节点是动态扩缩容的,晚上流量高就多拉起几个转码节点,白天闲时就缩容。每个节点启动后,去抢 /vod/lock/resource/transcode 这个锁,抢到的节点才真正消费任务队列。这个锁不需要长期持有,只需要保证“同一时刻只有一个节点在执行任务分发逻辑”。
用 Txn 实现锁比我之前用的 Redis SETNX 更稳妥的地方在于:Etcd 的锁依赖租约自动释放,不会因为持有锁的节点卡顿或 Full GC 而导致死锁;而且 Etcd 的锁语义更明确,获取锁、续约、释放都有对应的 API。实际使用中我把锁的 TTL 设为 15 秒,任务分发逻辑每秒检查一次是否需要执行,正常情况下续约不断,不会出现锁过期导致两个节点同时分发任务的窗口。
4. 接入Etcd-SDK时容易踩的坑
Etcd-SDK 用起来不难,但生产环境里出问题,十有八九不是 API 不会调,而是对连接生命周期、资源释放、异常分支的理解不够。我把踩过的坑列出来,每条都是真金白银换来的。
4.1 连接不复用导致的grpc连接风暴
上线第一版时,我在每个任务处理的方法里都创建了一个新的 jetcd Client,本地测试完全正常。结果压测一跑,Etcd 集群疯狂报警,连接数从几十飙到几千,直接打满了节点的文件描述符。
排查下来就是“每次新建 Client”的问题。gRPC 连接建立是有开销的,而且每个连接会占用 Etcd 端的 socket 资源。后来的规范是:每个服务进程全局只建一个 Client,所有模块共享;Client 内部自带连接池和负载均衡,不需要业务层自己做路由。
4.2 Watch未关闭导致的资源泄漏
Watch 是个长连接,创建后如果不主动 Close,会一直存活。我在做转码任务分发时,最初每天创建一个 Watcher 却没有关闭,结果服务运行一周后,线程数和内存稳步上涨,最终 OOM。
教训是:Watcher 的生命周期要和业务对象绑定。如果只是监听某个任务的短暂状态变化,用完后要主动 watcher.close();如果是监听全局任务前缀的长期 Watcher,要确保它是单例,并且跟随应用退出而关闭。另外,Watch 回调里如果抛出异常,jetcd 和 clientv3 都会在内部处理连接重连,但业务层要注意自己的处理逻辑必须 try-catch 包裹,不能把异常抛到 SDK 内部,否则事件会静默丢失。
4.3 序列化协议不统一导致的跨语言兼容问题
点播系统里 Java 和 Go 服务并存,这就出现了一个实际问题:Java 服务写入的 value,Go 服务能不能直接读出来?答案是可以,但前提是序列化协议要统一。
最初我用 Java 原生序列化写入任务对象,Go 服务读出来后是乱码。后来全部统一成 JSON,value 直接存 JSON 字符串,所有语言通用。还有一个容易忽略的点:key 也用字节数组存储,但编码要统一为 UTF-8。如果 Java 用 UTF-8,Go 用默认编码,在涉及中文 key 时会出现匹配不上、前缀查询漏数据的问题。
4.4 大Value与会话超时
这个坑比较隐蔽。我在一期需求里把一个视频的完整转码产物清单(包含所有分片的 CDN URL)塞进了一个 value,大小接近 1MB。结果写入时频繁超时,读取时也慢,甚至拖累了 Watch 性能。
Etcd 的设计初衷是存储小元数据,value 建议不要超过 1MB,实际最佳实践是控制在 100KB 以内。而且单个 key 过大时,Watch 仍然会返回完整的 key-value,导致每次变更传出大量数据。后来我把大对象拆成:元数据存 Etcd,数据体存对象存储,Etcd 里只放一个 URL 引用,问题立刻消失。
4.5 集群地址配置为单个节点
有一个运维事故:某个环境里 Etcd 集群有三个节点,但配置文件里只写了第一个节点的地址。平时负载不高看不出来,直到那个节点做重启,整个点播系统的配置热更新全部中断,因为 SDK 不会自动发现其他节点。
正确的配置永远是三节点地址全写上,并且放在同一个 endpoints 列表里。SDK 内部会自动做负载均衡,某个节点故障时自动切换到可用节点。
5. 生产环境的参数配置与一次故障复盘
最后分享一些我在生产环境里的参数经验,这些数值来自实际压测和线上运行,可以根据自己的集群规模微调。
5.1 Etcd-SDK的推荐配置参数
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| DialTimeout | 5s | 客户端连接超时,太短容易误判 |
| RequestTimeout | 3s ~ 5s | 单次请求超时,转码场景一般 3s 够用 |
| AutoSyncInterval | 1min | 自动同步 endpoints 列表,应对集群扩缩容 |
| MaxCallSendMsgSize | 10MB | 允许发送大对象,但要避免真用大对象 |
| KeepAliveTime | 30s | gRPC 层探活周期,值太小会增加网络包数量 |
| KeepAliveTimeout | 10s | 探活无响应判定超时,超过直接重连 |
Java jetcd 在构建 Client 时可以通过 ClientBuilder 设置这些参数,Go 侧在 clientv3.Config 里直接配置。这些参数默认值在开发环境没问题,生产环境务必显式设置,不要依赖默认。
5.2 一次线上故障的完整排查链路
那次故障发生在一次大促压测期间,现象是:转码任务大量堆积,Etcd 集群 CPU 升高,但并未达到瓶颈,点播系统的 Watch 事件延迟从几十毫秒飙升到十几秒。
我先查了转码节点的日志,发现客户端侧大量 context deadline exceeded 报错;再看 Etcd 监控,发现 etcd_server_leader 正常,但 etcd_network_client_grpc_received_bytes_total 涨得很快。进一步抓包发现,有个服务在循环 Get 一个大 key 前缀,每次返回的数据量有几十MB,因为我把分片列表整个放到了同一个 key 下。
根因就是我在上一条说的“大 Value + 高频读”。修复分三步:第一,把分片列表从 Etcd 拆出,改为数据落对象存储、Etcd 只存引用;第二,业务层对查询加本地缓存,减少重复读;第三,给某些不重要的查询设置独立的短超时,避免长时间占用连接。压测再跑,Watch 延迟回到了几十毫秒。
5.3 SDK版本与集群版本的兼容性
这个也算老生常谈,但确实踩过。某次升级 Etcd 集群从 3.4 到 3.5,结果 Java 服务里用的 jetcd 0.5.x 一直报 Unimplemented 错误。查文档发现,旧的 jetcd 版本对 3.5 的某些 API 支持不完整。
建议大家升级集群前先核对 SDK 兼容性矩阵。Go clientv3 对 3.4 和 3.5 支持都很稳,Java jetcd 尽量用 0.7.0 或更高版本。另外,不要把 SDK 版本锁得太死,至少每半年跟随社区版本更新一次,避免累积太多兼容性债务。
6. 写在实践之后的几点体会
Etcd-SDK 本身不复杂,复杂的是理解它背后那套“分布式协调”的思路。我个人的体会是,要把它用好在视频点播系统里,至少得把握三个程度:第一,熟悉 KV、Lease、Watch、Txn 这四个核心抽象,知道什么问题用哪个解决;第二,在动手写代码前先设计好 key 的层级和前缀,因为 Etcd 的 key 结构一旦上线后调整成本很高;第三,把连接管理、资源释放、异常处理当成一等公民,不要觉得“SDK 封装好了就不需要关心”。
如果你正在做点播系统,或者其他的媒资处理平台,我建议先不要急着把所有协调逻辑都往 Etcd 里塞,而是从“任务状态机 + 节点心跳 + 配置热更新”这个最小集开始,跑通了再扩展分片进度、分布式锁这些场景。Etcd 集群本身就三个节点起步,稳得很,真正限制系统上线速度的往往是我们在代码里有没有把边界条件处理好。
