主要内容 :

  • etcd 数据结构设计 ;
  • 构建可运行的注册中心 ;
  • 搭建 etcd 集群并在 Dubbo 中运行 。

着重从扩展 Dubbo 新注册中心方面入手 , 重点说明深入开发 Dubbo 注册中心需要关注的点 。 首先讲解 etcd 数据结构要如何设计 , 然后讲解构建可运行的 etcd 注册中心扩展的接口的实现步骤 , 最后把实现的扩展注册中心在 Dubbo 中运行

1 etcd 背景介绍

etcd 是一种分布式键值存储系统 , 它提供了可靠的集群存储数据的途径 。 它是开源的 , 可以在 GitHub 上找到它的源码 。 etcd 使用了 Raft 算法保证集群中数据的一致性 , 当 leader 节点下线时会自动触发新的 leader 选举 , 以此容忍机器的故障 。 应用可以在 etcd 中读写数据 , 例如 :把一些参数性的信息通过 key-value ( 键值对 ) 形式写入 etcd, 这些数据可以被监听 , 当数据发生变化的时候 , 可以通知监听者 。 etcd 还有其他高级特性 , 感兴趣的读者可以访问其官网查看 ,本书就不再赘述

虽然 Dubbo 默认支持 ZooKeeper 和 Redis 等注册中心实现 , 但是生产环境中使用较多的还是 ZooKeepero 后起之秀 etcd 广泛应用于 Kubernates 中 ( 用于服务发现 ) , 经过了生产环境的考验 。 相比于 ZooKeeper 实现 , 基于 etcd 实现的注册中心有很多优点 , 例如 : 不需要每次子节点变更都重新全量拉取节点数据 , 大大降低了网络的压力 。

支持 etcd 注册中心的原因非常简单 , 可以让 Dubbo 支持更多的注册中心 , 丰富 Dubbo 生态 。 在正式讲解实现原理前 , 我们先看一下 etcd 注册中心的一些优点 :

  • ( 1 ) etcd 使用增量快照 , 可以避免在创建快照时暂停 。
  • ( 2 ) etcd 使用堆外存储 , 没有垃圾收集暂停功能 。
  • ( 3 ) etcd 己经在微服务 Kubernates 领域中有大量生产实践 , 其稳定性经得起考验 。
  • ( 4 ) 基于 etcd 实现服务发现时 , 不需要每次感知服务进行全量拉取 , 降低了网络冲击 。
  • ( 5 ) etcd 具备更简单的运维和使用特性 , 基于 Go 开发更轻量 。
  • ( 6 ) etcd 的 watch 可以一直存在 。
  • ( 7 ) ZooKeeper 会丢失一些旧的事件 , etcd 设计了一个滑动窗口来保存一段时间内的事件 , 客户端重新连接上就不会丢失事件了 。

除此之外 , etcd 支持很多跨语言客户端直接通信 , 目前 etcd 是 Dubbo 生态中一部分 , 相信未来会有更多注册中心生态加入进来 。

2 etcd 数据结构设计

etcd注册中心只支持最新 v3 版本的 APE 在 v3 版本的 etcd 实现中 , 所有元数据信息都是基于key-value ( 键值对 ) 存储的 , 和 ZooKeeper 中节点和子节点不同 , etcd 存储是通过前缀区分的 。在 ZooKeeper 中有临时节点的概念 , 它是通过 TCP 连接状态断开自动删除临时节点数据的 。

etcd 注册中心也有临时节点的概念 , 但不是根据 TCP 连接状态 , 而是根据租约到期自动删除对应的 key 实现的 。 当 provider 和 consumer 上线时 , 会自动向注册中心写临时节点 。 可能有读者会有疑问 , 通过租约到期删除 key 会不会不可靠 ? 当 JVM 关闭时, Dubbo 会触发优雅停机逻辑 ,也会及时删除临时 key, 所以问题不大 。

其实 etcd3 没有树的概念 , etcd3 里面都是平铺展开的键值对 , 我们可以把展开的键值对抽象成树的概念 , 使其与 ZooKeeper 的模型保持一致 , 降低其他开发者针对每个注册中心都必须重新理解一遍模型的成本 。

在 etcd3 注册中心的存储结构中 , 我们会按照树状结构平铺展开来举例

(1) 接口子目录存储为 key-value 格式

