联席作者:吴毅挺 任浩军 童子龙
郑重鸣谢:Nacos - 彦林,Spring Cloud Alibaba - 小马哥、洛夜,Nacos 社区 - 张龙(pader)、春少(chuntaojun)
相关文章推荐:
在高速发展的时候,公司规模越来越大,老师人数越来越多,这时候公司不能铺太多人去做运营与服务,必须提高每个人效,这就需要技术驱动。因此掌门教育转变成一家技术驱动型的公司,如果被迫成为一家靠资金驱动的公司就活不下去了。
– 张翼(掌门教育创始人兼 CEO)
掌门教育自 2014 年正式转型在线教育以来,秉承“让教育共享智能,让学习高效快乐”的宗旨和愿景,经历云计算、大数据、人工智能、 AR
/ VR
/ MR
以及现今最火的 5G
,一直坚持用科技赋能教育。掌门教育的业务近几年得到了快速发展,特别是今年的疫情,使在线教育成为了新的风口,也给掌门教育新的机遇。
随着业务规模进一步扩大,流量进一步暴增,微服务数目进一步增长,使老的微服务体系所采用的注册中心 Eureka
不堪重负,同时 Spring Cloud
体系已经演进到第二代,第一代的 Eureka
注册中心已经不大适合现在的业务逻辑和规模,同时它目前被 Spring Cloud
官方置于维护模式,将不再向前发展。如何选择一个更为优秀和适用的注册中心,这个课题就摆在了掌门人的面前。经过对 Alibaba Nacos
、HashiCorp Consul
等开源注册中心做了深入的调研和比较,最终选定 Alibaba Nacos
做微服务体系 Solar
中的新注册中心。
两次 Eureka
引起业务服务大面积崩溃后,虽然通过升级硬件和优化配置参数的方式得以解决,Eureka
服务器目前运行平稳,但我们依旧担心此类事故在未来会再次发生,最终选择落地 Alibaba Nacos
作为掌门教育的新注册中心。
经过研究,我们采取了官方的 Nacos Eureka Sync
方案,在小范围试用了一下,效果良好,但一部署到 FAT
环境后,发现根本不行,一台同步服务器无法抗住将近 660 个服务(非实例数)的频繁心跳,同时该方案不具备高可用特点。
既然一台不行,那么就多几台,但如何做高可用呢?
我们率先想到的是一致性 Hash
方式。当一台或者几台同步服务器挂掉后,采用 Zookeeper
临时节点的 Watch
机制监听同步服务器挂掉情况,通知剩余同步服务器执行 reHash
,挂掉服务的工作由剩余的同步服务器来承担。通过一致性 Hash
实现被同步的业务服务列表的平均分配,基于对业务服务名的二进制转换作为 Hash
的 Key
实现一致性 Hash
的算法。我们自研了这套算法,发现平均分配的很不理想,第一时间怀疑是否算法有问题,于是找来 Kafka
自带的算法(见 Utils.murmur2
),发现效果依旧不理想,原因还是业务服务名的本身分布就是不平均的,于是又回到自研算法上进行了优化,基本达到预期,下文会具体讲到。但说实话,直到现在依旧无法做到非常良好的绝对平均。
这个方案是个小插曲,当一台同步服务器挂掉后,由它的“备”顶上,当然主备切换也是基于 Zookeeper
临时节点的 Watch
机制来实现的。后面讨论下来,主备方案,机器的成本很高,实现也不如一致性 Hash
优雅,最后没采用。
折腾了这么几次后,发现同步业务服务列表是持久化在数据库,同步服务器挂掉后 reHash
通知机制是由 Zookeeper
来负责,两者能否可以合并到一个中间件上以降低成本?于是我们想到了 Etcd
方案,即通过它实现同步业务服务列表持久化 + 业务服务列表增减的通知 + 同步服务器挂掉后 reHash
通知。至此方案最终确定,即两个注册中心( Eureka
和 Nacos
)的双向同步方案,通过第三个注册中心( Etcd
)来做桥梁。
解决了一致性 Hash
的问题后,还有一个潜在风险,即官方方案每次定时同步业务服务的时候,都会去读取全量业务服务名列表,对于业务服务数较少的场景应该没问题,但对于我们这种场景下,这么频繁的全量去拉业务服务列表,会不会对 Nacos
服务器的性能有所冲击呢?接下去我们对此做了优化,取消全量定时读取业务服务名列表,通过 DevOps
的发布系统平台实施判断,如果是迁移过来的业务服务或者新上 Nacos
的业务服务,由发布平台统一调用 Nacos
接口来增加新的待同步业务服务 Job
,当该业务服务全部迁移完毕后,在官方同步界面上删除该同步业务服务 Job
即可。
方案实现后,上了 FAT
环境上后没发现问题(此环境,很多业务服务只部署一个实例),而在 PROD
环境上发现存在双向同步丢心跳的问题,原因是同步服务器来不及执行排队的心跳线程,导致 Nacos
服务器无法及时收到心跳而把业务服务踢下来。我们从 8 台 4C8G
同步服务器扩容到 12 台,情况好了很多,但观察下来,还是存在一天内存在一些业务服务丢失心跳的情况,于是我们再次从 12 台 4C8G
同步服务器扩容到 20 台,情况得到了大幅改善,但依旧存在某个同步服务器上个位数丢失心跳的情况,观察下来,那台同步服务器承受的某几个业务服务的实例数特别多的情况,我们在那台同步服务器调整了最大同步线程数,该问题得到了修复。我们将继续观察,如果该问题仍旧复现,不排除升级机器配置到 8C16G
来确保 PROD
环境的绝对安全。
至此,经过 2 个月左右的努力付出,Eureka
和 Nacos
同步运行稳定, PROD
环境上同步将近 660 个服务(非实例数),情况良好。
非常重要的提醒:一致性 Hash 的虚拟节点数,在所有的 Nacos Sync Server 上必须保持一致,否则会导致一部分业务服务同步的时候会被遗漏。
注册中心迁移目标
A
业务服务注册到 Eureka
上, B
业务服务迁移到 Nacos
,A
业务服务和 B
业务服务的互相调用必须正常;注册中心迁移原则
Eureka
或者 Nacos
,最终结果都是等效的;Eureka
的业务服务需要同步到 Nacos
,那就有一个 Eureka -> Nacos
的同步任务,反之亦然。在平滑迁移中,一个业务服务一部分实例在 Eureka
上,另一部分实例在 Nacos
上,那么会产生两个双向同步的任务;Metadata
)的标记 syncSource
来决定。Nacos Eureka Sync
同步节点需要代理业务服务实例和 Nacos Server
间的心跳上报。Nacos Eureka Sync
将心跳上报请求放入队列,以固定线程消费,一个同步业务服务节点处理的服务实例数超过一定的阈值会造成业务服务实例的心跳发送不及时,从而造成业务服务实例的意外丢失。Nacos Eureka Sync
节点宕机,上面处理的心跳任务会全部丢失,会造成线上调用大面积失败,后果不堪设想。Nacos Eureka Sync
已经开始工作的时候,从 Eureka
或者 Nacos
上,新上线或者下线一个业务服务(非实例),都需要让 Nacos Eureka Sync
实时感知。Etcd
集群中;API
接口持久化到 Etcd
集群中,业务服务迁移过程整合 DevOps
发布平台。整个迁移过程全自动化,规避人为操作造成的遗漏;Etcd
集群获取任务列表,并监听同步集群的节点状态;Hash
算法,找到处理任务节点,后端接口通过 SLB
负载均衡,删除任务指令轮询到的节点。如果是自己处理任务则移除心跳,否则找到处理节点,代理出去;Eureka
到 Nacos
后,需要业务部门通知基础架构部手动从 Nacos Eureka Sync
同步界面摘除该同步任务。基于官方的 Nacos Sync
做任务分片和集群高可用,目标是为了支持大规模的注册集群迁移,并保障在节点宕机时,其它节点能快速响应,转移故障。技术点如下,文中只列出部分源码或者以伪代码表示:
详细代码,请参考:https://github.com/zhangmen-tech/nacos
Hash
分片路由Hash
环上能均匀分布;// 虚拟节点配置
sync.consistent.hash.replicas = 1000;
// 存储虚拟节点
SortedMap<Integer, T> circle = new TreeMap<Integer, T>();
// 循环添加所有节点到容器,构建Hash环
replicas for loop {
// 为每个物理节点设置虚拟节点
String nodeStr = node.toString().concat("##").concat(Integer.toString(replica));
// 根据算法计算出虚拟节点的Hash值
int hashcode = getHash(nodeStr);
// 将虚拟节点放入Hash环中
circle.put(hashcode, node);
}
// 异步监听节点存活状态
etcdManager.watchEtcdKeyAsync(REGISTER_WORKER_PATH, true, response -> {
for (WatchEvent event : response.getEvents()) {
// 删除事件,从内存中剔除此节点及Hash中虚拟节点
if (event.getEventType().equals(WatchEvent.EventType.DELETE)) {
String key = Optional.ofNullable(event.getKeyValue().getKey()).map(bs -> bs.toString(Charsets.UTF_8)).orElse(StringUtils.EMPTY);
//获取Etcd中心跳丢失的节点
String[] ks = key.split(SLASH);
log.info("{} lost heart beat", ks[3]);
// 自身节点不做判断
if (!IPUtils.getIpAddress().equalsIgnoreCase(ks[3])) {
// 监听心跳丢失,更显存货节点缓存,删除Hash环上节点
nodeCaches.remove(ks[3]);
try {
// 心跳丢失,清除etcd上该节点的处理任务
manager.deleteEtcdValueByKey(PER_WORKER_PROCESS_SERVICE.concat(SLASH).concat(ks[3]), true);
} catch (InterruptedException e) {
log.error("clear {} process service failed,{}", ks[3], e);
} catch (ExecutionException e) {
log.error("clear {} process service failed,{}", ks[3], e);
}
}
}
}
FNV1_32_HASH
算法计算每个业务服务的哈希值,计算该 Hash
值顺时针最近的节点,将任务代理到该节点。// 计算任务的Hash值
int hash = getHash(key.toString());
if (!circle.containsKey(hash)) {
SortedMap<Integer, T> tailMap = circle.tailMap(hash);
// 找到顺势针最近节点
hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
}
// 得到Hash环中的节点位置
circle.get(hash);
// 判断任务是否自己的处理节点
if (syncShardingProxy.isProcessNode(taskDO.getServiceName())) {
//如果任务属于该节点,则进行心跳同步处理
processTask(Task);
}
// 删除心跳同步任务
if (TaskStatusEnum.DELETE.getCode().equals(taskUpdateRequest.getTaskStatus())) {
// 通过Etcd存活节点的一致性Hash算法,获取此任务所在的处理节点
Node processNode = syncShardingProxy.fetchProcessNode(Task);
if (processNode.isMyself()) {
// 如果是自己的同步任务,发布删除心跳事件
eventBus.post(new DeleteTaskEvent(taskDO));
} else {
// 如果是其他节点,则通过Http代理到此节点处理
httpClientProxy.deleteTask(targetUrl,task);
}
}
Etcd
集群租约 TTL
, TTL
内至少发送 5 个续约心跳以保证一旦出现网络波动避免造成节点丢失;// 心跳TTL配置
sync.etcd.register.ttl = 30;
// 获取租约TTL配置
String ttls = environment.getProperty(ETCD_BEAT_TTL);
long ttl = NumberUtils.toLong(ttls);
// 获取租约ID
long leaseId = client.getLeaseClient().grant(ttl).get().getID();
PutOption option = PutOption.newBuilder().withLeaseId(leaseId).withPrevKV().build();
client.getKVClient().put(ByteSequence.from(key, UTF_8), ByteSequence.from(value, UTF_8), option).get();
long delay = ttl / 6;
// 定时续约
scheduledExecutorService.schedule(new BeatTask(leaseId, delay), delay, TimeUnit.SECONDS);
// 续约任务
private class BeatTask implements Runnable {
long leaseId;
long delay;
public BeatTask(long leaseId, long delay) {
this.leaseId = leaseId;
this.delay = delay;
}
public void run() {
client.getLeaseClient().keepAliveOnce(leaseId);
scheduledExecutorService.schedule(new BeatTask(this.leaseId, this.delay), delay, TimeUnit.SECONDS);
}
}
节点宕机:其中某个节点宕机,其任务转移到其它节点,因为有虚拟节点的缘故,所以此节点的任务会均衡 ReSharding
到其它节点,那么,集群在任何时候,任务处理都是分片均衡的,如图 2 中, B
节点宕机, ##1
、 ##2
虚拟节点的任务会分别转移到 C
和 A
节点,这样避免一个节点承担宕机节点的所有任务造成剩余节点连续雪崩;
节点恢复:如图 3,节点的虚拟节点重新添加到 Hash
环中, Sharding
规则变更,恢复的节点会根据新的 Hash
环规则承担其它节点的一部分任务。心跳任务一旦在节点产生都不会自动消失,这时需要清理其它节点的多余任务(即重新分配给复苏节点的任务),给其它节点减负(这一步非常关键,不然也可能会引发集群的连续雪崩),保障集群恢复到最初正常任务同步状态;
// 找到此节点处理的心跳同步任务
Map<String, FinishedTask> finishedTaskMap = skyWalkerCacheServices.getFinishedTaskMap();
// 存储非此节点处理任务
Map<String, FinishedTask> unBelongTaskMap = Maps.newHashMap();
// 找到集群复苏后,Rehash后不是此节点处理的任务
if (!shardingEtcdProxy.isProcessNode(taskDO.getServiceName()) && TaskStatusEnum.SYNC.getCode().equals(taskDO.getTaskStatus())) {
unBelongTaskMap.put(operationId, entry.getValue());
}
unBelongTaskMap for loop {
// 删除多余的节点同步
specialSyncEventBus.unsubscribe(taskDO);
// 删除多余的节点处理任务数
proxy.deleteEtcdValueByKey(PER_WORKER_PROCESS_SERVICE.concat(SLASH).concat(IPUtils.getIpAddress()).concat(SLASH).concat(taskDO.getServiceName()), false);
// 根据不同的同步类型,删除多余的节点心跳
if (ClusterTypeEnum.EUREKA.getCode().equalsIgnoreCase(clusterDO.getClusterType())) {
syncToNacosService.deleteHeartBeat(taskDO);
}
if (ClusterTypeEnum.NACOS.getCode().equalsIgnoreCase(clusterDO.getClusterType())) {
syncToEurekaService.deleteHeartBeat(taskDO);
}
// 删除多余的finish任务
finishedTaskMap.remove(val.getKey());
}
Etcd
集群连接不上,则存活节点从配置文件中获取,集群正常运作,但是会失去容灾能力。// 配置所有处理节点的机器IP,用于构建Hash环
sync.worker.address = ip1, ip2, ip3;
// 从配置文件获取所有处理任务节点IP
List<String> ips = getWorkerIps();
ConsistentHash<String> consistentHash = new ConsistentHash(replicas, ips);
// 如果从Etcd中获取不到当前处理节点,则构建Hash环用配置文件中的IP列表,且列表不会动态变化
if (CollectionUtils.isNotEmpty(nodeCaches)) {
consistentHash = new ConsistentHash(replicas, nodeCaches);
}
return consistentHash;
从如下界面可以保证,从 Eureka
或者 Nacos
上,新上线或者下线一个业务服务(非实例),都能让 Nacos Eureka Sync
实时感知。但我们做了更进一层的智能化和自动化:
DevOps
发布平台,当一个业务服务(非实例)新上线的时候,智能判断它是从哪个注册中心上线的,然后回调 Nacos Eureka Sync
接口,自动添加同步接口,例如,A
业务服务注册到 Eureka
上,DevOps
发布平台会自动添加它的 Eureka -> Nacos
的同步任务,反之亦然。当然从如下界面的操作也可实现该功能;DevOps
发布平台无法判断一个业务服务(非实例)下线,或者已经迁移到另一个注册中心,已经全部完毕(有同学会反问,可以判断的,即查看那个业务服务的实例数是否是零为标准,但我们应该考虑,实例数为零在网络故障的时候也会发生,即心跳全部丢失,所以这个判断依据是不严谨的),交由业务人员来判断,同时配合钉钉机器人告警提醒,由基础架构部同学从如下界面的操作实现该功能;从如下界面可以监控到,业务服务列表是否在同步服务的集群上呈现一致性 Hash
均衡分布。
FAT
环境进行演练,通过自动化运维工具 Ansible
两次执行一键升级和回滚均没问题;Nacos Eureka Sync
挂掉 3 台的操作,只丢失一个实例,但 5 分钟后恢复(经调查,问题定位在 Eureka
上某个业务服务实例状态异常);Nacos Eureka Sync
集群重新均衡 ReHash
,同步正常;Nacos Eureka Sync
集群重新均衡 ReHash
,同步正常;UAT
环境顺利;PROD
环境顺利;ReHash
时间小于 1 分钟,即 Nacos Eureka Sync
服务大面积故障发生时,恢复时间小于 1 分钟。eBay
、携程,曾任携程高级研发总监,负责从零打造携程私有云、容器云、桌面云和 PaaS
平台。PaaS
平台 Halo
基础服务框架研发。10 多年开源经历,Github
ID:@HaojunRen,Nepxion
开源社区创始人,Nacos
Group Member,Spring Cloud Alibaba
& Nacos
& Sentinel
& OpenTracing
Committer。参与 Nacos
落地的基础架构部成员,包括:
“阿里巴巴云原生关注微服务、Serverless、容器、Service Mesh 等技术领域、聚焦云原生流行技术趋势、云原生大规模的落地实践,做最懂云原生开发者的公众号。”