TPP有3600+个场景,每个场景是一些AB(算法方案代码+业务配置+流量分配策略)的集合,场景按业务团队划分物理集群,同一个物理集群内的容器是对等的,JVM内部署着算法容器,算法容器内混布相同的场景集合,算法容器是平台编码,场景方案代码则是算法编码并进行热部署。前端请求以场景为粒度请求RR,RR获取场景所在集群按集群进行路由。如下图所示。
如前文所述,容器是平台开发编码,代码质量可控,而算法场景代码则是全集团各个算法owner编写,编码质量参差不齐。这种情况下JVM内场景混布就会出现相互影响的问题,如cpu分配不均,内存分配不均等问题,最讨厌的是出现死循环。针对这些问题TPP已经将重要的核心场景和非重要的小场景进行物理隔离,即调配到不同的物理集群,这样一定程度上减少了非重要场景代码问题导致核心场景大量异常的情况,如超时。但非核心集群死循环,甚至核心集群相互影响的情况还是时有发生。那为什么不直接每个场景单独一个容器部署呢,通过docker层面cgroup直接隔离场景是否可行?当然可行,但是机器成本将大幅上升。因为每个容器里要加载各种二方服务,如pandora,forest,igraph,sumamry,各种hsf服务等,而且每个场景要保证至少两台的可用度,这样机器内存规模至少要扩大好多倍,机器数自然答复上涨。很多场景qps非常低的,峰值也是错开的,混布能极大提高资源利用率。我们对隔离做了一些改进工作,包括线程池隔离,多租户隔离。
首先系统进行了线程池隔离改造,算法方案代码从HSF业务线程直接执行改为HSF业务线程提交给场景线程池执行。每个场景都管理一个自己的线程池,平台根据流量需求可动态调配不同的线程池参数。如下图所示:
这样做的好处是:
- 保护了hsf入口工作线程,改造之前算法方案超时严重会造成容器hsf服务pool full。场景线程池隔离后根据场景超时上限(一般是200ms)做超时interrupt,保证不会大量并发堆积。同时场景线程池设置拒绝策略,在并发堆积超过wait_queue+max_pool的情况下立即拒绝服务。这样一定程度提升了hsf的可用性。
- 减少无用的超时后计算,hsf业务线程并不会被中断,如果算法中途超时了,并没必要做后面的复杂计算工作,浪费的cpu资源也被节约下来。
- 场景间公平性得到一定保障,代码有问题的场景不占满hsf线程的情况下,其他场景仍能有流量得到服务。
线程池隔离在双11前也发挥了作用,如rtp第一次成功升级arpc后出现过死锁,发生调用的业务线程都会一直阻塞,如果发生在hsf线程,这台机器就game over了,而通过重置场景业务线程池就能免启动瞬间修复。
线程池隔离带来的问题是增加一定的上下文切换开销,设置合理的core size和alive time,通过压测和实际运行发现并没有性能下降,也没有明显增加jvm的线程数。这里从同步改造成线程池方式,需要解决一些问题,典型的就是ThreadLocal问题,包括eagleeye和业务threadlocal。下面是支持eagleeye和业务threadlocal透传的线程池实现:
public class SolutionExecutorService extends ThreadPoolExecutor {
public SolutionExecutorService(int corePoolSize, int maximumPoolSize, long keepAliveTime,
TimeUnit unit, BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory, RejectedExecutionHandler handler) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);
}
@Override
protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
return new SolutionFutureTask<T>(runnable, value);
}
@Override
protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
return new SolutionFutureTask<T>(callable);
}
class SolutionFutureTask<T> extends FutureTask<T> {
// 当前context透传到工作线程
final RpcContext_inner rpcContext = EagleEye.getRpcContext();
final Map<String, Object> tppContext = ThreadLocalParams.save();
public SolutionFutureTask(Callable<T> callable) {
super(callable);
}
public SolutionFutureTask(Runnable runnable, T result) {
super(runnable, result);
}
public void run() {
Profiler.start("RunWithPool.");
EagleEye.setRpcContext(rpcContext);
ThreadLocalParams.restore(tppContext);
try {
super.run();
} finally {
EagleEye.clearRpcContext();
ThreadLocalParams.clear();
}
}
}
}
线程池隔离并没有根本解决死循环和cpu分配不均问题,因为cpu密集型计算是无法interrupt的,同时TPP的一个平台价值之一是算法可以随时变更算法方案热部署(包括双11当天),如果方案内存回收不彻底也会造成内存泄漏。因此我们利用多租户进一步解决cpu隔离和内存回收两个问题,改造后的隔离模式如下图所示, 将算法方案之间以及算法与系统之间进行隔离:
首先结合AJDK的多租户,利用cgroup进行彻底的cpu隔离。但这不是容易的事,对于TPP这样复杂的容器更不容易,下文将介绍TPP多租户改造的艰辛之路。
cgroup的cpu隔离主要有这么几种方式:cpuset,cpushares,cpu quota.
- cpuset是cpu核为粒度的物理隔离,我们搜索hippo调度docker容器的时候就是cpuset隔离,保证每个容器不相互影响。
- cpushares设置使用者的cpu使用权重,权重越大则分配的cpu资源越多,它是个相对值。如3个进程的cpu shares分别为512,1024,1024,则他们满负载时候分配到的cpu资源是1:2:2即20%:40%:40%。如果后两个线程没有满负载,第一个share为512的可以使用超过20%。如果后两个空闲,则第一个可以用到100%,一旦share为1024的进程要使用cpu,则512的进程会让出cpu。
- 最后cpu quota设置了进程能使用的cpu最大比例绝对值,如cfs_period=100000,cfs_quota=50000,则进程能用到一个cpu core的50%,cfs=50000n则可以用到n个core的50%,总cpu可以使用到50%n/cores。
再来分析下AJDK的多租户实现原理,首先看一个线程怎么被cgroup限制cpu:
TenantConfiguration tenantConfiguration = new TenantConfiguration(cpuShares, memLimit)
.limitCpuCfs(cfsPeriod, cfsQuota);
TenantContainer container = TenantContainer.create(name, tenantConfiguration);
用户创建了个租户容器,这里指定了租户的cpu shares,内存上限,cpu利用率上限。然后用户调用租户容器去执行运算。
container.run(new Runnable() {
@Override
public void run() {
doRun();
}
});
AJDK底层对多租户的改造有这样一个非常重要的原则:
线程1由租户容器1创建,则线程1创建的其他线程都属于容器1,这些线程整体cpu利用率受容器1的cgroup限制
这个原则会带来什么麻烦事呢,先看看租户执行的代码:
public void run(final Runnable runnable) throws TenantException {
if (state == TenantState.DEAD || state == TenantState.STOPPING) {
throw new TenantException("Tenant is dead");
}
// The current thread is already attached to tenant
if (this == TenantContainer.current()) {
runnable.run();
} else {
if (TenantContainer.current() != null) {
throw new TenantException("must be in root tenant before running into non-root tenant.");
}
// attach to new tenant
attach();
try {
runnable.run();
} finally {
// detach from the tenant
detach();
}
}
}
这里首先检查当前线程所属租户容器(下文以容器1代替)和当前执行租户容器(下文以容器2代替)是否同一个,如果同一个执行执行runnable,这里没有性能开销。如果不是麻烦来了,调用attach通过jni调用绑定当前线程到容器2的cgroup组,然后执行runnable,这时候线程的cpu就得到了租户容器2的cgroup限制,runnable执行结束后再通过jni恢复线程和容器1的绑定。这里有严重的性能开销,即jni调用cgroup非常慢(实测50ms以上)。因此每个场景都要有一个线程池和一个租户容器,线程池必须有一定的coresize和alive,防止频繁new线程调用cgroup产生大耗时,场景线程必须由租户容器创建。这样线程池submit一个task就打到和普通线程池一样的性能,我们为场景线程池定制了ThreadFactory,在线程池隔离的基础上能轻松实现:
static class TenantThreadFactory extends NamedThreadFactory {
private TenantContainer container;
public TenantThreadFactory(TenantContainer container, String prefix) {
super(prefix);
this.container = container;
}
@Override
public Thread newThread(Runnable r) {
final ObjectWrapper<Thread> wrapper = new ObjectWrapper<>();
// 用租户容器去创建线程
try {
container.run(() -> {
return super.newThread(r);
wrapper.setObject(t);
});
} catch (TenantException e) {
throw new RuntimeException("create tenant thread exception", e);
}
return wrapper.getObject();
}
}
对于简单应用到此为止就完成了多租户改造,而对TPP来说则只是完成了一小步。因为TPP接入了大量的二方服务,如IGraph, RTP, SUMMARY,很多HSF服务,Forest等,前文已经介绍过混布场景是为了复用二方服务,为每个场景克隆二方服务client会产生很大的内存开销。这些复用的二方服务也管理了自己的线程池,结合前文所述租户线程创建的其他线程也属于这个租户,一旦二方服务的线程由某个租户创建然后被其他租户复用则产生了cgroup切换的开销,同时cpu分配也会错乱。因此TPP还要对场景租户线程和二方服务线进行隔离,这就涉及对一些核心高并发二方服务(双11 IGraph峰值530w qps,SUMMARY 69w qps, RTP 75w qps)client的改造。原理很简单,为二方服务的线程池增加定制的ThreadFactory:
public class RootTenantThreadFactory extends NamedThreadFactory {
public RootTenantThreadFactory(String prefix, boolean daemon) {
super(prefix, daemon);
}
@Override
public Thread newThread(Runnable r) {
if (JvmUtil.isTenantEnabled()) {
final ObjectWrapper<Thread> wrapper = new ObjectWrapper<>();
// 用root容器去创建线程
try {
TenantContainer.primitiveRunInRoot(() -> {
Thread t = super.newThread(r);
wrapper.setObject(t);
});
}
return wrapper.getObject();
} else {
return super.newThread(r);
}
}
}
对于大部分异步httpclient类的扩展client只需要在构造时候增加设置threadFactory即可:
public class RootTenantThreadFactory extends NamedThreadFactory {
public RootTenantThreadFactory(String prefix, boolean daemon) {
super(prefix, daemon);
}
@Override
public Thread newThread(Runnable r) {
if (Profiler.getEntry() == null) {
Profiler.start("Create Pool Thread ");
} else {
Profiler.enter("Create Pool Thread ");
}
if (JvmUtil.isTenantEnabled()) {
final ObjectWrapper<Thread> wrapper = new ObjectWrapper<>();
// 用root容器去创建线程
try {
TenantContainer.primitiveRunInRoot(() -> {
Thread t = super.newThread(r);
wrapper.setObject(t);
});
} finally {
Profiler.release();
}
return wrapper.getObject();
} else {
return super.newThread(r);
}
}
}
友情提示:多租户的隔离方式不当使用会导致宿主机cgroup下目录太多而负载过高,这个之前在sigma上有反馈,容器销毁时需要删除ajdk的进程cgroup目录,需要应用自己操作,幸运的是hippo调度自动完成了这个工作。
最后看一下多租户隔离的效果:
8核虚拟机下进行测试,非租户隔离的情况下集群内其他场景发生死循环,且源源不断的有死循环请求进来,当前场景会因为并发数过大全部被限流
cpu基本被打满(这里对root租户作了10%cpu保护,并不会800%,这里实际略高于720%)
使用多租且限定租户最大cpu使用50%,仍然构建一个场景死循环,可以看到当前场景只是少量超时,因为cgroup的调度也会造成场景rt上升,符合业务95%以上正确率的要求。
观察容器cpu,正常场景800qps时容器cpu仍有余量
接下去我们还会做多租户的动态调控,对于问题场景自动降权,避免cpu的浪费。