注册redis账户 redis实现注册中心_元数据


(2) 临时节点存储为 key-value 格式

注册redis账户 redis实现注册中心_分布式_02

假设服务提供者接口为 com.alibaba.service.HelloService, 精简后的数据模型接口如下:

注册redis账户 redis实现注册中心_分布式_03


这里有意地画出了树状结构 , 每个层级都代表 etcd 中的 key, 非临时节点默认存储的是Hash 值 , 临时节点中存储的是 key 关联的租约 id 。

这里的模型做了简化 , 主要想表达在服务注册和发现过程中 , 服务提供者和消费者都会将自己的 IP 和端口等相关信息写入对应的 key-value 中 。 存储到注册中心的任何特殊字符都会被编码 , 比如 URL 中包含的 “ / ” 字符 , 在 etcd3 注册中心内部已经调用 URLEncode 进行特殊字符处理了

3 构建可运行的注册中心

在构建生产可用的版本前 , 要考虑的因素比较多 , 比如新的注册中心临时节点如何保活 、如何可扩展 、 如何降低网络拉取压力和如何兼容服务治理平台等因素 。 在实现 etcd 注册中心前 , 首先要考虑使用哪种客户端与 etcd server 通信 , 目前虽然采用官方的 jetcd 作为默认客户端 , 但提供了 SPI 扩展 , 为将来其他客户端替换它提供了可能(类似在使用 Zookeeper 时 ,将 curator 客户端替换成 zkclient) o 接下来 , 我们针对注册中心按照相关扩展逐个探讨 。

3.1 扩展 Transporter 实现

为了支持未来采用其他客户端与 etcd server 交互 , 我们需要提供一个新的扩展点EtcdTransporter, 在注册中心初始化时会通过这个 transporter 初始化真实的交互 client。 我们先看一下这个扩展点定义

tranporter 扩展点定义

注册redis账户 redis实现注册中心_数据_04


这个扩展点主要用于返回一个实现了 EtcdClient 接口的真实客户端 , 客户端查找交给Dubbo 框架实现 , 主要通过 URL 中的 TRANSPORTER_KEY 和 CLIENT_KEY 对应值依次查找 。 实现这个扩展点非常直截了当 , 直接返回具体客户端即可transporter 扩展点实现

注册redis账户 redis实现注册中心_数据_05


在实现扩展点之后 , 还需要将扩展点通过 SPI 的方式配置在项目中 , ExtensionLoader 才能正确查找和加载 , 将这个实现放到 resource/META-INF/dubbo/internal/org.apache.dubbo.remoting.etcd. EtcdTransporter 文件中 , 文件内容也是键值对形式 , 其中 key 代表扩展点名称 ,value 代表扩展点具体实现类 , 文件内容如下

注册redis账户 redis实现注册中心_元数据_06


因为这个是默认扩展点的实现 , 可以发现 jetcd 作为 key 和代码清单 SPI(jetcd) 值是一致的 , 默认会加载当前实现

3.2 扩展 RegistryFactory 实现

具体扩展点实现之后 , 我们要考虑在哪里使用 , 当 provider 或 consumer 启动时 , 会创建注册中心实例 RegistryProtocol#getRegistry, 这里通过另外一个扩展点加载 Factory, 这个新的扩展点是 RegistryFactory, 所有注册中心必须提供一个对应实现 , 我们先看一下这个新扩展点定义

RegistryFactory 扩展点定义

注册redis账户 redis实现注册中心_元数据_07


这个扩展点包含 SPI(dubbo), 代表默认使用基于内存的 Dubbo 注册中心实现 , 生产环境不会使用这个实现 。 这个扩展点只有一个接口方法 , 返回具体注册中心实例 , 因此 , 我们定义的EtcdTransporter 一定在这个接口内部使用 。

所有扩展 RegistryFactory 扩展点都应该继承 AbstractRegistryFactory 类 , 因为它提供了注册中心创建的 cache 及 JVM 销毁时删除注册中心中的服务元数据方法 。 在AbstractRegistryFactory#getRegistry 中会先检查是否已有实例 , 否则加锁创建具体注册中心 。

这里有两点值得注意 , 第一点 , 在生成 cache key 的时候 , 不应该调用注册中心 host 域名解析 ,否则可能因为 DNS 解析慢影响性能 。 第二点 , 不解析 host 是有好处的 , 运维人员维护 etcd 节点时 , 交给具体 client 处理域名转换的事情

