文|李旭东
专一于 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 的架构能够参见:https://www.sofastack.tech/blog/explore-sofaregistry-1-infrastructure/
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]
更具体的剖析能够参考 https://www.sofastack.tech/projects/sofa-registry/code-analyze/code-analyze-data-synchronization/
– 推送弥补
因为存在各种场景导致推送失败,上面每一个场景都会导致服务数据没有正确推送到每个客户端上
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。
public final class ThreadLocalStringBuilder {
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 同时运维。
详细分析能够参考:
https://www.sofastack.tech/projects/sofa-registry/code-analyze/code-analyze-data-inverted-index/
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 的内存空间,在高频场景下,一个局部变量的复用能节俭很多的内存开销:
https://github.com/mosn/mosn/pull/1794/files
3.9.2 string hash
代码中,很常见对一个 string 计算 Hash,如果采纳规范库,因为入参大多为为 []byte
,因而须要做 []byte(s)
把 string 转化为 []byte
, 而这一步往往比局部 Hash 算法自身的开销还高。
能够通过开发额定的间接对 string 计算 Hash 的函数来优化,比方 fnv Hash 对应的优化库:https://github.com/segmentio/fasthash
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 变更的开销,具体能够看:
https://www.sofastack.tech/blog/build-subset-optimization/
https://github.com/mosn/mosn/pull/2010
3.9.5 Random
golang 规范库 math/rand
提供的是一个非线程平安的随机种子,为了在并发场景应用他,须要加上互斥锁,而互斥锁会带来比拟大的开销。
对于随机种子平安要求不高,但性能要求比拟高的场景下,有其余的两个抉择:
– https://github.com/valyala/fastrand
应用 sync.Pool 实现,反对并发应用无需加锁;
– https://github.com/golang/go/blob/master/src/runtime/stubs.go#L154
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 的取模操作也进优化,改用乘加移位实现:https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction
4 总结与瞻望
最新版本的 SOFARegistry,通过上述优化,咱们撑持起了千万级别数据量的集群的服务发现,整体资源开销相比于老版本也有了很大的降落,当然将来还有一些优化点:
– 因为大量的应用了固定提早的批处理,导致推送提早还是偏高,推送变更提早会有 5s 左右,而市面上常见的注册核心 watch 的提早个别在 1s 以下,将来心愿能够通过辨认数据量,缩小批处理的固定提早,缩小整体变更推送提早。
– 目前对于单机房注册核心的规模撑持曾经齐全无压力,但后续 SOFARegistry 会反对多机房数据同步的性能,这部分性能在生产落地还须要咱们持续优化 SOFARegistry 的性能。
5 相干链接
[1]Session 接管到大量 Publisher,攒零售申请到 Data:
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/server/session/src/main/java/com/alipay/sofa/registry/server/session/node/service/DataNodeServiceImpl.java#L108
[2]Session 接管到大量 Subscriber,聚合去重后创立推送工作:
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/server/session/src/main/java/com/alipay/sofa/registry/server/session/push/RegProcessor.java#L54
[3]用 Map 存储 DataServer 上发生变化数据的 DataInfoId,聚合告诉 Session 进行推送
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/server/data/src/main/java/com/alipay/sofa/registry/server/data/change/DataChangeEventCenter.java#L112
[4]用 Map 存储 PushTask 进行去重,防止数据间断变动触发大量推送工作
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/server/session/src/main/java/com/alipay/sofa/registry/server/session/push/PushTaskBuffer.java#L51
[5]Session 利用 LoadingCache 从 Data 拉取数据
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/server/session/src/main/java/com/alipay/sofa/registry/server/session/push/FirePushService.java
[6]Session 在创立推送包的时候进行了压缩加缓存
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/server/session/src/main/java/com/alipay/sofa/registry/server/session/converter/pb/ReceivedDataConvertor.java#L81
[7]Data 返回服务数据给 Session 的时候进行了压缩加缓存
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/server/shared/src/main/java/com/alipay/sofa/registry/server/shared/util/DatumUtils.java#L149
[8]Data 变更告诉 Session 失败会退出重试队列最多重试 3 次
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/server/data/src/main/java/com/alipay/sofa/registry/server/data/change/DataChangeEventCenter.java#L199
[9]Session 推送给 Client 失败时会退出队列最多重试 3 次
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/server/session/src/main/java/com/alipay/sofa/registry/server/session/push/PushProcessor.java#L494
[10]Data 定时 (默认 6s) 与所有的 Session 比照并同步 pub 数据
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/server/data/src/main/java/com/alipay/sofa/registry/server/data/slot/SlotManagerImpl.java#L374
[11]Data slot follower 定时(默认 3min) 与 Data slot leader 比照并同步 pub 数据
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/server/data/src/main/java/com/alipay/sofa/registry/server/data/slot/SlotManagerImpl.java#L376
[12]Session 定时(默认 5s)与 Data 比照推送版本触发推送工作
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/server/session/src/main/java/com/alipay/sofa/registry/server/session/registry/SessionRegistry.java#L360
[13]代码
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/common/util/src/main/java/com/alipay/sofa/registry/task/KeyedThreadPoolExecutor.java#L176
[14]bug
https://jira.qos.ch/projects/LOGBACK/issues/LOGBACK-1358?filter=allopenissues
[15]log4j2 async logger
https://github.com/sofastack/sofa-registry/blob/d9ca139595f6cc5b647f53297db7be8c14390c2b/server/distribution/all/bin/base/start_base.sh#L24
本周举荐浏览
SOFARegistry 源码|数据分片之外围 - 路由表 SlotTable 分析
摸索 SOFARegistry(一)|基础架构篇
SOFARegistry 源码|数据同步模块解析
直播预报 | SOFAChannel#30《Nydus 开源容器镜像减速服务的演进与将来》