文|李旭东
专心于 SOFARegistry 及其周边基础设施的开发与优化
本文 7016 字 阅览15 分钟
1 前言
SOFARegistry 在蚂蚁内部迭代升级过程中,每年大促都会引来一些新的应战,经过不断的优化这些在大规划集群遇到的功能瓶颈,咱们总结出一些优化计划,来处理大规划集群遇到的功能问题。
经过阅览这篇文章,读者能够学习到一些 Java 和 Go 语言体系的优化技巧,在体系遇到瓶颈的时分,能够知道有哪些优化手法针对性的进行优化。
2 大规划集群的应战
随着事务的发展,事务的实例数在不断增加,注册中心所需求承载的数据量也在快速的增加 ,以其中 1 个集群为例,2019 年的数据为基准数据,在 2020 年 pub 挨近千万级。下图是该集群历年双 11 时的数据比照。
比较 2019 年双 11,2021 年双 11 接口级的 pub 增加 200%,sub 增加 80%。实例数和数据量的增加带来推送量的二次方办法的增加,SOFARegistry 每一年大促都会经历新的应战。
比方,在某一年的新机房压测过程中,因为新机房规划特别大(普通机房的 4 倍),导致注册中心的推送压力变大了十倍多,呈现了 :
–DataServer 的网络被打爆,导致很多数据改变没有及时告诉到 Session ,推送推迟飙升;
–因为数据包过大, SessionServer 与客户端之间呈现了很多的 channel overflow 推送失利,推送推迟飙升;
–因为实例数量过多,注册中心的推送包以及内部传输的数据包过大,很简略打满单机的网络处理上限,序列化数据也会占用很多的 CPU ;
–因为地址列表扩展了几倍,导致对应推送接收端 MOSN 也呈现了问题,很多机器呈现 OOM , 呈现很多 CPU 毛刺影响恳求推迟;
–注册中心常见瞬间很多并发的恳求,比方事务大规划重启,很简略导致瞬时注册中心自身处理能不足,怎么进行限流,以及怎么快速到达数据终究共同。
3 优化计划
针对上述大规划集群遇到的应战,咱们做了以下的优化计划:
3.1 横向扩展支撑大规划集群
在大规划集群场景下,单纯选用扩展机器标准的纵向扩展办法往往会遇到瓶颈,单机的装备是有上限的,超大的 heap gc 时也可能产生较高的暂停时刻,而且康复与备份会花费很长时刻。
3.1.1 双层数据架构进行数据分片
双层数据架构: Session (会话层:涣散链接)、Data (数据层:涣散数据)来完成横线扩展的才能,经过对链接和数据进行分片,SOFARegistry 能够经过横向扩容很简略的支撑更大的集群。单机选用小标准的机器,在容灾以及康复方面也能够获得很好的作用。
SOFARegistry 的架构能够拜见:www.sofastack.tech/blog/explor…
3.2 应对瞬时很多恳求
注册中心存在瞬时处理很多恳求的场景,比方当在很多运用一起发生运维或许注册中心自身发生运维的时分,会有很多的注册恳求发送到 Session 。
一起有一些依靠注册中心的基础设施会经过改变发布数据到注册中心来告诉到每一个订阅端。为了应对这种不行预见的瞬时很多恳求改变,注册中心需求有必定的战略进行削峰。
3.2.1 行列攒批处理
贴合蚂蚁的事务,为大规划集群而生,在大数据量,高并发写入下供给安稳的推送推迟,经过增加行列并进行攒批处理,进步吞吐量,对瞬间高并发恳求进行削峰。
举例:
– Session 接收到很多 Publisher ,攒批发恳求到 Data [1]
a.运用 BlockingQueue 存储需求发送 的恳求,一起会装备最大容量防止 OOM
b.独立的 Worker 线程从 BlockingQueue 中取出多个恳求,创立多个 BlockingQueue 和 Worker 进步并发度
c.依照分片规矩进行分组,打包成一个恳求发往不同的 DataServer
– Session 接收到很多 Subscriber ,聚合去重后创立推送使命 [2]
a.Subscriber 存储到 Map<datainfoid, map> 的数据结构中,能够进行去重的防止短时刻一个实例重复注册创立很多推送使命
b.守时从 Map 中取出 Subscribers ,进行分组创立推送使命
c.最大数据量是 Session 单机的一切 Subscriber ,容量可控
– 用 Map 存储 DataServer 上发生改变数据的 DataInfoId ,聚合告诉 Session 进行推送[3]
a.短时刻 DataServer 的数据可能改变屡次,比方很多 Publisher ,数据修正守时使命等等
b.对这些数据改变记录 DataInfoId , 短时刻只会对 Session 告诉一次改变创立推送使命
c.最大数据量是 Data 单机悉数的 DataInfoId
– 用 Map 存储 PushTask 进行去重,防止数据接连改变触发很多推送使命[4]
a.增加了 Map size 的查看,过多的推送使命会直接丢掉,防止 OOM
b.同样的推送使命高版别会替换掉低版别
3.3 削减网络通讯开支
3.3.1 LocalCache
Session 和 Data 之间会有很多的数据通讯,经过增加 LocalCache 能够在不增加代码架构复杂度的前提下大幅度提升体系的功能。
关于注册中心,服务数据能够经过 dataInfoId + version 仅有标识。Session 在创立推送使命时会从 Data 拉取最新的服务数据,运用 Guava 的LoadingCache
,很多推送使命被创立时,缓存的运用率会比较高,能够削减很多从 Data 拉取数据的开支。
– Session 运用 LoadingCache 从 Data 拉取数据[5]
a.会传入创立推送使命时的版别(一般由 Data 的改变告诉带过来)比照 Cache 内的数据是否足够新;
b.假如不够新,清理缓存后运用 LoadingCache 从 Data 拉取一次数据;
c. LoadingCache 会装备 maximumWeight 防止数据过多导致 OOM 。
3.3.2 紧缩推送
在集群规划比较大的时分,比方有一个运用发布了 100 个接口,每个接口的发布数据有 150B ,该运用有 8000 个实例,每个接口有 2w 订阅方。那么每次改变这个运用的机器形成的全量推送,每个推送包 1MB , 累积需求发出 200w 个推送包,即便 Session 能够横向扩容到 100 台, Session 单机也需求在 7 秒内发出 20GB 的流量,严重堵塞 Session 的网络行列,也会很快打爆 netty buffer ,形成很多的推送失利,这么多推送包的序列化也会耗费 Session 很多的 CPU 。
关于 Data ,假如 Session 的数量过多,每次改变需求给每台 Session 回来很多的大数据包,也会产生很多的出口流量,影响其他恳求的成功率。
因为每个实例发布的数据的类似度很高,简直只有 IP 不共同,所以当选用紧缩推送时紧缩率会非常高,能紧缩到 5% 的大小以下,此刻 Session 的出口流量能够大幅度降低。
SOFARegistry 内部有两个当地用到了紧缩,并且都有紧缩缓存,能够极大的削减序列化和紧缩的 CPU 开支。
Session 在开启紧缩缓存后,紧缩在 CPU 占比获得了大幅度的降低 (9% -> 0.5%)。
关于 Data 因为数据包被提早序列化+紧缩进行缓存,全体功能获得了大幅度的提升,能够轻松承载 300 台以上的 Session ,支撑亿级数据量的机房。
– Session 在创立推送包的时分进行了紧缩加缓存[6]
– Data 回来服务数据给 Session 的时分进行了紧缩加缓存[7]
3.4 面向错误规划
在实际生产环境中,机器毛病是很常见的事情:物理机宕机、网络毛病、 OOM , 体系从规划上就需求考虑犯错的场景能自动康复。
3.4.1 重试
在一个分布式体系中,失利是一个很常见的现象,比方因为网络或许机器改变等问题形成恳求失利,经过增加重试行列,参加次数有限的重试能够极大程度上进行容错
– Data 改变告诉 Session 失利会参加重试行列最多重试 3 次[8]
– Session 推送给 Client 失利时会参加行列最多重试 3 次[9]
3.4.2 守时使命
然重试能够必定程度上进步成功率,但毕竟不能无限的重试。一起各个攒批操作本身也会有容量上限,瞬间很多的恳求会形成使命被丢掉,因而就需求有守时使命来对因失利形成不共同的状况进行修正。
简要介绍一下 SOFARegistry 内部相关的守时使命是怎么规划,然后完成数据的终究共同性:
-增量数据同步
Session 作为客户端同步写入数据的人物,能够认为他的 pub/sub 数据是最最精确的整个数据的同步过程是一个单向流,运用守时使命做到终究共同性client -> Session -> dataLeader -> dataFollower
– Data 守时 (默许 6s) 与一切的 Session 比照并同步 pub 数据[10]
a.作为 Session 发送到 Data 上的 pub、unpub、clientoff 等修改数据的恳求失利的兜底措施
b.一起会在 slot leader 迁移到新的 Data 上或许 slot follower 升级成 slot leader 的时分自动发起一次同步,确保 slot 数据的完整性
– Data slot follower 守时(默许 3min) 与 Data slot leader 比照并同步 pub 数据[11]
更具体的剖析能够参阅www.sofastack.tech/projects/so…
– 推送补偿
因为存在各种场景导致推送失利,下面每一个场景都会导致服务数据没有正确推送到每个客户端上
a. Session 写入到 Data 失利
b. Data 写入数据后告诉 Session 失利
c. Session 因为推送使命过多导致丢掉使命
d. Session 推送客户端失利,比方客户端 fgc ,或许网络动摇
– Session 守时(默许 5s)与 Data 比照推送版别触发推送使命[12]
a. Session 聚合一切 Subscriber 的 lastPushVersion ,发送到 Data
b. Data 会回来最新数据的 version
c. Session 经过比照 Data的上数据的 version 来判断是否要触发推送使命
3.5 削减内存占用与分配
3.5.1 WordCache
事务发送给注册中心的数据通常有很多的重复内容的 String ,比方接口称号,属性称号等等,这些字符串占用了注册中心很大一部分的内存空间。
SOFARegistry 内会运用 WordCache 进行对这些字符串进行复用,选用 guava 的 WeakInterner 完成。经过 WordCache ,能够大大减轻常驻内存的压力。
public final class WordCache {
private static final Interner < String > interners = Interners.newWeakInterner();
public static String getWordCache(String s) {
if (s == null) {
return null;
}
return interners.intern(s);
}
}
public final class PublisherUtils {
public static Publisher internPublisher(Publisher publisher) {
...
publisher.setDataId(publisher.getDataId());
...
return publisher;
}
}
public abstract class BaseInfo implements Serializable, StoreData < String > {
public void setDataId(String dataId) {
this.dataId = WordCache.getWordCache(dataId);
}
}
3.5.2 临时目标复用*
关于高频运用场景,目标复用对内存优化是比较大的。
举例:
– 运用了 ThreadLocal 来对 StringBuilder 进行复用,关于高并发场景,能削减很多临时内存的分配;
– 下面的代码中 join 重载了多份,而没有运用join(String... es)
这种的写法,也是因为防止函数调用的时分需求临时分配一个 array 。
publicfinalclassThreadLocalStringBuilder{
private static final int maxBufferSize = 8192;
private static final transient ThreadLocal < StringBuilder > builder = ThreadLocal.withInitial(() - > new StringBuilder(maxBufferSize));
private ThreadLocalStringBuilder() {}
public static StringBuilder get() {
StringBuilder b = builder.get();
if (b.capacity() > maxBufferSize) {
b = new StringBuilder(maxBufferSize);
builder.set(b);
} else {
b.setLength(0);
}
return b;
}
public static String join(String e1, String e2) {
StringBuilder sb = get();
sb.append(e1).append(e2);
return sb.toString();
}
public static String join(String e1, String e2, String e3) {
StringBuilder sb = get();
sb.append(e1).append(e2).append(e3);
return sb.toString();
}
...
}
3.6 线程池死锁
3.6.1 独立 Bolt 线程池
依据恳求类型不同拆分线程池能够大幅度进步抗并发的才能,SOFARegistry 内分了多个独立的线程池,不同恳求和事情运用同一个线程池处理,形成死锁:
– Session
a. accessDataExecutor : 处理来自注册中心客户端的恳求
b. dataChangeRequestExecutor :处理 data 告诉改变
c. dataSlotSyncRequestExecutor : 处理 data 向 Session 发起同步的恳求
…
-data
a. publishProcessorExecutor : 处理 Session 写数据的恳求
b. getDataProcessorExecutor : 处理 Session 拉取数据的恳求
…
3.6.2 KeyedThreadPoolExecutor
代码[13]关于一个线程池内,能够对 task 增加 key ,比方推送用的线程池,依照推送的 IP 地址作为 key , 防止对一个客户端短时刻产生过多的推送。
public class KeyedThreadPoolExecutor {
private static final Logger LOGGER = LoggerFactory.getLogger(KeyedThreadPoolExecutor.class);
private final AbstractWorker[] workers;
protected final String executorName;
protected final int coreBufferSize;
protected final int coreSize;
public < T extends Runnable > KeyedTask < T > execute(Object key, T runnable) {
KeyedTask task = new KeyedTask(key, runnable);
AbstractWorker w = workerOf(key);
// should not happen,
if (!w.offer(task)) {
throw new FastRejectedExecutionException(
String.format(
"%s_%d full, max=%d, now=%d", executorName, w.idx, coreBufferSize, w.size()));
}
w.workerCommitCounter.inc();
return task;
}
}
3.7 其他常见优化
3.7.1 倒排索引
SOFARegistry 内对部分数据需求按某些属性进行查找,比方依据 IP 查询发布和订阅的数据,用于事务运维时的提早摘流,Session 单机往往包含了挨近百万的数据量,假如每次查询都需求遍历全量数据集,在高频场景,这个开支是无法接受的。
因而 SOFARegistry 内规划了一个简略高效的倒排索引来做依据 IP 查询这件事,能够进步不计其数倍的摘流功能,能够支撑上千 Pod 一起运维。
具体剖析能够参阅:
www.sofastack.tech/projects/so…
3.7.2 异步日志
SOFARegistry 内部的日志输出量是比较大的,每一个推送改变都在各个阶段都会有日志,各个组件之间的交互也有具体清晰的错误日志,用于自动化诊断体系对体系进行自愈。
异步日志输出相对同步日志会带来很大的功能提升。
SOFARegistry 是一个根据 SpringBoot 的项目,之前是选用默许的 logback 作为日志输出组件,在某次毛病注入压测后,发现 logback AsyncAppender 的一个bug[14], 在磁盘注入毛病时,logback 因为类加载失利导致异步输出线程挂掉了,在 Error 级别日志行列被打满整个进程进入卡死的状况,一切的线程悉数卡在 Logger 上,所以在新版别中改成了选用log4j2 async logger[15]的完成。
3.8 反常带来的额定开支
3.8.1 hessian 反序列化
下图为咱们在某次压测中的火焰图,发现很多的 CPU 耗费在 hessian 解析失利触发的反常上:
经排查,是咱们的呼应包里的 List 运用了Collections.unmodifiableList
,hessian 无法构造UnmodifiableList
会降级到ArrayList
,但降级过程会抛出反常导致耗费了很多的 CPU 。
3.8.2 fillInStackTrace
在某些高频调用的当地 throw Exception , Throwable 默许的 fillInStackTrace 开支很大:
public class Throwable implements Serializable {
public synchronized Throwable fillInStackTrace() {
if (stackTrace != null ||
backtrace != null /* Out of protocol state */ ) {
fillInStackTrace(0);
stackTrace = UNASSIGNED_STACK;
}
return this;
}
}
建议 override 掉 fillInStackTrace 办法,比方线程池的 RejectedExecutionException ,
public class FastRejectedExecutionException extends RejectedExecutionException {
public FastRejectedExecutionException(String message) {
super(message);
}
@Override public Throwable fillInStackTrace() {
// not fill the stack trace return this;
}
}
3.9 Client 优化技巧
大规划集群时,不光是注册中心,注册中心客户端甚至更上层的逻辑也会遇到瓶颈,蚂蚁内部主要的场景是 MOSN ,下面介绍一些在 SOFARegistry 迭代过程中 Go 语言相关的优化技巧。
3.9.1 目标复用
– 解析 URL 参数优化
SOFARPC框架下,发布到注册中心的数据是 url 格局,MOSN 端在接收到注册中心推送的时分就需求解析 url 参数,选用 go 标准库的url.Values
解析很多的 url 在 CPU 和 alloc 方面都不佳,替换成根据 sync.Pool 完成,能够进行目标复用的fasthttp.Args
能够削减很多的 CPU 和 alloc 开支。
– 部分 slice 内存复用go 的 slice 规划非常精巧, 经过a = a[:0]
能够很轻松的复用一个 slice 底层 array 的内存空间,在高频场景下,一个部分变量的复用能节省很多的内存开支:
github.com/mosn/mosn/p…
3.9.2 string hash
代码中,很常见对一个 string 核算 Hash ,假如选用标准库,因为入参大多为为[]byte
,因而需求做[]byte(s)
把 string 转化为[]byte
, 而这一步往往比部分 Hash 算法本身的开支还高。
能够经过开发额定的直接对 string 核算 Hash 的函数来优化,比方 fnv Hash 对应的优化库:github.com/segmentio/f…
3.9.3 削减字符串拼接
在选用多个 string 共同作为 map 的 key 的时分,常见把这几个字符串拼接成一个字符串作为 key ,此刻能够选用定义一个 struct 作为 key 的办法来削减临时的内存分配。
key1 := s1 + s2 + s3
type Key struct{
s1 string
s2 string
s3 string
}
3.9.4 Bitmap
bitmap 作为一个很常见的优化手法,在适宜的场景进行运用在 CPU 以及 memory 方面都会有比较大的改善。
MOSN 的代码中就有运用 bitmap 优化用于路由匹配的 subsetLoadbalancer 的案例,大大降低了注册中心推送期间 MOSN 改变的开支,具体能够看:
www.sofastack.tech/blog/build-…
github.com/mosn/mosn/p…
3.9.5 Random
golang 标准库math/rand
供给的是一个非线程安全的随机种子,为了在并发场景运用他,需求加上互斥锁,而互斥锁会带来比较大的开支。
关于随机种子安全要求不高,但功能要求比较高的场景下,有其他的两个挑选:
–github.com/valyala/fas…
运用 sync.Pool 完成,支撑并发运用无需加锁;
– github.com/golang/go/b…
go runtime 的非导出办法,threadlocal 的完成,直接运用 runtime 内的 m.fastrand 属性
运用 link 指令能够进行导出
//go:linkname FastRandN runtime.fastrandn
func FastRandN(n uint32) uint32
比照一下这 3 个 rand 的功能
BenchmarkRand
BenchmarkRand/mutex_rand
BenchmarkRand/mutex_rand-12 16138432 75.3ns/op
BenchmarkRand/fast_rand
BenchmarkRand/fast_rand-12 227684223 5.32 ns/op
BenchmarkRand/runtime_rand
BenchmarkRand/runtime_rand-12 1000000000 0.561 ns/op
PASS
比较标准库的 math.rand , runtime.fastrandn 如此的快,因为他直接运用了go runtime 中 m.fastrand 作为种子,没有加锁操作,是 threadlocal 的完成,关于 randn 的取模操作也进优化,改用乘加移位完成 :lemire.me/blog/2016/0…
4 总结与展望
最新版别的 SOFARegistry ,经过上述优化,咱们支撑起了千万级别数据量的集群的服务发现,全体资源开支比较于老版别也有了很大的下降,当然未来还有一些优化点:
– 因为很多的运用了固定推迟的批处理,导致推送推迟仍是偏高,推送改变推迟会有 5s 左右,而市面上常见的注册中心 watch 的推迟一般在 1s 以下,未来期望能够经过识别数据量,削减批处理的固定推迟,削减全体改变推送推迟。
– 目前关于单机房注册中心的规划支撑现已完全无压力,但后续 SOFARegistry 会支撑多机房数据同步的功能,这部分功能在生产落地还需求咱们继续优化 SOFARegistry 的功能。
5 相关链接
[1]Session 接收到很多 Publisher ,攒批发恳求到 Data: github.com/sofastack/s…
[2]Session 接收到很多 Subscriber ,聚合去重后创立推送使命: github.com/sofastack/s…
[3]用 Map 存储 DataServer 上发生改变数据的 DataInfoId ,聚合告诉 Session 进行推送 github.com/sofastack/s…
[4]用 Map 存储 PushTask 进行去重,防止数据接连改变触发很多推送使命 github.com/sofastack/s…
[5]Session 运用 LoadingCache 从 Data 拉取数据 github.com/sofastack/s…
[6]Session 在创立推送包的时分进行了紧缩加缓存 github.com/sofastack/s…
[7]Data 回来服务数据给 Session 的时分进行了紧缩加缓存 github.com/sofastack/s…
[8]Data 改变告诉 Session 失利会参加重试行列最多重试3次 github.com/sofastack/s…
[9]Session 推送给 Client 失利时会参加行列最多重试3次 github.com/sofastack/s…
[10]Data 守时 (默许 6s ) 与一切的 Session 比照并同步 pub 数据 github.com/sofastack/s…
[11]Data slot follower 守时(默许 3min) 与 Data slot leader 比照并同步 pub 数据 github.com/sofastack/s…
[12]Session 守时(默许 5s )与 Data 比照推送版别触发推送使命 github.com/sofastack/s…
[13]代码 github.com/sofastack/s…
[14]bug jira.qos.ch/projects/LO…
[15]log4j2 async logger github.com/sofastack/s…
本周引荐阅览
SOFARegistry源码|数据分片之核心-路由表 SlotTable 剖析
探索 SOFARegistry(一)|基础架构篇
SOFARegistry 源码|数据同步模块解析
直播预告 | SOFAChannel#30《Nydus 开源容器镜像加快服务的演进与未来》