在 AbstractRegistryFactory#getRegistry 中通过 createRegistry 方法创建注册中心 , 这个是抽象方法 , 交给开发者实现 , 因此 , 在实现 etcd 时我们需要提供 EtcdRegistryFactory 对应实现 ,

EtcdRegistryFactory 实现扩展点

注册redis账户 redis实现注册中心_分布式_08


这里有两处需要注意 , 在①中直接返回注册中心实例 , 在 Dubbo 框架实现中 , 所有扩展点加载都是单例的 。 可能有读者疑惑 etcdTransporter 属性在哪里赋值呢 ? 很容易想到这个是 ExtensionLoader 自动注入的 , 加载具体 SPI 时 , 会获取所有 set方法尝试注入对应的实例引用 , 在②中会发现 setEtcdTransporter 方法 , 查找 EtcdTransporter扩展点并自动注入 , 因此在创建初始化 EtcdRegistryFactory 时就注入好了 。

3.3 新增 JEtcdClient 实现

在讲解具体实现前 , 我们先看一下开发一个客户端需要具备的一些特性 , 比如要支持临时 / 永久节点的创建和删除 、 获取子节点 、 查询节点和保活机制等 。

表列出了 API 对应的功能

注册redis账户 redis实现注册中心_分布式_09


注册redis账户 redis实现注册中心_注册redis账户_10


在实现注册中心客户端基础上 , 不仅要考虑增删改查 , 还需要考虑 watch 机制 、 连接状态变更 , 主要是为了方便接收 etcd 服务端推送的服务元数据和连接恢复需要重新注册的本地服务元数据 。为了循序渐进地弄明白 etcd 扩展实现的原理 , 我们先分析增删改查的处理 , 然后分析 watch机制的实现 。 所有与注册中心进行交互都在 OEtcdClientWrapper 中 , 在构造函数中主要异步创建与 etcd server 之间的 TCP 连接 , 并且初始化 RPC 调用失败时的重试机制 。

以临时节点为例 ,代码清单展示了临时节点的创建过程 。

临时节点的创建过程

注册redis账户 redis实现注册中心_分布式_11


在①中主要支持与 etcd 服务端交互的防御性容错 , 类似 zkClient 实现 , 失败时是允许重试的 , 默认策略是失败最多重试 1 次 , 每次重试休眠 I 秒 , 防止出现对 etcd 服务端的冲击 , 关于重试的设计会在后面分析 。 在②中进行客户端初始化检查 , 只有客户端正确初始化才会触发网络调用 。 在③中会将用户配置的 session 作为 keep-alive 时间 , 默认是 30 秒 。 在实现租约保活时,做了一次优化 , 对同一个应用临时节点做了租约复用 。 因为每次租约保活会触发一次 gRPC调用 , 租约复用机制大大降低了 stream 数量占用 , 同时降低了 etcd 集群内存使用 。 etcd 要求提供一个时间间隔 , 服务端会返回唯一标识来代表这个租约 , 需要线程池定期续租 。 jetcd 触发续租逻辑的主要原理是通过 2 个线程池处理的 , 第 1 个线程池定期刷新 TTL 保持存活 , 第 2 个线程池定期检测本地是否过期 。 在 jetcd 0.3.0 之后提供了 keep-alive 回调机制 , 允许保活失败执行开发者的自定义逻辑 。 在④中会构造 key-value 键值对 , 键对应 Dubbo 服务元数据 , 值是租约 。 写值时自动关联租约 , 如果 provider 节点宕机无法续租 , 则 etcd 服务端会自动将节点清除 , 因此无须担心有垃圾数据的问题 。

接下来 , 我们需要解决如何获取直接子节点的问题 , 因为 etcd 是平铺的 key-value 。 这里有两种解决办法 , 第一种是用 key 对应的 value 存储所有子节点的值 , 第二种是把所有元数据作为key, 值实际上不存储有意义的元素 。 如果采用第一种方法 , 则不可避免地又回到了 ZooKeeper的数据结构 , 每次单个 provider 上线都会触发所有客户端进行拉取 , 对网络资源消耗较大 。 因此 , 这里采用第二种办法 , 将元数据作为 key, 并且使用特定的前缀进行区分 , 比如服务提供者会采用 /dubbo/com.alibaba.demo.HelloService/providers 作为前缀 , 针对每个接口com . alibaba . demo . HelloService 都采用这个前缀进行匹配 。

这里有一个技巧 , 当拉取数据时 , 我们想要的数据是直接子节点的数据 , 比如接口com . alibaba . demo . HelloService 服务提供者 , 我们需要过滤 providers key 后面的数据 , 可以取紧跟在 “ / ” 之后的值作为服务元数据 , 获取直接子节点如代码清单所示 。

获取直接子节点

注册redis账户 redis实现注册中心_注册redis账户_12


在①中将 path 作为查找直接子节点的开始索引 , 比如要获取 com.alibaba.demo.HelloService对应的 providers, 这里的 path 其实对应 /dubbo/com.alibaba . demo.HelloService/providers, 因此 , 我们期望的初始索引指向 key /dubbo/com.alibaba.demo.HelloService/providers/ 最后一个字符 “ / ” 。 在②中主要是获取服务端返回的 key 做并行 stream 计算 , 这里是安全的 。 在③中主要是探测返回的 key 是否是一级子节点 , 存储到注册中心的数据特殊字符 “ / ” 己经做了编码 ,因此不用担心会遇到这个字符 , 如果发现不是直接子节点也会快速失败 。

为了保持完整性 , 我们需要继续完成当服务下线时节点的删除逻辑 , 删除节点的核心逻辑非常简单,主要就是利用 jetcd 客户端 delete 方法删除对应的 key 即可

删除节点

注册redis账户 redis实现注册中心_元数据_13


了解了前面常用的添加 、 查找和删除的逻辑 , 我们都会调用 RetryLoops . invokeWithRetry进行失败重试 , 接下来我们看一下这个机制的逻辑结构 , 如图所示

注册redis账户 redis实现注册中心_客户端_14

为了能够处理所有与 etcd server 进行的交互操作 , 支持失败重试 , 这里抽象了 RetryLoop类 , 这个类主要持有 Callable 实例用于执行回调函数或真实逻辑 , 另外一个实例就是重试策略 。是否重试应该交给具体的策略逻辑来判断 , 比如最大重试几次 , 每次重试是否需要 “ sleep ” 等 ,易变的逻辑抽象成一个接口是合适的 。 RetryNtimes 代表具体的重试策略 , 最大重试 N 次 , 每次重试都会进行 “ sleep ” 来指定时间

我们先给出重试策略的接口定义

注册redis账户 redis实现注册中心_分布式_15


为了防止给注册中心瞬间造成很大的压力 , 接口指明了是否允许休眠 , 并给出了每次触发重试策略 shouldRetry 己经发生的重试次数和收到的时间耗时 。 接下来我们要实现 RetryLoops,主要的逻辑为控制失败是否应该重试 , 如果第一次就成功则应该立即返回 。 当发生异常时 , 经过重试策略判断是否重试 , 如果应该重试则再次循环执行RetryLoops 核心重试逻辑

注册redis账户 redis实现注册中心_注册redis账户_16


① : 在重试开始前会生成 RetryLoops 实例记录当前的状态 , 主要包括第一次重试时间戳 、己经重试的次数和是否正常完成等状态 。 ② : 主要进入循环判断是否继续执行真实逻辑 , 第一次状态会放开执行 。 ③ : 在调用方线程中执行具体逻辑 , 如果正常完成方法执行 , 则在下次调用时退出循环④ 。 ⑤ : 负责处理异常调用 , 是否重试由两部分决定 , 第 1 部分只有是可恢复异常才有机会重试 , 第 2 部分满足重试策略当前条件 , 比如最多重试 2 次 , 当前虽然调用失败没达到上限也应该继续重试 。 ⑥ : 是否可恢复异常(比如当前 leader 正在选举)并且执行重试策略逻辑 , 如果允许当前失败则会默默丢弃 。

JEtcdClientWrapper 主要负责对 etcd 服务端进行增删改查 , 我们要实现的 JEtcdClient 需要支持 watch 机制 , 这个是服务发现重度依赖的特性 。 官方的 jetcd 客户端(最新版本是 0.3.0), 因为提供的 watch 接口是阻塞的且不易于使用 , 因此借鉴了 gRPC 的接口 , 利用它实现等价的 watch 功能 o jetcd 底层实现调用是 gRPC 协议 (HTTP/2.0), 因此会自动利用连接复用特性 。 相比原来 etcdv2 版本不支持连接复用 , 大大提升了性能 。

Streamobserver 接口是 gRPC 中面向 stream 的核心接口 , 当服务端推送 watch 事件时 , onNext方法会被触发 。因此我们需要实现这个接口用于监听并回调刷新最新的服务可用列表 。 自己扩展 gRPC 回调接口有一定的难度 , 需要理解内部通信机制 , 这里直接给出 EtcdWatcher 类的接收通知实现

接收 watch 事件变更

注册redis账户 redis实现注册中心_分布式_17


① : 从 gRPC 响应中获取响应事件 。 ② : 如果是新增事件 , 则首先通过 find 判断是否是当前 path 直接子节点 , 如果满足则保存在 URL 列表中 , 等一批事件处理完后一次性通知 。 ③ :通过加锁将服务元数据保存在链表中 , 这里加锁是因为 watch 是异步的 , 由 gRPC 线程池触发 。④ : 感知服务下线 、 动态配置或路由删除 , 同样会将服务从 URL 列表中移除 , modified 标志用于记录是否应该通知 , 这里用整型变量是为了消除 ABA 问题 , 如果用 boolean 变量保存并且发生了 put 和 delete 事件 , 可能误认为不需要通知 。 ⑤ : 从 URL 列表中移除服务元数据 , 通过⑥通知最新可用的服务 , 这里的 listener 一般是 RegistryDirectory , 如果是 dubbo-admin 则对应RegistryServerSync 。

如果读过 ZooKeeper 注册中心代码 , 那么会发现它每次接收事件变更都会触发 getChildren拉取全量数据 , etcd 在这里做了一层 cache, 不存在也不需要每次拉取 , 因为通知的时间携带了key 信息 , 而且 key 就是我们要的服务元数据 。

接下来我们需要实现发起 watch 的 RPC 调用和异常重连的场景 ,

watch 请求监听

注册redis账户 redis实现注册中心_数据_18


在①中做了幕等保证 , 防止对同一个 path 多次 “ watch ” , 如果多次 “ watch ” , 首先取消之前的 watch, 防止多次 watch. 在②中主要创建本地调用代理存根 。 在③中会将自己注册为回调通知 , 注册成功后服务端通知会执行前面的 onNext 方法接收事件 。 在④中会发起 watch 远程调用 , 在调用前会使用 nextRequest 创建当前 path 作为请求对象 。 我们监听的是前缀 , 利用了 etcd范围监听机制 , 使用比较简单 , 只需要将监听的 path 最后一个字符值加 1 即可 。 ⑤ :因为监听并不会拉取节点数据 , 所以第一次要手动拉取一次直接子节点数据 。

如果 RPC 发生了异常没有正确处理 , 则可能会丢失 watch, 因此任何异常错误 gRPC 都会回调 。 Error 方法 , 我们需要在这里保证健壮性

watch 异常处理

注册redis账户 redis实现注册中心_元数据_19


当 gRPC 发生异常时 , 通过 onError 直接调用 tryReconnect 方法 。 ① : 处理严重故障时延迟重试 。 ② : 对常规异常进行快速重试 。 reconnect 中的实现非常简单 , 直接关闭当前 watch,然后重新发起一个新的 RPC 调用进行 “ watch ” 。 比如正在选举 leader, 等待较长时间是合适的 ,采用随机延迟 , 防止在一瞬间对集群造成较大压力 。 目前对 etcd 注册中心创建节点 、 查询节点 删除节点和对 watch 机制的说明占用了较多的篇幅 , 接下来我们会对上层使用注册中心接口 API做进一步探讨 。

3.4 扩展 FailbackRegistry 实现

实现具体注册中心时 , 我们应该最大复用现有注册中心的功能 , 比如注册失败和订阅失败应该重试 , 当注册中心 “ 挂掉 ” 后 , 应用启动应该自动使用 cache 文件等 。 这些功能现有的 Dubbo框架巳经给我们提供好了 , 因此我们的注册中心只需要继承 FailbackRegistry 类并重写具体订阅和注册等逻辑即可 。

扩展注册中心时 , 我们主要的聚焦点是实现 doSubscribe、 doUnsubscribe、 doRegister 和 doUnregister 这四个方法的功能是支持订阅 、 取消订阅 、 注册服务元数据和取消注册服务元数据 。 如果读者理解了第 3 章 ZooKeeper 注册中心实现 , 那么会更容易理解 etcd 中的实现 。在实现数据订阅时 , 我们必须要兼容服务治理平台 ( dubbo-admin), 服务治理平台启动时接口会传递星号 (*), 代表订阅所有接口数据 。 因此在处理服务治理场景中必须递归拉取所有接口 , 然后订阅接口中包含的服务数据或配置 , etcd 注册中心订阅逻辑如代码清单所示(部分删减) 。

etcd 注册中心订阅逻辑

注册redis账户 redis实现注册中心_数据_20

我们首先分析 dubbo-admin 逻辑订阅场景 , 前面提到首先要拉取根节点下对应的所有接口 ,在①中主要接收根节点推送的接口数据 。 ② : 当收到推送数据时需要递归调用接口下的数据( provider 、 consumers’Configurators 和 routers ) 。 ③:主要创建根节点数据 ( 默认是 /dubbo ) 。④ : 在第一次添加 watcher 监听时会返回当前节点的最新数据 。 ⑤ : 处理第一次拉取数据并在⑥中发起递归调用监听 。 其中①和②处理异步推送回的接口信息 , 然后递归订阅每个接口服务元数据 , ⑤和⑥主要是对第一次订阅返回的数据进行处理 。

在处理常规接口和服务治理平台订阅的接口时 , 首先会获取当前服务元数据 URL 对应的类别 , 默认类别是 provideto : 接收注册中心主动推送的接口对应的全量数据 , 主要包括 providers 、动态配置和路由 , 但每次推送都是某一个类别的全量数据 。 ⑧ : 精确匹配服务端元数据 ( 接口 、分组和版本等 ) , 客户端获取的 Invoker 也是在这里过滤的 。 ⑨ ; 创建某个接口包含的类别 , 就是对应在接口下方的直接目录 , 比如 /providerso ⑩ : 监听具体接口下面的分类 , 第一次监听会主动拉一次最新数据 。 @ : 主要实现对返回的数据做过滤和剔除前缀等逻辑 。 ⑫ : 通知刷新本地服务列表 、 动态配置或路由 , consumer 端最常用的是 RegistryDirectory 实现 。 为了聚焦关注点 , 删除了对 listener 做缓存的代码 。

在处理完服务订阅时 , 我们需要实现服务注册 , 服务注册比较简单 , 主要就是把 URL 转换成注册中心对应的 path, 需要区分当前节点是否是临时节点 , 如果是临时节点则调用前面的接口 createEphemeral 实现即可 。 在当前接口内部会自动封装保活逻辑 , 如果是永久节点则直接创建即可 , 不需要保活 , etcd 服务端会自动持久化 。 当 JVM 退出后进行反注册也比较简单 , 直接调用接口删除注册中心的 key 即可

3.5 编写单元测试

不管是编写框架运行代码还是业务代码 , 详尽的测试再多也不为过 。 在编写完注册中心实现后 , 我们进行代码在正确用例和失败用例下的测试 。

在网络层面 , 我们尽可能测试下面几种场景 :

  • (1) etcd 注册中心正常连接集群服务器 。
  • (2) etcd 注册中心发生网络断开时能够自动尝试建立连连 。
  • (3) etcd 注册中心在失败一段时间后 , 建立连接能够自动恢复服务注册 。

在服务读写场景中 , 我们尽可能测试下面几种场景 :

  • (1) etcd 能够正常注册临时节点 。
  • (2) etcd 能够正常注销临时节点 。
  • (3) etcd 超过保活时间能够自动删除节点 。
  • (4) etcd 能够正常创建永久节点 。
  • (5) etcd 能够正常删除永久节点 。

在服务订阅场景中 , 我们尽可能测试下面几种场景 :

  • ( 1 ) 服务能够监听直接子节点 。
  • (2) 如果已经有感兴趣的 key 存在 , 则触发 watch 时应该立即感知到数据 。
  • (3) watch 不会丢失 。
  • ( 4) watch 能够被取消 。
  • (5) 如果 "watch ” 失败能够自动尝试 "watch ” 直到成功 。