暂时未有相关云产品技术能力~
Seata 目前支持 AT 模式、XA 模式、TCC 模式和 SAGA 模式,之前文章更多谈及的是非侵入式的 AT 模式,今天带大家认识一下同样是二阶段提交的 TCC 模式。什么是 TCCTCC 是分布式事务中的二阶段提交协议,它的全称为 Try-Confirm-Cancel,即资源预留(Try)、确认操作(Confirm)、取消操作(Cancel),他们的具体含义如下:Try:对业务资源的检查并预留;Confirm:对业务处理进行提交,即 commit 操作,只要 Try 成功,那么该步骤一定成功;Cancel:对业务处理进行取消,即回滚操作,该步骤回对 Try 预留的资源进行释放。TCC 是一种侵入式的分布式事务解决方案,以上三个操作都需要业务系统自行实现,对业务系统有着非常大的入侵性,设计相对复杂,但优点是 TCC 完全不依赖数据库,能够实现跨数据库、跨应用资源管理,对这些不同数据访问通过侵入式的编码方式实现一个原子操作,更好地解决了在各种复杂业务场景下的分布式事务问题。Seata TCC 模式Seata TCC 模式跟通用型 TCC 模式原理一致,我们先来使用 Seata TCC 模式实现一个分布式事务:假设现有一个业务需要同时使用服务 A 和服务 B 完成一个事务操作,我们在服务 A 定义该服务的一个 TCC 接口:public interface TccActionOne { @TwoPhaseBusinessAction(name = "DubboTccActionOne", commitMethod = "commit", rollbackMethod = "rollback") public boolean prepare(BusinessActionContext actionContext, @BusinessActionContextParameter(paramName = "a") String a); public boolean commit(BusinessActionContext actionContext); public boolean rollback(BusinessActionContext actionContext); }同样,在服务 B 定义该服务的一个 TCC 接口:public interface TccActionTwo { @TwoPhaseBusinessAction(name = "DubboTccActionTwo", commitMethod = "commit", rollbackMethod = "rollback") public void prepare(BusinessActionContext actionContext, @BusinessActionContextParameter(paramName = "b") String b); public void commit(BusinessActionContext actionContext); public void rollback(BusinessActionContext actionContext); }在业务所在系统中开启全局事务并执行服务 A 和服务 B 的 TCC 预留资源方法:@GlobalTransactional public String doTransactionCommit() { //服务A事务参与者 tccActionOne.prepare(null, "one"); //服务B事务参与者 tccActionTwo.prepare(null, "two"); }以上就是使用 Seata TCC 模式实现一个全局事务的例子,可以看出,TCC 模式同样使用 @GlobalTransactional 注解开启全局事务,而服务 A 和服务 B 的 TCC 接口为事务参与者,Seata 会把一个 TCC 接口当成一个 Resource,也叫 TCC Resource。TCC 接口可以是 RPC,也可以是 JVM 内部调用,意味着一个 TCC 接口,会有发起方和调用方两个身份,以上例子,TCC 接口在服务 A 和服务 B 中是发起方,在业务所在系统中是调用方。如果该 TCC 接口为 Dubbo RPC,那么调用方就是一个 dubbo:reference,发起方则是一个 dubbo:service。Seata 启动时会对 TCC 接口进行扫描并解析,如果 TCC 接口是一个发布方,则在 Seata 启动时会向 TC 注册 TCC Resource,每个 TCC Resource 都有一个资源 ID;如果 TCC 接口时一个调用方,Seata 代理调用方,与 AT 模式一样,代理会拦截 TCC 接口的调用,即每次调用 Try 方法,会向 TC 注册一个分支事务,接着才执行原来的 RPC 调用。当全局事务决议提交/回滚时,TC 会通过分支注册的的资源 ID 回调到对应参与者服务中执行 TCC Resource 的 Confirm/Cancel 方法。Seata 如何实现 TCC 模式从上面的 Seata TCC 模型可以看出,TCC 模式在 Seata 中也是遵循 TC、TM、RM 三种角色模型的,如何在这三种角色模型中实现 TCC 模式呢?我将其主要实现归纳为资源解析、资源管理、事务处理。资源解析资源解析即是把 TCC 接口进行解析并注册,前面说过,TCC 接口可以是 PRC,也可以是 JVM 内部调用,在 Seata TCC 模块中中一个 remoting 模块,该模块专门用于解析具有 TwoPhaseBusinessAction 注解的 TCC 接口资源:RemotingParser 接口主要有 isRemoting、isReference、isService、getServiceDesc 等方法,默认的实现为 DefaultRemotingParser,其余各自的 RPC 协议解析类都在 DefaultRemotingParser 中执行,Seata 目前已经实现了对 Dubbo、HSF、SofaRpc、LocalTCC 的 RPC 协议的解析,同时具备 SPI 可扩展性,未来欢迎大家为 Seata 提供更多的 RPC 协议解析类。在 Seata 启动过程中,有个 GlobalTransactionScanner 注解进行扫描,会执行以下方法:io.seata.spring.util.TCCBeanParserUtils#isTccAutoProxy该方法目的是判断 bean 是否已被 TCC 代理,在过程中会先判断 bean 是否是一个 Remoting bean,如果是则调用 getServiceDesc 方法对 remoting bean 进行解析,同时判断如果是一个发起方,则对其进行资源注册:io.seata.rm.tcc.remoting.parser.DefaultRemotingParser#parserRemotingServiceInfopublic RemotingDesc parserRemotingServiceInfo(Object bean, String beanName, RemotingParser remotingParser) { RemotingDesc remotingBeanDesc = remotingParser.getServiceDesc(bean, beanName); if (remotingBeanDesc == null) { return null; } remotingServiceMap.put(beanName, remotingBeanDesc); Class<?> interfaceClass = remotingBeanDesc.getInterfaceClass(); Method[] methods = interfaceClass.getMethods(); if (remotingParser.isService(bean, beanName)) { try { //service bean, registry resource Object targetBean = remotingBeanDesc.getTargetBean(); for (Method m : methods) { TwoPhaseBusinessAction twoPhaseBusinessAction = m.getAnnotation(TwoPhaseBusinessAction.class); if (twoPhaseBusinessAction != null) { TCCResource tccResource = new TCCResource(); tccResource.setActionName(twoPhaseBusinessAction.name()); tccResource.setTargetBean(targetBean); tccResource.setPrepareMethod(m); tccResource.setCommitMethodName(twoPhaseBusinessAction.commitMethod()); tccResource.setCommitMethod(interfaceClass.getMethod(twoPhaseBusinessAction.commitMethod(), twoPhaseBusinessAction.commitArgsClasses())); tccResource.setRollbackMethodName(twoPhaseBusinessAction.rollbackMethod()); tccResource.setRollbackMethod(interfaceClass.getMethod(twoPhaseBusinessAction.rollbackMethod(), twoPhaseBusinessAction.rollbackArgsClasses())); // set argsClasses tccResource.setCommitArgsClasses(twoPhaseBusinessAction.commitArgsClasses()); tccResource.setRollbackArgsClasses(twoPhaseBusinessAction.rollbackArgsClasses()); // set phase two method's keys tccResource.setPhaseTwoCommitKeys(this.getTwoPhaseArgs(tccResource.getCommitMethod(), twoPhaseBusinessAction.commitArgsClasses())); tccResource.setPhaseTwoRollbackKeys(this.getTwoPhaseArgs(tccResource.getRollbackMethod(), twoPhaseBusinessAction.rollbackArgsClasses())); //registry tcc resource DefaultResourceManager.get().registerResource(tccResource); } } } catch (Throwable t) { throw new FrameworkException(t, "parser remoting service error"); } } if (remotingParser.isReference(bean, beanName)) { //reference bean, TCC proxy remotingBeanDesc.setReference(true); } return remotingBeanDesc; }以上方法,先调用解析类 getServiceDesc 方法对 remoting bean 进行解析,并将解析后的 remotingBeanDesc 放入 本地缓存 remotingServiceMap 中,同时调用解析类 isService 方法判断是否为发起方,如果是发起方,则解析 TwoPhaseBusinessAction 注解内容生成一个 TCCResource,并对其进行资源注册。资源管理1、资源注册Seata TCC 模式的资源叫 TCCResource,其资源管理器叫 TCCResourceManager,前面讲过,当解析完 TCC 接口 RPC 资源后,如果是发起方,则会对其进行资源注册:io.seata.rm.tcc.TCCResourceManager#registerResourcepublic void registerResource(Resource resource) { TCCResource tccResource = (TCCResource)resource; tccResourceCache.put(tccResource.getResourceId(), tccResource); super.registerResource(tccResource); }TCCResource 包含了 TCC 接口的相关信息,同时会在本地进行缓存。继续调用父类 registerResource 方法(封装了通信方法)向 TC 注册,TCC 资源的 resourceId 是 actionName,actionName 就是 @TwoParseBusinessAction 注解中的 name。2、资源提交/回滚io.seata.rm.tcc.TCCResourceManager#branchCommitpublic BranchStatus branchCommit(BranchType branchType, String xid, long branchId, String resourceId, String applicationData) throws TransactionException { TCCResource tccResource = (TCCResource)tccResourceCache.get(resourceId); if (tccResource == null) { throw new ShouldNeverHappenException(String.format("TCC resource is not exist, resourceId: %s", resourceId)); } Object targetTCCBean = tccResource.getTargetBean(); Method commitMethod = tccResource.getCommitMethod(); if (targetTCCBean == null || commitMethod == null) { throw new ShouldNeverHappenException(String.format("TCC resource is not available, resourceId: %s", resourceId)); } try { //BusinessActionContext BusinessActionContext businessActionContext = getBusinessActionContext(xid, branchId, resourceId, applicationData); // ... ... ret = commitMethod.invoke(targetTCCBean, args); // ... ... return result ? BranchStatus.PhaseTwo_Committed : BranchStatus.PhaseTwo_CommitFailed_Retryable; } catch (Throwable t) { String msg = String.format("commit TCC resource error, resourceId: %s, xid: %s.", resourceId, xid); LOGGER.error(msg, t); return BranchStatus.PhaseTwo_CommitFailed_Retryable; } }当 TM 决议二阶段提交,TC 会通过分支注册的的资源 ID 回调到对应参与者(即 TCC 接口发起方)服务中执行 TCC Resource 的 Confirm/Cancel 方法。资源管理器中会根据 resourceId 在本地缓存找到对应的 TCCResource,同时根据 xid、branchId、resourceId、applicationData 找到对应的 BusinessActionContext 上下文,执行的参数就在上下文中。最后,执行 TCCResource 中获取 commit 的方法进行二阶段提交。二阶段回滚同理类似。事务处理前面讲过,如果 TCC 接口时一个调用方,则会使用 Seata TCC 代理对调用方进行拦截处理,并在处理调用真正的 RPC 方法前对分支进行注册。执行方法io.seata.spring.util.TCCBeanParserUtils#isTccAutoProxy除了对 TCC 接口资源进行解析,还会判断 TCC 接口是否为调用方,如果是调用方则返回 true:io.seata.spring.annotation.GlobalTransactionScanner#wrapIfNecessary如图,当 GlobalTransactionalScanner 扫描到 TCC 接口调用方(Reference)时,会使 TccActionInterceptor 对其进行代理拦截处理,TccActionInterceptor 实现 MethodInterceptor。在 TccActionInterceptor 中还会调用 ActionInterceptorHandler 类型执行拦截处理逻辑,事务相关处理就在 ActionInterceptorHandler#proceed 方法中:public Object proceed(Method method, Object[] arguments, String xid, TwoPhaseBusinessAction businessAction, Callback<Object> targetCallback) throws Throwable { //Get action context from arguments, or create a new one and then reset to arguments BusinessActionContext actionContext = getOrCreateActionContextAndResetToArguments(method.getParameterTypes(), arguments); //Creating Branch Record String branchId = doTccActionLogStore(method, arguments, businessAction, actionContext); // ... ... try { // ... ... return targetCallback.execute(); } finally { try { //to report business action context finally if the actionContext.getUpdated() is true BusinessActionContextUtil.reportContext(actionContext); } finally { // ... ... } } }以上,在执行 TCC 接口一阶段之前,会调用 doTccActionLogStore 方法分支注册,同时还会将 TCC 相关信息比如参数放置在上下文,上面讲的资源提交/回滚就会用到这个上下文。如何控制异常在 TCC 模型执行的过程中,还可能会出现各种异常,其中最为常见的有空回滚、幂等、悬挂等。下面我讲下 Seata 是如何处理这三种异常的。如何处理空回滚什么是空回滚?空回滚指的是在一个分布式事务中,在没有调用参与方的 Try 方法的情况下,TM 驱动二阶段回滚调用了参与方的 Cancel 方法。那么空回滚是如何产生的呢?如上图所示,全局事务开启后,参与者 A 分支注册完成之后会执行参与者一阶段 RPC 方法,如果此时参与者 A 所在的机器发生宕机,网络异常,都会造成 RPC 调用失败,即参与者 A 一阶段方法未成功执行,但是此时全局事务已经开启,Seata 必须要推进到终态,在全局事务回滚时会调用参与者 A 的 Cancel 方法,从而造成空回滚。要想防止空回滚,那么必须在 Cancel 方法中识别这是一个空回滚,Seata 是如何做的呢?Seata 的做法是新增一个 TCC 事务控制表,包含事务的 XID 和 BranchID 信息,在 Try 方法执行时插入一条记录,表示一阶段执行了,执行 Cancel 方法时读取这条记录,如果记录不存在,说明 Try 方法没有执行。如何处理幂等幂等问题指的是 TC 重复进行二阶段提交,因此 Confirm/Cancel 接口需要支持幂等处理,即不会产生资源重复提交或者重复释放。那么幂等问题是如何产生的呢?如上图所示,参与者 A 执行完二阶段之后,由于网络抖动或者宕机问题,会造成 TC 收不到参与者 A 执行二阶段的返回结果,TC 会重复发起调用,直到二阶段执行结果成功。Seata 是如何处理幂等问题的呢?同样的也是在 TCC 事务控制表中增加一个记录状态的字段 status,该字段有有 3 个值,分别为:tried:1committed:2rollbacked:3二阶段 Confirm/Cancel 方法执行后,将状态改为 committed 或 rollbacked 状态。当重复调用二阶段 Confirm/Cancel 方法时,判断事务状态即可解决幂等问题。如何处理悬挂悬挂指的是二阶段 Cancel 方法比 一阶段 Try 方法优先执行,由于允许空回滚的原因,在执行完二阶段 Cancel 方法之后直接空回滚返回成功,此时全局事务已结束,但是由于 Try 方法随后执行,这就会造成一阶段 Try 方法预留的资源永远无法提交和释放了。那么悬挂是如何产生的呢?如上图所示,在执行参与者 A 的一阶段 Try 方法时,出现网路拥堵,由于 Seata 全局事务有超时限制,执行 Try 方法超时后,TM 决议全局回滚,回滚完成后如果此时 RPC 请求才到达参与者 A,执行 Try 方法进行资源预留,从而造成悬挂。Seata 是怎么处理悬挂的呢?在 TCC 事务控制表记录状态的字段 status 中增加一个状态:suspended:4当执行二阶段 Cancel 方法时,如果发现 TCC 事务控制表有相关记录,说明二阶段 Cancel 方法优先一阶段 Try 方法执行,因此插入一条 status=4 状态的记录,当一阶段 Try 方法后面执行时,判断 status=4 ,则说明有二阶段 Cancel 已执行,并返回 false 以阻止一阶段 Try 方法执行成功。
Seata AT 模式是一种非侵入式的分布式事务解决方案,Seata 在内部做了对数据库操作的代理层,我们使用 Seata AT 模式时,实际上用的是 Seata 自带的数据源代理 DataSourceProxy,Seata 在这层代理中加入了很多逻辑,比如插入回滚 undo_log 日志,检查全局锁等。为什么要检查全局锁呢,这是由于 Seata AT 模式的事务隔离是建立在支事务的本地隔离级别基础之上的,在数据库本地隔离级别读已提交或以上的前提下,Seata 设计了由事务协调器维护的全局写排他锁,来保证事务间的写隔离,同时,将全局事务默认定义在读未提交的隔离级别上。Seata 事务隔离级别解读在讲 Seata 事务隔离级之前,我们先来回顾一下数据库事务的隔离级别,目前数据库事务的隔离级别一共有 4 种,由低到高分别为:Read uncommitted:读未提交Read committed:读已提交Repeatable read:可重复读Serializable:序列化数据库一般默认的隔离级别为读已提交,比如 Oracle,也有一些数据的默认隔离级别为可重复读,比如 Mysql,一般而言,数据库的读已提交能够满足业务绝大部分场景了。我们知道 Seata 的事务是一个全局事务,它包含了若干个分支本地事务,在全局事务执行过程中(全局事务还没执行完),某个本地事务提交了,如果 Seata 没有采取任务措施,则会导致已提交的本地事务被读取,造成脏读,如果数据在全局事务提交前已提交的本地事务被修改,则会造成脏写。由此可以看出,传统意义的脏读是读到了未提交的数据,Seata 脏读是读到了全局事务下未提交的数据,全局事务可能包含多个本地事务,某个本地事务提交了不代表全局事务提交了。在绝大部分应用在读已提交的隔离级别下工作是没有问题的,而实际上,这当中又有绝大多数的应用场景,实际上工作在读未提交的隔离级别下同样没有问题。在极端场景下,应用如果需要达到全局的读已提交,Seata 也提供了全局锁机制实现全局事务读已提交。但是默认情况下,Seata 的全局事务是工作在读未提交隔离级别的,保证绝大多数场景的高效性。全局锁实现AT 模式下,会使用 Seata 内部数据源代理 DataSourceProxy,全局锁的实现就是隐藏在这个代理中。我们分别在执行、提交的过程都做了什么。1、执行过程执行过程在 StatementProxy 类,在执行过程中,如果执行 SQL 是 select for update,则会使用 SelectForUpdateExecutor 类,如果执行方法中带有 @GlobalTransactional or @GlobalLock注解,则会检查是否有全局锁,如果当前存在全局锁,则会回滚本地事务,通过 while 循环不断地重新竞争获取本地锁和全局锁。io.seata.rm.datasource.exec.SelectForUpdateExecutor#doExecutepublic T doExecute(Object... args) throws Throwable { Connection conn = statementProxy.getConnection(); // ... ... try { // ... ... while (true) { try { // ... ... if (RootContext.inGlobalTransaction() || RootContext.requireGlobalLock()) { // Do the same thing under either @GlobalTransactional or @GlobalLock, // that only check the global lock here. statementProxy.getConnectionProxy().checkLock(lockKeys); } else { throw new RuntimeException("Unknown situation!"); } break; } catch (LockConflictException lce) { if (sp != null) { conn.rollback(sp); } else { conn.rollback(); } // trigger retry lockRetryController.sleep(lce); } } } finally { // ... }2、提交过程提交过程在 ConnectionProxy#doCommit方法中。1)如果执行方法中带有@GlobalTransactional注解,则会在注册分支时候获取全局锁:请求 TC 注册分支io.seata.rm.datasource.ConnectionProxy#registerprivate void register() throws TransactionException { if (!context.hasUndoLog() || !context.hasLockKey()) { return; } Long branchId = DefaultResourceManager.get().branchRegister(BranchType.AT, getDataSourceProxy().getResourceId(), null, context.getXid(), null, context.buildLockKeys()); context.setBranchId(branchId); }TC 注册分支的时候,获取全局锁io.seata.server.transaction.at.ATCore#branchSessionLockprotected void branchSessionLock(GlobalSession globalSession, BranchSession branchSession) throws TransactionException { if (!branchSession.lock()) { throw new BranchTransactionException(LockKeyConflict, String .format("Global lock acquire failed xid = %s branchId = %s", globalSession.getXid(), branchSession.getBranchId())); } }2)如果执行方法中带有@GlobalLock注解,在提交前会查询全局锁是否存在,如果存在则抛异常:io.seata.rm.datasource.ConnectionProxy#processLocalCommitWithGlobalLocksprivate void processLocalCommitWithGlobalLocks() throws SQLException { checkLock(context.buildLockKeys()); try { targetConnection.commit(); } catch (Throwable ex) { throw new SQLException(ex); } context.reset(); }GlobalLock 注解说明从执行过程和提交过程可以看出,既然开启全局事务 @GlobalTransactional注解可以在事务提交前,查询全局锁是否存在,那为什么 Seata 还要设计多处一个 @GlobalLock注解呢?因为并不是所有的数据库操作都需要开启全局事务,而开启全局事务是一个比较重的操作,需要向 TC 发起开启全局事务等 RPC 过程,而@GlobalLock注解只会在执行过程中查询全局锁是否存在,不会去开启全局事务,因此在不需要全局事务,而又需要检查全局锁避免脏读脏写时,使用@GlobalLock注解是一个更加轻量的操作。如何防止脏写先来看一下使用 Seata AT 模式是怎么产生脏写的:注:分支事务执行过程省略其它过程。业务一开启全局事务,其中包含分支事务A(修改 A)和分支事务 B(修改 B),业务二修改 A,其中业务一执行分支事务 A 先获取本地锁,业务二则等待业务一执行完分支事务 A 之后,获得本地锁修改 A 并入库,业务一在执行分支事务时发生异常了,由于分支事务 A 的数据被业务二修改,导致业务一的全局事务无法回滚。如何防止脏写?1、业务二执行时加 @GlobalTransactional注解:注:分支事务执行过程省略其它过程。业务二在执行全局事务过程中,分支事务 A 提交前注册分支事务获取全局锁时,发现业务业务一全局锁还没执行完,因此业务二提交不了,抛异常回滚,所以不会发生脏写。2、业务二执行时加 @GlobalLock注解:注:分支事务执行过程省略其它过程。与 @GlobalTransactional注解效果类似,只不过不需要开启全局事务,只在本地事务提交前,检查全局锁是否存在。2、业务二执行时加 @GlobalLock 注解 + select for update语句:注:分支事务执行过程省略其它过程。如果加了select for update语句,则会在 update 前检查全局锁是否存在,只有当全局锁释放之后,业务二才能开始执行 updateA 操作。如果单单是 transactional,那么就有可能会出现脏写,根本原因是没有 Globallock 注解时,不会检查全局锁,这可能会导致另外一个全局事务回滚时,发现某个分支事务被脏写了。所以加 select for update 也有个好处,就是可以重试。如何防止脏读Seata AT 模式的脏读是指在全局事务未提交前,被其它业务读到已提交的分支事务的数据,本质上是Seata默认的全局事务是读未提交。那么怎么避免脏读现象呢?业务二查询 A 时加 @GlobalLock 注解 + select for update语句:注:分支事务执行过程省略其它过程。加select for update语句会在执行 SQL 前检查全局锁是否存在,只有当全局锁完成之后,才能继续执行 SQL,这样就防止了脏读。
上次讲到 Raft 领导者选举:「图解 Raft 共识算法:如何选举领导者?」,接着这个话题继续跟大家聊下关于 Raft 日志复制的一些细节。Raft 日志格式在 Raft 算法中,需要实现分布式一致性的数据被称作日志,我们 Java 后端绝大部分人谈到日志,一般会联想到项目通过 log4j 等日志框架输出的信息,而 Raft 算法中的数据提交记录,他们会按照时间顺序进行追加,Raft 也是严格按照时间顺序并已一定的格式写入日志文件中:如上图所示,Raft 的日志以日志项(LogEntry)的形式来组织,每个日志项包含一条命令、任期信息、日志项在日志中的位置信息(索引值 LogIndex)。指令:由客户端请求发送的执行指令,有点绕口,我觉得理解成客户端需要存储的日志数据即可。索引值:日志项在日志中的位置,需要注意索引值是一个连续并且单调递增的整数。任期编号:创建这条日志项的领导者的任期编号。日志复制过程Raft 的复制过程大致如下:领导者接收到客户端发来的请求,创建一个新的日志项,并将其追加到本地日志中,接着领导者通过追加条目 RPC 请求,将新的日志项复制到跟随者的本地日志中,当领导者收到大多数跟随者的成功响应之后,则将这条日志项应用到状态机中,可以理解成该条日志写成功了,最后领导者返回日志写成功的消息响应客户端,流程如下图所示:可以看出,Raft 的复制过程中,领导者接收到大多数跟随者成功响应,并且将日志项应用到状态机之后,不需要将结果响应给跟随者,而是直接将成功消息响应给客户端,这是一种优化方式,同时 Raft 会在下一次 RPC 追加日志请求中附加上本次的日志项信息。以上仅仅只是一种没有发生任何问题的复制过程,在这过程中难免会发生节点宕机等问题,在这种情况下,Raft 是如何处理的呢?如何保证日志的一致性?上面讲到,在正常情况下,领导者的日志追加 RPC 请求响应都成功的情况下,领导人和跟随者的日志保持一致性。然而在领导者突然宕机的情况下有可能会造成领导者与跟随者日志不一致的情况,这种情况会随着后续领导者一些列宕机的情况下加剧问题的严重:注:例子来源于 Raft 论文。如上所示,当一个领导者成功当选时,跟随者有可能是 a-f 的情况:a-b 表示跟随者的日志项落后于当前领导者;c-d 表示跟随者有些日志项没有被提交;e-f 情况稍微有点复杂,以上两种情况它们都存在。下面我来还原上面图所表示的情况是怎么发生的:假设一开始 e 为领导者,在任期 2 时,f 被推选为领导者,写入了若干日志项之后,在追加 RPC 请求中崩溃了,重启后又被选举为领导者(任期号 3),又在写入了若干日志项之后奔溃了;e 此时又重新选举为领导者(任期号为 4),成功复制了若干日志项,同时还有一部分没有成功追加到大多数跟随者又崩溃了,同时跟随者 b 复制了一部分日志项之后崩溃了;假设 a 在任期 5 时被选举为领导者,c 在任期 6 时被选举为领导者,还未全部将本地日志复制到其他跟随者之前又崩溃了,在任期 7 时 d 被选择为领导者,写入了若干日志项之后,在追加 RPC 请求中崩溃了,最后形成了上图的情况。面对以上的情况,Raft 是如何解决日志的一致性呢?在 Raft 的日志机制中,为了简化日志一致性的行为,有以下两点非常重要的特性:如果在不同的日志中的两个条目拥有相同的索引和任期号,那么他们存储了相同的指令。如果在不同的日志中的两个条目拥有相同的索引和任期号,那么他们之前的所有日志条目也全部相同。第一个特性是因为 Raft 日志项在日志中不会改变,因此只要日志项只要是索引值和任期号相同,就可以认为他们是存储了相同的指令数据信息。第二个特性是因为领导者会通过强制覆盖的方式让跟随者复制自己的日志来解决日志不一致的问题,领导者在追加 RPC 请求过程中会附带需要复制的日志以及前一个日志项相关信息,如果跟随者匹配不到包含相同索引位置和任期号的日志项,那么他就会拒绝接收新的日志条目,接着领导者会继续递减要复制的日志项索引值,直至找到相同索引和任期号的日志项,最后就直接覆盖跟随者之后的日志项。可认为两个条目拥有相同的索引和任期号,那么他们之前的所有日志条目也全部相同。因此,Raft 的日志追加大致可分为两个步骤:领导者找到跟随者与自己相同的最大日志项,这意味着跟随者之前的日志都与领导者的日志相同;领导者强制覆盖之后不一致的日志,实现日志的一致性。下面我用一个例子充分表达 Raft 在日志复制过程中是如何进行日志强制覆盖的。假设有一个领导者和一个跟随者,他们的日志项复制情况如下:可以看出,跟随者在任期号 3 时是领导者,在追加日志过程中崩溃了,重启之后成为跟随者,随后新的领导者向其追加日志,此时他的任期号为 3 最后的一个日志项将被覆盖。先来看下 Raft 追加条目 RPC 的请求参数:参数描述term领导者的任期leaderId领导者ID 因此跟随者可以对客户端进行重定向(译者注:跟随者根据领导者id把客户端的请求重定向到领导者,比如有时客户端把请求发给了跟随者而不是领导者)prevLogIndex紧邻新日志条目之前的那个日志条目的索引prevLogTerm紧邻新日志条目之前的那个日志条目的任期entries[]需要被保存的日志条目(被当做心跳使用是 则日志条目内容为空;为了提高效率可能一次性发送多个)leaderCommit领导者的已知已提交的最高的日志条目的索引领导者追加并覆盖跟随者过程如下:领导者通过日志追加 RPC 请求,将当前最新的要追加到跟随者的日志项以及前一个它的 prevLogIndex=7、prevLogTerm=3 等信息发送跟跟随者;跟随者判断当前最新的日志的任期号与 prevLogTerm 不一致,拒绝追加;领导者继续递减需要复制的日志项的索引值,此时 prevLogIndex=6、prevLogTerm=3;跟随者找到了 LogIndex=6、LogTerm=3 的日志项,跟随者接受追加请求;领导者接着会将跟随者 LogIndex=6、LogTerm=3 的日志项之后的日志项进行追加并覆盖。
前段时间在公众号读者交流群,有读者提问到关于并发场景相关的问题:从读者的描述,可以看出高并发处理的经验,在面试中占据着举足轻重的地位,关于高并发相关的面试题,一直都是面试热题,因为这类面试题能够更加直观地体现候选人的技术水平与深度。如何解决高并发场景下的问题,永远都不会过时。在之前的工作经历中,我做过营销相关项目,接触过关于票券秒杀的高并发场景,秒杀场景也算是最热门的高并发场景之一了。下面我就把我对秒杀场景的一些理解简单写下来,仅供大家参考,欢迎留言纠错或者补充。核心要素何为高并发?高并发指的是在同一时刻,有大量用户的请求同时到达服务器,而服务器需要在有限的资源内处理这些请求,并尽可能快地响应用户请求。在秒杀场景中,我们需要从在大量并发请求过程中提升服务器的处理性能,在处理过程中数据处理不能存错,同时在整个秒杀链路中需要满足高可用性,即在秒杀过程中,服务不能突然掉链子,需要满足秒杀场景活动生命周期的完成。我们可以总结出秒杀场景中有三个核心要素:高性能;一致性;高可用性。如何提高性能?秒杀场景核心的问题是如何解决海量请求带来的性能问题,那么我们如何在有限的资源下,尽最大的限度去提高服务器访问性能?按照我以往的经验,我大致总结有这几点:热点数据处理、流量削峰、资源隔离、服务器优化。热点数据处理1、什么是热点数据?我理解的热点数据指的是用户请求量非常高的那些数据,在秒杀场景中,热点数据就是那些要被秒杀的商品数据。这些热点请求会大量占用服务器的资源,如果不对这些数据进行处理,那么会严重占用资源,进而影响系统的性能,导致其他业务也受影响。热点数据又可以分为“静态热点数据”和“动态热点数据”。2、静态热点数据静态热点数据指的是可以提前预知的热点数据,比如本文所说的秒杀场景,需要参与本次秒杀的商家提前报名,并将秒杀的商品录入热点分析系统中。业务系统通过这次提前录入的热点数据,进行预加载,甚至可以将数据放入本地缓存中,这样做的好处可以有效缓解避缓存集群的压力,避免流量集中时压垮缓存集群。可能有人会问如何更新本地缓存?我的做法是将热点数据录入热点分析平台,本地对热点数据进行订阅,并根据订阅规则去更新本地缓存即可。3、动态热点数据动态指的就是不能提前预知哪些数据是热点的,需要通过数据收集与分析,或者通过大数据平台预测。我的做法是通过在网关平台中做一个用于收集日志的异步日志收集系统,通过采集商品请求的日志,处理后发送到热点分析平台,热点分析平台通过一些列的分析计算将这些热点商品进行热点数据处理,后端通过订阅这些热点数据就可以识别哪些商品是热点数据了。流量削峰在服务器资源固定的情况下,说明处理能力是有峰值存在的,如果不对请求处理进行处理的话,很可能会在流量峰值的瞬间压垮服务器,但流量峰值存在的时间不长,其实服务器的处理能力大部分时间都是处于闲置状态,那么我们可不可以将峰值集中的请求分散到其他时间呢?1、消息队列消息队列除了在解耦、异步场景之外,最大的作用场景是用于流量削峰,面对海量流量请求,可以将这些请求数据用异步的方式先存放在消息队列中,而消息队列一般都能够存储大量消息,消息会被消费端订阅消费,这样就有效地将峰值均摊到其他时间进行处理了。如上,消息队列就像我们平常见到的水库一样,当洪水来临时,拦住并对其进行储蓄,以减少对下游的冲击,避免了洪水的灾害。目前有大量优秀的开源消息队列框架,如 RocketMQ、Kafka 等,而我之前在中通时主要负责消息平台的建设与维护工作,中通每天面对几千万的订单流量依然那么稳固,其中消息队列起了很大的“防洪”作用!2、答题除了利用消息队列对请求进行“储蓄”达到削峰的目的之外,还可以通过在用户发起请求前,对用户进行一些校验操作,比如答题、输入验证码等等,这种答题机制,除了可以防止买家在秒杀过程中使用作弊脚本之外,在秒杀场景中最主要的作还是将请求分散到各个时间点,秒杀场景一般都是集中在某个点进行,比如 0 点时刻,如果没有答题机制,几乎所有的流量都在 0 点时刻涌入服务器中,如果有答题机制,就能延缓用户的请求,从而达到请求分散到各个时间点的目的。如何保持一致性?秒杀场景,本质上就是在海量买家同时请求购买时,能够准确并将商品卖出去。在秒杀的高并发读写请求过程中,需要保证商品不会发生“超卖”现象,因为秒杀的商品是数量一定的,但会有成千上万个用户在同一时间下单购买,在减扣库存过程中如何保证商品数量的准确性至关重要。减扣库存方案分析我在以前在做秒杀项目的时,分析过几种减扣库存的方式,我简单分析下。1、下单减扣库存买家只要完成下单,立即减扣商品库存,这种方式实现是最简单而且也是最精准的,通常可以在下单时利用数据库事务能力即可保证减扣库存的准确性,但需要考虑买家下单后不付款的情况。2、付款减扣库存即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现买家下单后付不了款的情况,因为可能商品已经被其他人买走了。当只有买家下单后,并且已完成付款,才执行库存的减扣,这种方式好处是避免了买家不付款导致实际没有卖出这么多商品的情况,但这种方式会造成用户体验不好,因为这会导致有些用户付款时商品有可能被人买走了导致付款失败的问题。3、预扣库存这种方式结合以上两种方式的优点,当买家下单后,预扣库存,只会其保留一定的时间,比如 10 分钟,在这段时间内如果买家不付款,则将库存自动释放,其它买家可以继续抢购。这种做法需要买家付款前,再做一次商品库是否还有保留,如果没有保留,则再次尝试预扣,预扣失败则不允许继续付款;如果有保留,付款完成后执行真正的减扣库存动作。但预扣库存依然没有彻底解决减扣库存链路中存在的问题,比如有些买家可以在释放的瞬间立马又重新下单一次,相当于将库存无限地保留下去,因此我们还需要将记录用户下单次数,如果连续下单超过一定次数,或者超过下单并不付款次数,就拦截用户下单请求。总结:一般最简单的做法就是使用下单减库存的方式(我之前的项目中就是用的这种),我当初的考虑是因为在秒杀场景中,商品的性价比通常很高,秒杀就是创造一种只有少量买家能买到的场景,一般来说买家只要“秒”到商品了,极少情况会出现退款的,即使发生了少量退款,造成实际卖出去的商品会比数据上少,也是可以通过候补来解决。如何减扣库存?减扣库存动作应该放在哪里执行?下面我具体分析一下减扣库存的几种实现方式:如果链路涉及的逻辑比较简单的,比如下单减库存这种方式,最简单的做法就是在下单时,利用数据库的本地事务机制进行对库存的减扣,比如使用 where 库存 >0不满足就回滚;将库存数量值放在缓存中,比如 Redis,并做持久化处理。需要注意的是,如果遇到减扣库存的逻辑很复杂,比如减扣库存之后需要在同一个事务中做一些其他事情,那么就不能使用第二种方式了,只能使用第一种方式在数据库层面上面操作,以保证同在一个事务中。面对这种情况,你可以将热点数据进行数据库隔离,把这些热点商品单独放在一个数据库中。如何实现高可用性?最后,为了保证秒杀系统的高可用性,必须要对系统进行兜底处理,以便遇到极端的情况系统依然能够运转,通常的做法有服务降级、服务限流、拒绝请求等方式处理。服务降级当请求量达到系统承受的能力时,需要对系统的一些非核心功能进行关闭操作,尽可能将资源留给秒杀核心链路。比如在秒杀系统中,还存在其他非核心的功能,我们可以在系统中设计一些动态开关,比如在网关层在路由开关,将这些非核心的请求直接在最外层拒掉。还有就是对页面展示的数据进行精简化,用降低用户体验换取核心链路的稳定运行。服务限流限流的目的是通过对并发访问/请求进行限速或者一个时间窗口内的的请求进行限速来保护系统,常用的有 QPS 限流,用户请求排队限流,需要设置过期时间,一旦超过过期时间则丢弃,这样做是为了用户请求可以做到快速失败的效果,这种机制在 RocketMQ 中也有相关的应用,RocketMQ broker 会对客户端请求进行排队限流处理,当请求在队列中超过了过期时间,则丢弃,客户端快速失败进行第二轮重试。拒绝请求如果服务降级、服务限流都不能解决问题,最后的兜底,那就是直接拒绝用户请求,比如直接给用户返回 “服务器繁忙,请稍后再试”等提示文案。只会发生在服务器负载过载时会启动,因此只会发生短暂不可用时刻,由于此时服务依然还在稳定运行中,等负载下降时,可以快速恢复正常服务。
IDEA 被越来越多的 Java 开发者所接受,我也不例外,当年刚入职场时用的是 Eclipse,后来看到有同事用 IDEA,我也跟风下载了使用了,之后再也回不去 Eclipse 了,相比 Eclipse,IDEA 简直好用到爆,无论是从界面 UI,还是智能提示,完爆 Eclipse 好吗?在我心目中,IDEA 是最好用的 IDE,没有之一!在网上我也看过一些段子手说的:“可以毫不夸张地说,多少 Java 程序员离开了 IDEA 就不会写代码了(手动狗头)!在我刚入职新公司不久,Java 领域最好用的 IDE,IntelliJ IDEA 发布了 2021 年第一个大版本更新:IntelliJ IDEA 2021.1,加入了很多特性,启动也更快了。1、全新的启动界面跟以往的启动界面有很大的不同,全新的启动界面变得更加花里胡哨,同时不失雅致,宛如在跟开发者彰显着版本特性之多。同时发现,IDEA 启动的速度更加快了!2、集成 SpaceJetBrains 的 团队协作工具平台 Space 不知道大家有没有用过,可以在 Jetbrains 官网单独下载这个工具:Space 是一个团队协作环境,它集成了软件开发、团队管理、聊天和文件、项目管理等在内的一整套协作一体化解决方案。在最新的 IntelliJ IDEA 2021.1 版本中,以插件的形式将其集成在 IDEA 中:3、Code With MeCode With Me 是一项用于协作开发与结对编程的服务,可以远程结对编程,手把手教你写代码:值得一提的是,它还具有视频和语音通话功能,使远程协作更上一层楼。每当您想与同事交谈时,都可以直接从JetBrains IDE 发起音频和视频通话。“与我一起编码” 非常适合 1:1 会议和小组会议,您可以邀请数十名与会人员参加!这功能简直太骚气了!4、支持 Java 16我现在还在用这 Java 8,相信很多读者都是 Java 8 忠实用户吧?新版任你发,我用 Java 8 !但 IntelliJ IDEA 新版本还是对对 Java 16 进行了一波支持,该支持于 2021 年 3 月发布。某些更新包括内部类可以声明显式或隐式静态成员以及对Stream.collect(toUnmodifiableList())进行更改的新可能性。现在将其转换为stream.toList()。IDE 在流的代码完成列表上首先显示toList()项目。5、支持 WSL 2这个功能对于要依赖 WSL 功能的用户来说,简直太赞了!要知道以前的 WSL 是没有和 IDEA 打通的!现在,你可以使用 IntelliJ IDEA 中 WSL 2 中的 Java 项目。IDE 可以检测 JDK 安装,安装 JDK,编译和运行 Maven 和 Gradle 项目以及使用 IntelliJ IDEA 构建系统的项目。6、Run Targets这个功能有点类似于 WSL 2,通过运行目标功能,你可以在 Docker 容器或远程计算机上运行,测试,分析和调试应用程序。IntelliJ IDEA Ultimate 当前允许你在 Docker,SSH 和 WSL 目标上运行 Java 应用程序,JUnit 测试以及 Maven,Gradle,Micronaut,基于 Maven 的 Quarkus 和 Spring Boot 项目。有没有发现,IntelliJ IDEA 2021.1 大大加强了对远程的操控本领,开发者不仅可以在本地运行项目,用上新版之后,还可以使用 WSL 2、SSH 远程主机、Docker 上运行项目了!IntelliJ IDEA 2021.1 新增和增强的特性远远不止我上述说到的更新,它还有很多在本次新增的特性,比如:IDE 内置 HTML 预览窗口、增强了对 Kotlin、Scala、JavaScript 等语言特性的支持和优化、对容器和容器编排方面:Docker 和 kubernetes 支持优化等等,更多特性可以查看 What’s New in IntelliJ IDEA 2021.1:https://www.jetbrains.com/idea/whatsnew/
Seata 是一款开源的分布式事务解决方案,star 高达 19200+,社区活跃度极高,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。注:本期分享借鉴于 Seata 三位 PMC 清铭、煊檍、屹远 分享人:陈健斌(funkye) github id: a364176773作者介绍:同盾科技高级开发工程师 、Seata Committer、Spring cloud alibaba contributor,、Mybatis-Plus contributor(by dynamic-datasource)目录XA模式是什么?什么是 Seata 的事务模式?AT模式是什么?为什么Seata要支持XA模式?AT与XA之间的关系总结1. XA模式是什么?首先正如煊檍兄所言,了解了什么是XA与什么是Seata定义的事务模式,便一目了然。1.1 什么是XA用非常官方的话来说XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准。XA 规范 描述了全局的事务管理器与局部的资源管理器之间的接口。XA规范 的目的是允许的多个资源(如数据库,应用服务器,消息队列等)在同一事务中访问,这样可以使 ACID 属性跨越应用程序而保持有效。XA 规范 使用两阶段提交(2PC,Two-Phase Commit)来保证所有资源同时提交或回滚任何特定的事务。XA 规范 在上世纪 90 年代初就被提出。目前,几乎所有主流的数据库都对 XA 规范 提供了支持。1.2 什么是Seata的事务模式?Seata 定义了全局事务的框架。全局事务 定义为若干 分支事务 的整体协调:1.TM 向 TC 请求发起(Begin)、提交(Commit)、回滚(Rollback)全局事务。2.TM 把代表全局事务的 XID 绑定到分支事务上。3.RM 向 TC 注册,把分支事务关联到 XID 代表的全局事务中。4.RM 把分支事务的执行结果上报给 TC。(可选) 5.TC 发送分支提交(Branch Commit)或分支回滚(Branch Rollback)命令给 RM。Seata 的 全局事务 处理过程,分为两个阶段:执行阶段 :执行 分支事务,并 保证 执行结果满足是 可回滚的(Rollbackable) 和 持久化的(Durable)。完成阶段:根据 执行阶段 结果形成的决议,应用通过 TM 发出的全局提交或回滚的请求给 TC, TC 命令 RM 驱动 分支事务 进行 Commit 或 Rollback。Seata 的所谓 事务模式 是指:运行在 Seata 全局事务框架下的 分支事务 的行为模式。准确地讲,应该叫作 分支事务模式。不同的 事务模式 区别在于 分支事务 使用不同的方式达到全局事务两个阶段的目标。即,回答以下两个问题:执行阶段 :如何执行并 保证 执行结果满足是 可回滚的(Rollbackable) 和 持久化的(Durable)。完成阶段:收到 TC 的命令后,做到事务的回滚/提交2. 那么什么是Seata XA 模式?XA 模式:在 Seata 定义的分布式事务框架内,利用事务资源(数据库、消息服务等)对 XA 协议的支持,以 XA 协议的机制来管理分支事务的一种 事务模式。执行阶段:可回滚:业务 SQL 操作放在 XA 分支中进行,由资源对 XA 协议的支持来保证 可回滚 持久化:XA 分支完成后,执行 XA prepare,同样,由资源对 XA 协议的支持来保证 持久化(即,之后任何意外都不会造成无法回滚的情况) 完成阶段:分支提交:执行 XA 分支的 commit 分支回滚:执行 XA 分支的 rollback以下是XA模式在Seata所定义的事务模式下的设计模型2.1 什么是Seata AT(TXC) 模式?去年 1 月份,Seata 开源了 AT 模式。AT 模式是一种无侵入的分布式事务解决方案。在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。通过简介,其实可以发现AT模式的特点,只需关注自己的业务sql,对业务无入侵的一种分布式事务模式。那么我们应该知道他是怎么对业务做到无入侵的?2.2 AT 模式如何做到对业务的无侵入 ?AT模式一阶段首先,在Seata的组件中,如果你想开启分布式事务,那么就应该在你的业务入口或者事务发起入口加上@GlobalTransactional注解如果你是AT模式就要做好数据源代理(seata1.0后全面支持自动代理),并被sqlsessionfactroy使用(或者直接jdbc操作使用被代理数据源)可以发现比较关键的异步,与其他模式的区别便是代理数据源,而代理数据源又有什么奥秘呢?如上图所示,你的数据源被代理后,通过被DataSourceProxy代理后,你所执行的sql,会被提取,解析,保存前镜像后,再执行业务sql,再保存后镜像,以便与后续出现异常,进行二阶段的回滚操作。2.3 AT 模式如何保证隔离性首先我们拿到官网所展示的文档来更直观的描述:可以通过上图得出:一阶段本地事务提交前,需要确保先拿到 全局锁 。拿不到 全局锁 ,不能提交本地事务。拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 ,如tx2等待所超时,那么tx2便回滚本地事务所以他不会产生脏数据。AT 模式二阶段提交二阶段如果是提交的话,因为“业务 SQL”在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。AT 模式二阶段回滚二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据。回滚方式便是用“before image”还原业务数据;但在还原前要首先要校验脏写, 对比“数据库当前业务数据”和 “after image”, 如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写, 出现脏写就需要转人工处理。完整的AT在Seata所制定的事务模式下的模型图:3. 为什么支持XA?首先我们应该从AT去做判断,为什么Seata有了AT模式还去做XA的支持从视角出发:首先,我们来总结下AT模式,首先所有的事物发起,都是从TM(不仅AT) 且数据的读已提交只能在应用中见效(用户自行开发的系统),对资源的查看,无法做到全方面 而XA可让资源也感知到自身已处于全局事务中,对资源的隔离性可由数据库本身来实现,满足 全局一致性从入侵性,数据库支持角度:业务无入侵的更彻底,少于2个服务的操作,仅使用本地事务即可满足一致性,而AT需要 全局锁来保证隔离性,所以无论是1个服务,单库的操作,还是n个服务都需要开启全局事务来保证 隔离性。对数据库的支持,如果AT需要支持mysql,pgsql,oracle以外的数据库,需要做适配,并且 对复杂sql的解析成本更大,开发效率低,支持的sql数量少,XA可全方位支持数据库的sql语句 多语言支持,如果你有java应用已经使用了seata xa那么本地数据库已经帮我们保证了隔离 性,即便其余seata不支持的语言和java并行处理下,数据也不会出现不一致的情况。4. 为什么Seata要支持XA模式?数据锁定:在整个事务处理过程结束前,涉及数据都被锁定,读写都按隔离级别的定义约束起来。AT 模式使用 全局锁 保障基本的 写隔离,实际上也是锁定数据的,只不过锁在 TC 侧集中管理 解锁效率高且没有阻塞的问题,且XA本地数据库可能持有间隙锁,造成锁的粒度更大,锁定更多无辜数据死锁(协议阻塞):XA prepare 后,分支事务进入阻塞阶段,收到 XA commit 或 XA rollback 前必须阻塞等待。如果没有一个靠谱的协调者存在,比如abc三个库的数据被二阶段决议为提交,此时ab收到的指令,提交后,c库在收到指令后挂了,并没有提交xa事务,或者协调者没有做到二阶段重试,那么这个没有提交的xa事务将会一直 持有锁,造成死锁的局面性能差:性能的损耗主要来自两个方面:一方面,事务协调过程,增加单个事务的 RT;另一方面,并发事务数 据的锁冲突,降低吞吐。其实主要原因就是上面的阻塞跟数据锁定造成,因为xa的一阶段并非提交,如果一阶段都是提交的场景下,由于At模式的一阶段提交,at的性能是优于xa,因为它锁在tc一侧集中释放,无需多个库进行本地的锁释放AT 与 XA 的关系首先,我们要明确,无论是AT还是XA,他们都是有利用到数据库自带的事务特性,来保证数据一致性和隔离性比如AT一阶段提交和二阶段回滚,都是执行了本地事务。比如XA的一阶段和二阶段,也都是利用了数据库本身的事务特性那么这样一样我们是否应该在数据库层面进行挖掘,AT与XA的关系呢?首先这个时候,我们肯定要从中找相同,与找不同。AT首当其冲,他有个必须品,也就是undolog表,undolog,相 信了解数据库的同学肯定是知道。数据库有六种日志分别是:重做日志(redo log)、回滚日志(undo log)、二进制日志(binlog)、错误日志(errorlog)、 慢查询日志(slow query log)、一般查询日志(general log),中继日志(relay log)那么数据库的undolog是做什么用的呢?undolog保存了事务发生之前的数据的一个版本,可以用于回滚,同时可以提供多版本并发控制下的读(MVCC)可以发现数据库的undolog跟seata at模式的undolog的作用不谋而合,所以可以判断,at模式的undolog就是把本地事务作用中的undolog,利用他的原理,做到了分布式事务中,来保证了分布式事务下的事务一致性。那么说完了undolog,redolog呢?Redolog的作用便是防止在发生故障的时间点,尚有脏页未写入磁盘,在重启mysql服务的时候,根据redo log进行 重做,从而达到事务的持久性这一特性。那么为什么Seata AT模式没有看到redolog的存在?其实很简单,这个redolog被隐藏的很深,也就是AT模式的一阶段提交,让数据库作为我们的redolog,保证一阶段的数据准确落盘。这个时候是不是会想到LCN事务模式?他的undolog由数据库来保证,缺少了一个redolog的存在。其实大可不必思念LCN事务,解析到这里,如果把AT改为一阶段不提交,二阶段提交时,前镜像便是undolog,后镜像便是redolog,也就是说AT其实就是一个不在数据库层面,按照数据库事务思想和实现原理的方式,做到了分布式中的事务一致性。这时候讲到这里,XA跟AT的关系应该一幕了然了,准确的说,其实应该说是分布式事务跟数据库本地事务的关系,可以说XA的缺点造成了AT模式的出生,锁在多侧(多个库),资源阻塞,性能差。而AT就像为了把事务的实现决定权从数据库手中,放到了Seata中,自实现sql解析,自实现undolog(redolog),既然我们没有 办法去直接优化数据库在分布式事务下的问题,那么不如创造一个新的模式,去其糟粕,取其精华。Seata AT 与 XA 的优劣其实上面零零碎碎也说了不少各自的优缺点,现在我们总结一下分3点来做比较sql支持隔离性侵入性我们先讲第一点,由于上面我们总结了,其实AT就是一个自实现的XA事务,所以其实可以知道,AT在sql支持上,是远不及利用本地事务的XA模式,既然AT需要做sql解析,那么背后的实现只能自己来解决,也就是靠Seata社区的贡献者们来贡献解决方案,这是一个长期性的关键问题,但是依然有很多用户选择了重写sql,来获取AT事务模式的支持。在sql支持上XA无疑是完胜的第二点隔离性,Seata AT模式通过解析sql获取涉及的主键id,生成行锁,也就是AT模式的隔离就是靠全局锁来保证,粒度细至行级,锁信息存储在Seata-Server一侧。XA模式的隔离性就是由本地数据库保证,锁存储在各个本地数据库中。由于XA模式一旦执行了prepare后,再也无法重入这个XA事务,也无法跟其他XA事务共享锁。因为XA协议,仅是通过XID来start一个xa事务,本身它不存在所谓的分支事务说法,它本事就是一个XA事务而已,也就是说它只管它自己。这时候可能由同学有疑问了,为什么我在branch_table里看到里XA分支事务呢?其实这个问题根据上面的什么是Seata事务模式可以了解到,Seata的事务模式就是由全局事务,分支事务,锁信息所组成。而XA的分支事务,仅仅是作为一个参与方的存在,也就是说这个XA分支在Seata定义中为分支事务,作为分支信息记录在案,方便宕机后也可以下发二阶段决议信息。而AT由于锁是自实现,也就相对XA来说,我只要知道用户sql涉及到的数据,是不是数据这个全局事务下的,只要是我默认他就可以使用这个锁,也就解决了重入问题。我们可以得出总结,XA的隔离性是全局的,AT的隔离性是更灵活且相对全局的(保证所有对数据的写操作被Seata事务覆盖)。第三点,入侵性,通过我们以上的信息,其实可以发现,谁更底层,谁的入侵性更小,所以由数据库自身所支持的XA模式来说,无疑入侵最小,使用成本最低。其实说到这里,大家可能会觉得XA模式怎么感觉比AT好这么多,虽然他不支持锁重入,但是我可以避免这个情况发生呀。这时候,我画个图,大家可能会比较理解上图中,右侧图1是at模式运行时,图2时xa模式运行时。可以很明显,xa的阻塞带来的性能下降时非常厉害的,特别是你的分支事务非常多,每个资源的释放必须等到每个分支的数据库去单独释放,后续的事务才能进入。虽然XA带来的无侵入非常高,但是由于性能下降的程度太大,也就促使了AT的诞生,而现在AT,TCC,SAGA的模式的接受度也越来越高,这也正说明了开发者对性能的要求。AT可以看作时由Seata社区进行全方面优化,自研的XA模式,最大特点就是解决了XA模式的性能差的问题。TCC由Seata决定二阶段状态通知,其使用全权交托用户,性能仅仅是2个本地事务+些许rpc开销。SAGA整个事务链路,事务处理全权交托用户编排,性能完全由用户来保证,Seata作为事务的协助方,记录全局事务的运行状态。可以看出来,越高入侵性的模式其实背后可优化的点更多,越少入侵性的,也就是会被局限,只能依托组件开发者进行不定期的优化来保证性能。总结在当前的技术发展中,目前分布式事务就是属于扮演东风的角色,大量的分布式,微服务化,带来的性能提 升非常明显,但是却缺少一个有利的保障,我相信Seata就是承担着这样的一个角色,让万事俱备不欠东风。Seata项目的最核心的价值在于:构建一个全面解决分布式事务问题的 标准化 平台。基于 Seata,上层应用架构可以根据实际场景的需求,灵活选择合适的分布式事务解决方案。
Raft 是通过以领导者为准实现各个节点日志一致的一种共识算法,被越来越多的分布式系统框架应用,比如 Etcd、Consul 等等,Seata 未来也会引用 Raft,即将发布的 Kafka 2.8 也引入了 Raft,在 Raft 的基础上做了一些改版,在 Kafka 2.8 中称作 KRaft。由此看来,Raft 是目前大部分分布式系统的首选共识算法,学习 Raft 将有助于你在分布式领域中如鱼得水。本文主要内容为我对 Raft 选举领导者的一些理解总结。成员按照我的理解,Raft 是一种强领导者模型,即一切以领导者为准,实现一系列的共识和各个节点日志一致性的一种共识算法。Raft 一共有三种成员身份,分别是:领导者(Leader)、跟随者(Follower)、候选人(Candidate)。跟随者:在 Raft 中只有领导者才会与客户端交互,因此在不发生选举时,跟随者仅默默地处理来自领导者发送的消息,充当数据冗余的作用,当领导者心跳超时,跟随者就会主动推荐自己当选候选人。候选人:成为候选人之后,就会向其他节点发送请求投票消息,以获取其他节点的投票,如果获得了大多数选票,则当选领导者。领导者:数据一切以领导者为准,它也是与客户端交互的唯一角色,处理请求,管理日志的复制,同时还不断地发送心跳信息给跟随者,不断刷新跟随者节点的超时时间,以防跟随者发起新的选举。选举过程下面我以一个刚初始化的 Raft 集群为例:1、初始状态Raft 每个节点初始化后的心跳超时时间都是随机的,如上所示,节点 C 的超时时间最短(120ms),任期编号都为 0,角色都是跟随者。2、请求投票此时没有一个节点是领导者,节点等待心跳超时后,会推荐自己为候选人,向集群其他节点发起请求投票信息,此时任期编号 +1,自荐会获得自己的一票选票。3、跟随者投票跟随者收到请求投票信息后,如果该候选人符合投票要求后,则将自己宝贵(因为每个任期内跟随者只能投给先来的候选人一票,后面来的候选人则不能在投票给它了)的一票投给该候选人,同时更新任期编号。4、当选领导者当节点 C 赢得大多数选票后,它会成为本次任期的领导者。5、领导者与跟随者保持心跳领导者周期性发送心跳消息给其他节点,告知自己是领导者,同时刷新跟随者的超时时间,防止跟随者发起新的领导者选举。关于任期从以上的选举过程看,我们知道在 Raft 中的选举中是有任期机制的,顾名思义,每一任领导者,都有它专属的任期,当领导者更换后,任期也会增加,Raft 中的任期还要注意以下个细节:如果某个节点,发现自己的任期编号比其他节点小,则会将自己的任期编号更新比自己更大的值;从上面的选举过程看出,每次推荐自己成为候选人,都会得到自身的那一票;如果候选人或者领导者发现自己的任期编号比其它节点好要小,则会立即更新自己为跟随者,这点很重要,按照我的理解,这个机制能够解决同一时间内有多个领导者的情况,比如领导者 A 挂了之后,集群其他节点会选举出一个新的领导者 B,在节点 A 恢复之后,会接收来自新领导者的心跳消息,此时节点 A 会立即恢复成跟随者状态;如果某个节点接收到比自己任期号小的请求,则会拒绝这个请求。关于随机超时跟随者如果没有在某个时间内接收到来自领导者的心跳,则会发起新一轮的领导者选举,试想一下,如果全部跟随者都在同一时间发起领导者选举,这是一种怎样的场景?会不会造成同一时间内造成选举混乱呢?如果同时发起选举,会不会因为选票被瓜分导致选举失败的原因?感觉会出现很多问题,但是 Raft 它利用随机超时巧妙地避开了这些问题。为此为我还在视频号录制了一段 Raft 选举过程的视频:原文链接:https://mp.weixin.qq.com/s/_j5EfT4S2R40yvePKtmxIg如果你想自己亲自调试并观摩 Raft 选举过程,你可以访问以下网址:https://raft.github.io/
ZCache 是中通下一代缓存服务平台,实现多种缓存类型自动部署,提供 Proxy 访问层,通过 Proxy 层提供指令限制、访问权限、限流、分片处理等功能,通过自研 K8s Operator 实现自动部署与故障转移,实现集群的高可用,提供完善统计、监控、运维功能、减少运维成本和误操作,提高机器的利用率,提供灵活的伸缩性,方便用户接入缓存服务。背景当前公司的缓存使用搜狐 TV 开源的 CacheCloud 缓存服务平台进行托管,CacheCloud 可以快速在不同机器上部署一套 Redis 集群,在用户层,CacheCloud 将每个集群抽象成一个“应用”,用户可以在”应用“中很方便地对自己申请的集群进行查看和命令执行、申请扩容等等。随着公司的业务不断发展,在使用 CacheCloud 过程中伴随而来的问题也接踵而至:1、资源隔离问题由于 CacheCloud 所管理的物理机器是集群共享的,这样可以有效地利用机器资源,因此用户间的集群节点很可能会共享同一个物理机,且没有对资源进行隔离,比如某个集群访问量高会影响另一个集群等。2、集群访问权限粒度问题用户申请一个应用,即拥有了一个完整的 Redis 集群资源,用户对该集群拥有很大的权限,且不好对集群权限进行很好的管理。3、集群资源不均衡由于用户每申请一个应用,就会创建一个完整的 Redis 集群,该集群初始容量为 8G,但在实际使用过程中,用户仅使用了 2G 缓存资源,这个问题在使用 CacheCloud 过程中普遍存在于每个应用中,缓存资源使用率非常低。4、仅支持 Redis 类型的集群仅仅支持 Redis 缓存类型无法满足公司业务的告诉发展,某些业务需要存储大 Key 大 Value,这种类型也许使用 HBase 作缓存会更加合适。5、集群节点无法做到高可用性如果集群某个节点挂了,仅能通过 Redis 的高可用对故障的节点进行迁移,后续还需要运维介入,将挂掉的节点重启。ZCache 设计思想基于以上的几个问题,我们知道目前 CacheCloud 的各种不足之处,它基于集群托管化管理的思想不足以应对公司日益增长的业务需求,我们需要设计一个全新的缓存服务平台,该平台需要解决以上遇到的问题,经过一系列调研,我总结以下几个设计要点:1、提供 Proxy 代理层用户不再是通过直连的形式与集群进行交互,在用户与集群之间,增加一层 Proxy 层,通过 Proxy 层,可以将 Proxy 的服务与缓存集群隔离开,因为用户只与 Proxy 层交互,不再通过直连的形式与缓存集群进行交互,有效地避免了网络拥堵对其它集群的影响,同时也减少了缓存集群的 TCP 连接数,而且 Proxy 是一个无状态的服务,理论上可以对 Proxy 的服务无限水平扩容。通过 Proxy 层,我们可以做很多事情,比如指令限制,访问权限控制,限流等等,同时还可以对 Key 做分片处理。由于用户不再需要直连集群,因此用户不再需要关心缓存类型。2、使用 Kubernetes Operator 进行自动化部署通过 Proxy 代理层,已经解决了大部分的问题了,那么缓存的实例应该怎么进行部署呢?如果还是像 CacheCloud 那样直接在物理机器上面进行搭建,则无法解决集群节点的高可用性,无法做到自动维护集群节点之间的稳定关系,如果使用 K8s 进行集群的部署,则可以利用 K8s Operator 的高可用特性,维持集群节点间的高可用性,这时集群可以利用 Redis Sentinel 可以进行主从切换做到故障转移,通过 K8s Operator 实现集群节点的高可用性,利用这两层高可用机制维持了集群的稳定性,同时减少运维的工作量。ZCache 整体架构如下图所示:为什么要使用 OperatorK8s 所托管的容器,一般都是无状态的,这非常适合微服务的理念,使用 K8s 对微服务进行自动部署和编排,再利用 K8s Deployment 特性,能够维护服务节点的数量,提高微服务架构的稳定性,且还能够根据流量的大小,对微服务的实例进行扩/缩容处理。但如果利用 K8s 对有状态的服务进行部署,就不是那么好处理了,比如使用 K8s 部署 MySql、RocketMQ 集群、Redis 集群等等,对于这些有状态实例,删除/添加实例,其他节点直接都需要做相关的配置变更与状态维护,而且这些操作,在以往我们都是通过人工操作进行的,如果使用 K8s 部署,就失去了它引以为傲的自动化特性。K8s 也意识到这点不足,在 1.5 版本中引入了 StatefulSet 资源,StatefulSet 主要是用于处理有状态的容器编排,它能够为容器提供一些列有状态的标识,比如指定的 volume、网络标识、容器编号索引等等,再结合 K8s 的 Headless Service,就能够实现对容器的拓扑状态和存储状态的维护。经过一系列的对 StatefulSet 实践,我发现想要维护 Redis Sentinel 集群的拓扑关系,会异常困难,由于 Pod 重启后 IP 会变化,因此我们需要编写复杂的脚本来维护它们之间的关系,通过自定义的脚本来识别集群之间各个节点之间的拓扑关系,而且这个过程中往往是非常复杂的,而且很容易出错。既然 K8s StatefulSet 资源都不能很好地对有状态服务进行管理,还有哪些方法可以弥补 StatefulSet 的不足呢?在 2016 年,CoreOS 引入了 Opoerator 的思想,用来扩充 K8s 管理有状态应用的能力。Operator 原理在 k8s 官网上面是这么介绍 Operator 的:Operator 是 Kubernetes 的扩展软件,它利用定制资源管理应用及其组件。Operator 遵循 Kubernetes 的理念,特别是在控制器方面。官方的描述虽然简单,却概括了 Operator 核心原理,我们可以捉重点:定制资源、控制器。下面我用自己的理解尽量通俗地讲解这两个重点。1、定制资源在以往我们使用 K8s,会在 yaml 文件(或者通过 API 编写)上定义好各种资源的信息,比如部署实例个数 replicas、镜像名称等等,将定义好的资源提交到 K8s 之后,K8s 会负责维护你这些资源的状态,比如实例少了,会根据定义好的资源信息,将实例维持在 replicas 数量,最终确保集群维护的资源状态于定义的资源一致。至于资源格式,K8s 已经帮我们定义好了,比如 Deployment 资源,我们只需要按照 Deployment 的资源格式填写并提交到 K8s 中,即可定义一个 Deployment 资源。同理我们也可定义属于自己的资源类型,在 K8s 中叫作 “CRD”,全称 “CustomResourceDefinition”,举个例子,我们需要自定义一个名为 “zcacheclusters.com.zto.zcache” 的 CRD 资源,如下所示:apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: zcacheclusters.com.zto.zcache spec: group: com.zto.zcache scope: Namespaced names: plural: zcacheclusters singular: zcachecluster kind: ZcacheCluster shortNames: # ... ...2、控制器在 K8s 中已经有了很多自带的控制器,比如 Deployment、StatefulSet 等等,举个 Deployment 的例子,将某个服务实例的 Deployment 资源定义好,其中 replicas=2,提交到 K8s 集群之后,Deployment 控制器会根据定义的资源,创建两个服务实例的 Pod,并且无限循环地监听集群中服务实例的状态,当服务有变化时,会不断协调最后确保整个集群的服务与定义的一致为止。同理,Operator 在 K8s 的角色也是一个控制器,根据用户自定义的 CRD 资源,我们可以实现一个针对这个 CRD 资源的控制器,在 Operator 控制器内部,可以调用 K8s API 的客户端,用于实现复杂的控制逻辑,也就是说,以往我们需要调用 K8s API 处理各种逻辑, Operator 控制器将这些操作封装成一个自定义的控制器,我们只需要将自定义的 CRD 资源提交到 K8s 中,即可处理该 CRD 资源。无论是 K8s 的自带控制,还是我们自定义的控制,它们都把高级的指令集封装成一个个控制器,将其转换为低级操作。Operator 就是一个自定义的控制器,用户可以随意编写自己想要的控制器功能,由此可见,Operator 几乎可执行任何操作:扩展复杂的应用,应用版本升级,极大地扩展了 K8s 的能力,同时减轻了开发人员的负担。ZCache Operator在讲 Zcache Operator 的实现之前,我先让大家了解下 ZCache Redis 缓存实例的部署架构,ZCache 参考了 Codis 的架构思想,ZCache 的 Redis 底层缓存实例是一组组的 Redis 主从架构,理论上可无限扩展主从的数量,对于用户来说,可以认为 ZCache 是一个无限容量的缓存服务。我们部署一个 Redis 哨兵集群,通常是先部署 Sentinel 节点,再部署一个主从加入 Sentinel,一个 Sentinel 集群可添加多个主从,我们后续可以继续往 Sentinel 添加主从。ZCache 的 Operator 也需要满足这个部署顺序,当 ZCache 需要扩容时,会往 Sentinel 添加若干组主从,同时 Operator 需要维护哨兵集群中 Sentinel 节点与主从之间的关系。下面表示 ZCache 创建一组名为 group-1 主从redis 的定制资源到控制器监听资源状态的处理过程:提前在 K8s 中自定义了名为 ZcacheCluster 的 CRD 资源,用户编写 ZcacheCluster 的资源,从以上流程图可知,用户目的是为了创建两个 Redis Pod 实例,并且将其维护为一组名为 group-1 的主从,K8s 不断 Watch 该组资源的状态。以上流程图也表达了控制器的核心理念,控制器的逻辑就是封装了 K8s API 的处理逻辑过程,将它们抽象成一个个控制器的模式,使用户只需要定义相关控制器的资源并提交到 K8s 中,即可完成资源的托管与状态维护。由于 ZCache 是基于 Java 编写的,官方提供的 operator-sdk 是 Go 语言编写的,如果要自己实现一个套 Java 版的 operator-sdk 成本太大,我在 GitHub 中找到了一个非常不错的 Java 版本 operator-sdk,ZCache Operator 决定基于 java-operator-sdk 编写,从 GitHub 的 Feature 看, java-operator-sdk 的主要特性如下:处理 K8s API 事件;自定义资源监视的自动注册;失败时重试操作;智能事件调度(仅处理同一资源的最新事件)。具体的实现可查看 GitHub 地址:https://github.com/java-operator-sdk/java-operator-sdkZCache Operator 的每次触发创建和更新动作,都会调用 io.javaoperatorsdk.operator.api.ResourceController#createOrUpdateResource 接口,ZCache Operator 具体实现逻辑如下:@Override public UpdateControl<ZcacheResource> createOrUpdateResource(ZcacheResource zcacheResource, Context<ZcacheResource> context) { logger.info("create or update resource:{}, retryNumber:{}, lastAttempt:{}", zcacheResource, context.retryInfo().getRetryNumber(), context.retryInfo().isLastAttempt()); // 一、创建或更新资源(sentinel、redis-ms) // 部署缓存资源通常来说,会先创建一组 sentinel 节点,接着按需创建一组组 m - s // 因此: // 1、一个 operator 只存在一组 sentinel 节点(默认 3 个),存在多组 m - s // 2、sentinel 节点单独创建 statefulSet,每次 m - s 单独创建 statefulSet(replicas = 2) // 注:创建的资源都是没有任何状态的(即不形成 m - s 主从,sentinel 不监控 master 节点) UpdateControl<ZcacheResource> result = generateHandler.handle(zcacheResource); if (!result.isUpdateCustomResource()) { // 二、检查资源并给定对应状态 // 1、检查资源 redis 、sentinel 节点数量 // 2、资源状态设定 // 2.1 redis 资源 master 节点选定,另外一个节点则监听 master 节点,组成主从架构 // 2.2 每个 sentinel 节点分别 monitor master 节点 result = checkHandler.handle(zcacheResource); if (!result.isUpdateCustomResource()) { // 三、更新资源配置(获取资源 podIP,通过创建redis-client客户端,调用 redis api 设置) // 1、更新 redis 节点配置 // 2、更新 sentinel 节点配置 return configHandler.handle(zcacheResource); } } return result; }我将 ZCache Operator 监听逻辑目前分成三部分:1、创建或更新资源GenerateHandler 处理器的逻辑主要是使用 K8s API 创建缓存资源,比如:Redis StatefulSet、Redis Service、Sentinel HeadlessService、Sentinel StatefulSet 等,用这些资源搭建哨兵集群。在这个过程中,还有一个小插曲,以上的资源创建好之后,如果有变更并不会触发 Operator,仅仅只是它们自身控制器在维护其状态而已,经过深入看 java-operator-sdk 的相关源码以及机制,已经搞明白为啥通过Operator 创建的的默认资源(deployment、statefulset 等等)不触发 Operator 控制器了,这些默认的资源需要在 Operator 内部对这些资源进行 Watch 并处理才行,本质上来说 java-operator-sdk 也是将用户所写的 controller当成一个资源进行 Watch 而已。因此,通过 Operator 创建的这些资源,一定要记得调用 Watch 监听。2、检查资源并给定对应状态这部分是 ZCache Operator 最为关键的逻辑,它体现了 Operator 的核心关键能力:资源状态维护。文章前面提到,如果使用 K8s StatefulSet 部署有状态的集群服务,想要维护集群的拓扑关系与状态,会异常困难,需要编写一系列复杂的脚本去处理。通过自定义 Operator,我们就可以在实现中添加集群服务拓扑关系与状态的维护逻辑了。3、更新资源配置如果我们需要变更集群的配置,通常来说我们需要停止集群节点,并进行滚动更新,运维成本极大,ZCache Operator 的设计理念之一,就是尽可能减少人工运维的成本,因此 ZCache Operator 还能支持集群服务的配置更新,运维仅需要在 ZCache 控制台简单操作,即可完成集群配置的更新。在 ZCache Operator 的设计中,我把相关的处理逻辑抽象成一个个处理器,随着业务的发展,以后增加处理其只需要实现 Handler 接口即可。以上是 ZCache Operator 的整体设计理念,接下来,我们要想,我们如何将编写好的 Operator 部署到 K8s 集群中呢?Operator 部署并没有严格的要求,只要 Operator 能够访问 K8s 集群,以及能够被 K8s 触发执行即可,最简单的做法就是将 Operator 作为 K8s 集群中的一个 Pod,为了 Operator 高可用,使用 Deployment 进行部署,同时将 Replicas 设置为 1 即可。
随着业务上的增长与迭代,业务使用的消息集群会创建越来越多主题,在业务流量不断增长的情况下,还需要不断增加主题的分区数量,Kafka 由于本身的存储机制特点,随着主题和分区数的增加,性能会不断下降,无法满足业务上的发展。通常我们的做法是扩容集群,但随着集群的不断扩大,又会伴随着很多问题,随着集群的扩容节点,创建主题和分区数不断增多,存储在 zk 上的元数据就会越来越多,每当需要全量同步元数据到 Broker 节点时,会是一笔很大的网络开销,由于当 contrller 切换时往往需要全量同步元数据到每个 Broker 上,因此,元数据越多,controller 的切换时长会越长,而且由于 Kafka 会独立一个复制线程进行分区副本的复制,多个分区共享该线程,因此 Broker上的分区不断增多后会造成复制线程负载增大,严重时会会造成某些分区副本复制跟不上,导致 ISR 频繁变化。最简单的做法就是将集群拆分成若干个小集群,将主题平均分配到这些小集群中,但这会使得用户需要变更系统配置,那有没有一种办法可以在不影响用户系统的情况下,同时还能兼容小集群的模式呢?中通消息平台(以下文简称 ZMS)应运而生。ZMS 脱胎于中通内部对消息引擎的实践经验总结,它屏蔽底层消息引擎具体实现,通过唯一标识动态路由消息,同时为开发运维人员提供自动化部署运维集群,主题、消费组申请与审批、实时监控、自动告警、容灾迁移等功能。ZMS 整体架构如下图所示:ZMS-SDK 模块从技术的角度是 ZMS 核心技术实现,封装消息引擎内核,ZMS 核心功能都围绕着 ZMS-SDK 展开;从用户的角度上可以让用户与消息集群解耦,屏蔽各消息集群差异,用户无需关心消息引擎底层技术细节。ZMS-SDK 模块具体实现是将用户在控制台申请的主题消费组元数据信息保存在 ZK 节点,当用户使用 ZMS-SDK 发送消息时,ZMS-SDK 会从 ZK 对应节点获取该主题元数据信息,并为主题创建一个 Producer,Producer 会缓存在本地,同时 ZMS-SDK 会监听该主题对应的 ZK 节点,一旦该主题元数据变更就会销毁缓存中的 Producer,同时再创建一个新的 Producer 缓存在本地中。ZMS-SDK 订阅消费逻辑同理消息发送。如下图所示:基于 ZMS-SDK 核心逻辑,再结合 ZMS 的主题迁移功能,ZMS 就可以解决消息集群的主题分区数过多带来性能下降的问题,通过 ZMS 可以将它管理下的主题分散在各个小集群当中,用户只需申请主题消费组即可,审批到哪个集群并不需要关心,这完全由运维根据集群负载情况决定主题消费组被审批到相对应的集群中,而用户待主题消费组审批通过后,就可以通过 ZMS-SDK 进行发送和消费消息,如果主题消费组由变更,ZMS-SDK 就会感知对应 ZK 节点的变化,更新本地 Producer 和 Consumer 缓存。用户感知不到扩容迁移动作,真正实现无感知生产和消费。通过上述的一些改造,我们就可以支持更大的业务规模,用户在使用时只需要知道主题/消费组名字即可。但缺点也有,ZMS 所管理下的消息集群,就必须要依赖于 ZMS-SDK,才能实现以上功能,而目前 ZMS-SDK 仅有 Java 版本,对于 Go 项目,那么以上功能就失效了,而且 ZMS 很多监控指标都是依赖于 ZMS-SDK 进行上报的,因此,如果用户使用非 ZMS-SDK 连接 ZMS 管理下的集群,就会失去很多 ZMS 功能。我想归根结底是 ZMS-SDK 并没有对消息内核进行改造,仅仅是对其客户端进行了一些改造与封装,在 ZMS-SDK 内部缓存了多个集群的客户端实例,如下图所示:这也许会带来另外一个问题:如果用户的系统使用了很多个主题/消费组,且这些资源都不在一个集群上,ZMS-SDK 则会为每个主题/消费组创建一个客户端,如果在一个系统中创建过多的客户端会导致创建过多的线程,导致项目 Socket 连接开销巨大。当然,在中通内部使用,通常来说用户的系统不会存在过多的主题/消费组,因此目前还没有遇到这个问题。如何解决 ZMS-SDK 当前设计上的存在问题呢?对这方面我也有过一些探索,我在腾讯云中间件看到一篇关于 Kafka 集群突破百万 partition 的技术探索文章,对我的启发非常大,它对 Kafka 内部进行了一系列的改造,使用小集群组建逻辑集群的思想,实现单客户端对应一个逻辑集群:
某天晚上打球打得正嗨,突然间收到运维电话,说某个 Kafka 集群 RT 值非常高,使用该集群的用户也发现了消息堆积现象,此刻我意识到问题的严重性,于是急忙跑回办公室查看这个问题。分析问题现象打开消息控制台(以下简称 ZMS),查看该集群的状态,发现 RT 值比平时高了很多:这很不正常,于是赶忙去查看各个节点日志:发现某个节点日志出现 ISR 频繁收缩又扩张的现象,接着查看其他节点,发现只有某个节点会出现这种现象,在 ZMS 中再次查看各个节点的 major GC 情况:发现该节点 major GC 有点频繁而且不规律,接着还发现了一些连接断开的日志:同时还发现该节点流出的流量不正常:从上图可看出,该节点的流出流量少了很多,猜测是因为 follower 副本拉取消息的流量少了很多,也是该节点的 Leader 副本会将 follower 副本踢出 ISR 列表的表现现象。根据业务方反馈,在 1 月 12 日那天,这一天增加了很多客户端连接集群。导致每个节点在这一天开始大概增加了 4 千个 TCP连接,这个问题也是从这一天开始出现的,从上图也可以看出,连接数也是从这天开始飙升的。从上图也可以看出,而且该集群的 RT 值升高也是从这一天开始发生的。以上根据 Broker 日志、GC、连接数量、RT 值等多个方面查处问题的具体现象。排查解决问题既然是增加了那么多客户端连接,那是不是由于 Kafka Broker 处理请求不过来,导致请求阻塞,超时后被断开了,因此才会出现 ISR 变化的同时还会出现连接断开的日志?排查问题之前,大致讲下 Kafka 的网络线程模型,它的处理流程如下:如上,要理解 Kafka 的网络线程模型可以看下 Kafka 的 kafka.network.SocketServer 类注释(不得不说 Kafka 源码在注释方面做得非常棒,值得学习):从源码注释可以看出, Kafka 的网络线程模型采用了 1 Acceptor 线程 + N Processor 线程 + M Handler 线程的线程模型。其中 Processor 线程可以通过 num.network.threads 参数设定,默认为 3,专门用于处理接受请求和发送响应;Handler 线程可以通过 num.io.threads 参数设定,默认为 8,专门用于处理各种请求,是 Kafka 真正用于请求处理的线程。Kafka 为了监控为了实时监控这些网络线程的运行状态,专门提供了相关监控统计,其中:提供了kafka.network:type=SocketServer,name=NetworkProcessorAvgIdlePercent 指标用于统计 Processor 线程的空闲率;提供了kafka.server:type=KafkaRequestHandlerPool,name=RequestHandlerAvgIdlePercent 指标用于统计 Handler 线程的空闲率。查看各个节点 Processor 线程的空闲率情况:从上图可以看出,出现问题的那个节点, Processor 线程的空闲率几乎为 0,等待流量下来之后才慢慢恢复。于是去查看节点的相关配置,发现每个节点 num.network.threads = 6, num.io.threads = 16,查看节点的 CPU 核数为 48,而且此时的 CPU 负载并不高,由此可见是因为节点设置的线程数不够,导致节点处理请求忙不过来。于是我在原来的基础上将这两个参数同时调大一倍,分别为 num.network.threads = 12, num.io.threads = 32,并且将请求队列长度调大一倍(原来使用默认值 500) queued.max.requests = 1000,接着等待夜深人静时各个节点恢复正常后,改好参数后逐个重启。第二天醒来后,发现即使在集群 TPS 非常高的时候,Processor 线程的空闲率依然可以维持在 0.9 左右:节点的 CPU 使用率也提高了:直至目前写完文章,集群现在依然是稳如老狗,集群各个节点没有再发生过 ISR 频繁变化,连接频繁断开的现象了。总结该问题主要是从集群 ISR 频繁变化、频繁断开与客户端连接两个问题作为出发点,根据这两个问题分析出这是导致集群 RT 值升高的直接原因,接着与业务方沟通并分析具体原因,得出业务方在某个时间点增加了大量客户端连接,也许是因为网络连接问题导致集群 ISR 频繁变化、频繁断开与客户端连接的,带着这个疑问接着去查看各个节点的网络线程空闲率情况,发现问题根源,最后根据机器配置详情适当调大各个节点网络线程模型的线程数量,顺利解决了这个问题。在这个排查问题过程中,其实遇到不少曲折过程没有描述出来,比如为什么这个问题每次只会发生在某个节点上?本来是想获取各个节点的堆内存转储快照,但由于一些原因一直 dump 不下来,少了这块的分析。为什么这个问题每次只会发生在某个节点上,根据对当时节点上的 TCP 连接客户端分析,以及业务方的描述,当前出现问题的节点存在某些客户端的连接非常耗资源,比如每次发送的消息量特别大,节点处理时间需要一些时间,而且 IO 线程负载已经达到极限了,导致后面的请求被阻塞,处于请求队列中的请求超时断开。经过这次事件之后,考虑到客户端连接不可控性,ZMS 后续还需要增加集群节点网络线程空闲率全局告警功能,及时处理,以免再次发生此类现象。下次看到日志由出现频繁断开连接,以及 ISR 频繁发生变化,就需要注意下是否是 Broker 的网络线程出现阻塞了。以上截图很多来自中通消息服务平台 ZMS,目前 ZMS 已开源,欢迎各位大佬加入到该项目中,共同打造一体化的智能消息运维平台。仓库地址:https://github.com/ZTO-Express/zms
某天临时被当成壮丁拉去参加一个非常牛逼的应用监控平台(后续会开源),然后大佬就给我派了一个任务,要将项目中的查询性能优化 50 倍以上,大佬对我如此地寄予厚望,我怎么能让大佬失望呢(虽然我内心瑟瑟发抖)?于是我就开始了这段性能优化之旅。初步认识 Calcite项目使用 Calcite 框架作为查询引擎,之前一直没停过这玩意是啥,而且网上资料特别少,又是体现我学习能力的时候了,在着手排查性能问题前,我花了非常多时间在了解 Calcite 实现原理上面。1、Calcite 简介Apache Calcite是一款开源的动态数据管理框架,它提供了标准的 SQL 语言、多种查询优化和连接各种数据源的能力,但不包括数据存储、处理数据的算法和存储元数据的存储库。Calcite 之前的名称叫做optiq,optiq 起初在 Hive 项目中,为 Hive 提供基于成本模型的优化,即CBO(Cost Based Optimizatio)。2014 年 5 月 optiq 独立出来,成为 Apache 社区的孵化项目,2014 年 9 月正式更名为 Calcite。Calcite 的目标是“one size fits all(一种方案适应所有需求场景)”,希望能为不同计算平台和数据源提供统一的查询引擎。2、Calcite 执行流程1)解析 SQL,目的是为了将 SQL 转换成 AST 抽象语法数,Calcite 有一个专门的对象 SqlNode 表示;2)语法检查,用数据库的元数据信息进行语法验证;3)逻辑优化,根据前面生成的逻辑计划按照相应的规则(Rule)进行优化;4)SQL 执行,按照执行计划执行。3、Calcite 相关对象RelNode:关系表达式, 主要有 TableScan, Project, Sort, Join 等。如果 SQL 为查询的话,所有关系达式都可以在 SqlSelect中找到, 如 where 和 having 对应的 Filter, selectList 对应 Project, orderBy、offset、fetch 对应着 Sort, From 对应着 TableScan/Join 等等, 示便 Sql 最后会生成如下 RelNode 树。Debug 源码得到的 RelNode 对象长这样:RexNode:行表达式, 如 RexLiteral(常量), RexCall(函数), RexInputRef (输入引用) 等,举个例子:SELECT LOCATION as LOCATION,MERGE2(VALUE2) as VALUE2 FROM transaction WHERE REPORTTIME >=1594887720000 AND REPORTTIME <=1594891320000 AND APPID = 'test-api' AND GROUP2 IN ('DubboService','URL') AND METRICKEY IN ('$$TOTAL') GROUP BY LOCATIONRexCall<=($1, 1595496539000)RexInputRef$1RexLiteral1595496539000:BIGINT下面根据官方资料的描述,总结 Calcite 的三种查询模式:1)ScannableTable这种方式基本不会用,原因是查询数据库的时候没有任何条件限制,默认会先把全部数据拉到内存,然后再根据filter条件在内存中过滤。使用方式:实现Enumerable scan(DataContext root);,该函数返回Enumerable对象,通过该对象可以一行行的获取这个Table的全部数据。2)FilterableTable初级用法,我们能拿到filter条件,即能再查询底层DB时进行一部分的数据过滤,一般开始介入calcite可以用这种方式(translatable方式学习成本较高)。使用方式:实现Enumerable scan(DataContext root, List filters )。如果当前类型的“表”能够支持我们自己写代码优化这个过滤器,那么执行完自定义优化器,可以把该过滤条件从集合中移除,否则,就让calcite来过滤,简言之就是,如果我们不处理List filters ,Calcite也会根据自己的规则在内存中过滤,无非就是对于查询引擎来说查的数据多了,但如果我们可以写查询引擎支持的过滤器(比如写一些hbase、es的filter),这样在查的时候引擎本身就能先过滤掉多余数据,更加优化。提示,即使走了我们的查询过滤条件,可以再让calcite帮我们过滤一次,比较灵活。3)TranslatableTable高阶用法,有些查询用上面的方式都支持不了或支持的不好,比如join、聚合、或对于select的字段筛选等,需要用这种方式来支持,好处是可以支持更全的功能,代价是所有的解析都要自己写,“承上启下”,上面解析sql的各个部件,下面要根据不同的DB(esmysqldrudi..)来写不同的语法查询。当使用ScannableTable的时候,我们只需要实现函数Enumerable scan(DataContext root);,该函数返回Enumerable对象,通过该对象可以一行行的获取这个Table的全部数据(也就意味着每次的查询都是扫描这个表的数据,我们干涉不了任何执行过程);当使用FilterableTable的时候,我们需要实现函数Enumerable scan(DataContext root, List filters );参数中多了filters数组,这个数据包含了针对这个表的过滤条件,这样我们根据过滤条件只返回过滤之后的行,减少上层进行其它运算的数据集;当使用TranslatableTable的时候,我们需要实现RelNode toRel( RelOptTable.ToRelContext context, RelOptTable relOptTable);,该函数可以让我们根据上下文自己定义表扫描的物理执行计划,至于为什么不在返回一个Enumerable对象了,因为上面两种其实使用的是默认的执行计划,转换成EnumerableTableAccessRel算子,通过TranslatableTable我们可以实现自定义的算子,以及执行一些其他的rule,Kylin就是使用这个类型的Table实现查询。由于我对 Calcite 还没有一个更加深入的了解(但是并不阻碍我排查问题,而且 Calcite 这玩意对我来说太复杂了),因此 Calcite 更加复杂的概念我在这里就不继续啰嗦了,比如关系代数的基本知识、查询优化器等等,排查问题并不需要了解那么深入,而且项目中只是使用了 Calcite 一小部分功能。使用 Calcite 实现一个简单的数据库需要做如下几步:编写 model.json自定义 SchemaFactory自定义 Schema(像一个“没有存储层的databse”一样,Calcite不会去了解任何文件格式)自定义Table自定义 Enumeratordemo url: https://github.com/objcoding/calcite-demo耗时排查我在项目中使用了 FilterableTable 模式,Cacite 在解析 Sql 时耗时非常大,然后通过调试,我发现每个请求都占据了两个位置:org.apache.calcite.adapter.enumerable.EnumerableInterpretable#getBindableCacite 在这个地方通过设置缓存大小来优化缓存设置。org.apache.calcite.interpreter.JaninoRexCompiler#baz但是不会缓存该位置,并且每次都会使用新的表达式字符串通过反射创建它。我使用 JProfile 工具对线程耗时的地方进行定位:Calcite 会在这个地方会调用反射根据不同的 Sql 动态生成不同的表达式,Debug 获取的表达式如下:Calcite 为什么会有这种机制呢?我们先从 Bindable 对象讲起:在 EnumerableRel(RelNode,我们可以通过 TranslatableTable 自定义 FilterRel、JoinRel、AggregateRel)的每个算子的 implement 方法中会将一些算子(Group、join、sort、function)要实现的算法写成 Linq4j 的表达式,然后通过这些 Linq4j 表达式生成 Java Class。通过 JavaRowFormat 格式化)calcite 会将 sql 生成的 linq4j 表达式生成可执行的 Java 代码( Bindable 类):org.apache.calcite.adapter.enumerable.EnumerableInterpretable#getBindableCalcite 会调用 Janino 编译器动态编译这个 java 类,并且实例化这个类的一个对象,然后将其封装到 CalciteSignature 对象中。调用 executorQuery 查询方法并创建 CalciteResultSet 的时候会调用 Bindable 对象的 bind 方法,这个方法返回一个Eumerable对象:org.apache.calcite.avatica.AvaticaResultSet#executeorg.apache.calcite.jdbc.CalcitePrepare.CalciteSignature#enumerable将 Enumerable 赋值给 CalciteResultSet 的 cursor 成员变量。在执行真正的数据库查询时,获得实际的 CalciteResultSet,最终会调用:org.apache.calcite.avatica.AvaticaResultSet#next以下是根据 SQL 动态生成的 linq4j 表达式:public static class Record2_0 implements java.io.Serializable { public Object f0; public boolean f1; public Record2_0() {} public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof Record2_0)) { return false; } return java.util.Objects.equals(this.f0, ((Record2_0) o).f0) && this.f1 == ((Record2_0) o).f1; } public int hashCode() { int h = 0; h = org.apache.calcite.runtime.Utilities.hash(h, this.f0); h = org.apache.calcite.runtime.Utilities.hash(h, this.f1); return h; } public int compareTo(Record2_0 that) { int c; c = org.apache.calcite.runtime.Utilities.compare(this.f1, that.f1); if (c != 0) { return c; } return 0; } public String toString() { return "{f0=" + this.f0 + ", f1=" + this.f1 + "}"; } } public org.apache.calcite.linq4j.Enumerable bind(final org.apache.calcite.DataContext root) { final org.apache.calcite.rel.RelNode v1stashed = (org.apache.calcite.rel.RelNode) root.get("v1stashed"); final org.apache.calcite.interpreter.Interpreter interpreter = new org.apache.calcite.interpreter.Interpreter( root, v1stashed); java.util.List accumulatorAdders = new java.util.LinkedList(); accumulatorAdders.add(new org.apache.calcite.linq4j.function.Function2() { public Record2_0 apply(Record2_0 acc, Object[] in) { final Object inp9_ = in[9]; if (inp9_ != null) { acc.f1 = true; acc.f0 = com.zto.zcat.store.api.query.Merge2Fun.add(acc.f0, inp9_); } return acc; } public Record2_0 apply(Object acc, Object in) { return apply( (Record2_0) acc, (Object[]) in); } } ); org.apache.calcite.adapter.enumerable.AggregateLambdaFactory lambdaFactory = new org.apache.calcite.adapter.enumerable.BasicAggregateLambdaFactory( new org.apache.calcite.linq4j.function.Function0() { public Object apply() { Object a0s0; boolean a0s1; a0s1 = false; a0s0 = com.zto.zcat.store.api.query.Merge2Fun.init(); Record2_0 record0; record0 = new Record2_0(); record0.f0 = a0s0; record0.f1 = a0s1; return record0; } } , accumulatorAdders); return org.apache.calcite.linq4j.Linq4j.singletonEnumerable(interpreter.aggregate(lambdaFactory.accumulatorInitializer().apply(), lambdaFactory.accumulatorAdder(), lambdaFactory.singleGroupResultSelector(new org.apache.calcite.linq4j.function.Function1() { public Object apply(Record2_0 acc) { return acc.f1 ? com.zto.zcat.store.api.query.Merge2Fun.result(acc.f0) : (Object) null; } public Object apply(Object acc) { return apply( (Record2_0) acc); } } ))); } public Class getElementType() { return java.lang.Object.class; }Enumerator是Linq风格的迭代器,它有4个方法:currentmoveNextresetclosecurrent 返回游标所指的当前记录,需要注意的是current并不会改变游标的位置,这一点和iterator是不同的,在iterator相对应的是next方法,每一次调用都会将游标移动到下一条记录,current则不会,Enumerator是在调用moveNext方法时才会移动游标。moveNext方法将游标指向下一条记录,并获取当前记录供current方法调用,如果没有下一条记录则返回false。CsvEnumerator是读取 csv 文件的迭代器,它还得需要一个RowConverter,因为csv中都是String类型,使用RowConverter转化成相应的类型。在moreNext方法中,有Stream和谓词下推filter部分的实现,在本文只关注如下几行代码:总结执行顺序:1、executeQuery 方法:1)根据算子 linq4j 表达式子生成 Bindable 执行对象,如果有设置缓存,则会将对像存储到缓存中;2)生成 CalciteResultSet 时会调用 Bindable#bind 方法返回一个 Enumerable 对象;2、getData 方法:调用 ResultSet#next 方法最终会嗲用 Enumerable#moveNext一图理解 Bindable 在 calcite 中的作用:发现 Bindable 缓存会持续增加,说明 Bindable 类内容不一致:也说明了 calcite 会根据不同的 SQL 动态生成 linq4j 表达式。性能优化以上排查结果可知,在 Calcite 内容会频繁使用 JaninoRexCompiler 使用反射动态生成表达式,由于项目中的 sql 格式相对固定,因此我们是否可以自定义一个 Compiler,然后替换 JaninoRexCompiler ?我将使用 JaninoRexCompiler 的相关类复制出来,实现一个自定义的 Interpreter.ScalarCompiler,然后在这个地方 org.apache.calcite.interpreter.Interpreter.CompilerImpl#CompilerImpl替换 JaninoRexCompiler。关于自定义 Interpreter.ScalarCompiler 的具体思路过程,我记录在这里了:https://issues.apache.org/jira/browse/CALCITE-4144经过反复调试,发现性能提上了 50 倍以上!再次使用 JProfiler 查看,发现 Calcite 查询过程耗时已经大大降低了。
消息引擎最重要的工作就是将生产者生产的消息传输到消费者,消息的格式应该要怎么设计是各大消息引擎框架最核心的问题,消息格式决定了消息引擎的性能与效率,Kafka 在过去的多个版本迭代中,衍生了 3 个版本的消息格式,每个版本的消息格式之间究竟有哪些差异,它们之间的升级解决了什么样的问题呢?下面我就对 Kafka 的消息格式进行深度剖析。V0 版本消息格式V0 版本的消息格式主要存在于 Kafka 0.10.0.0 之前的版本,也是 Kafka 最早的消息版本,Kafka 的消息在 Kafka 的设计中被叫做 “Record”,我们也可以定位到 org.apache.kafka.common.record.Record 类,该类即是 Kafka 消息的类,我们可以从类中看到消息的一些字段长度的定义,其中还包括了 ByteBuffer 字段,从而得知 Kafka 使用 ByteBuffer 来保存消息,而不是使用 Java 类,这样做的好处是可以节省很多空间,ByteBuffer 是一个紧凑的二进制字节的结构,而 Java 类由于 Java 内存模型机制的原因会产生字段填充问题,下面我们来看下 Kafka 是怎么将消息写入 ByteBuffer:org.apache.kafka.common.record.Record#write(org.apache.kafka.common.record.Compressor, long, byte, byte[], byte[], int, int)public static void write(Compressor compressor, long crc, byte attributes, byte[] key, byte[] value, int valueOffset, int valueSize) { // write crc compressor.putInt((int) (crc & 0xffffffffL)); // write magic value compressor.putByte(CURRENT_MAGIC_VALUE); // write attributes compressor.putByte(attributes); // write the key if (key == null) { compressor.putInt(-1); } else { compressor.putInt(key.length); compressor.put(key, 0, key.length); } // write the value if (value == null) { compressor.putInt(-1); } else { int size = valueSize >= 0 ? valueSize : (value.length - valueOffset); compressor.putInt(size); compressor.put(value, valueOffset, size); } }从以上代码逻辑可以看出,我们可以得知 Kafka 的消息格式包括了一下字段:crc:CRC 校验码,用于确保消息在传输过程中不会被篡改;magic:消息的版本号,这里 magic=0,表示 V0 版本;attributes:属性字段,V0 版本目前只使用该字段保存消息的压缩类型;key length:用于保存 key 字段长度,若 key 为空,则该字段为 -1;key:用于保存 key 值;value length:用于保存 value 字段长度,若 value 为空,则该字段为 -1;value:用于保存 value 值。再结合 org.apache.kafka.common.record.Record 类中常量定义的字段大小,我用以下图表示 V0 版本消息格式的样子:从上图可以看出,V0 版本的消息最小为 14 字节,小于 14 字节的消息会被 Kafka 视为非法消息。下面我来举个例子来计算一条消息的具体大小,某条消息的各个字段值依次如下:CRC:对消息进行 CRC 计算后的值;magic:0;attribute:0x00(未使用压缩);key 长度:3;key:say;value 长度:5;value:hello。该条消息长度为:4 + 1 + 1 + 4 + 3 + 4 + 5 = 22 字节。V1 版本消息格式随着 Kafka 的不断迭代演进,用户发现 V0 版本的消息格式由于没有保存时间信息导致 Kafka 无法依据消息的具体时间作进一步判断,比如定期删除过期日志 Kafka 只能依靠日志文件的最近修改时间,这个时间很容易被外界干扰,比如在 linux 中执行了 touch 命令就会更改这个时间。V1 版本的消息格式在 V0 版本的基础上增加了时间戳字段,切换到 Kafka 0.10.0 分支,再次观察 Kafka 是如何将消息写入 ByteBuffer 的:org.apache.kafka.common.record.Record#write(org.apache.kafka.common.record.Compressor, long, byte, long, byte[], byte[], int, int)public static void write(Compressor compressor, long crc, byte attributes, long timestamp, byte[] key, byte[] value, int valueOffset, int valueSize) { // write crc compressor.putInt((int) (crc & 0xffffffffL)); // write magic value compressor.putByte(CURRENT_MAGIC_VALUE); // write attributes compressor.putByte(attributes); // write timestamp compressor.putLong(timestamp); // write the key if (key == null) { compressor.putInt(-1); } else { compressor.putInt(key.length); compressor.put(key, 0, key.length); } // write the value if (value == null) { compressor.putInt(-1); } else { int size = valueSize >= 0 ? valueSize : (value.length - valueOffset); compressor.putInt(size); compressor.put(value, valueOffset, size); } }我用以下图表示 V1 版本消息格式的样子:从上图可以看出,V1 版本的消息最小为 22 字节,小于 22 字节的消息会被 Kafka 视为非法消息。总的来说比 V0 版本的消息大了 8 字节,如果还是按照 V0 那条消息计算,则在 V1 版本中它的总字节数为:22 + 8 = 30 字节。还需要注意的另一点差别就是 V1 版本中的 attribute 字段的第 4 位用于保存时间戳类型,当前时间戳类型有:CREATE_TIME:时间戳由 Producer 创建时间时指定;LOG_APPEND_TIME:时间戳由 broker 端写入消息时指定。V0、V1 消息集合格式V0、V1 版本的消息集合的设计没有任何区别,被称作“日志项”,在源码中,我们找到了 LogEntry 类:org.apache.kafka.common.record.LogEntrypublic final class LogEntry { private final long offset; private final Record record; // ... public int size() { return record.size() + Records.LOG_OVERHEAD; } }可以看出,V0、V1 版本的消息集合设计的非常简单,offset 字段记录了消息在 Kafka 分区日志中的 offset,record 即消息本身,还有一个size()方法 ,该方法记录的是消息集合的长度,我们再看下 LOG_OVERHEAD 字段:org.apache.kafka.common.record.Recordspublic interface Records extends Iterable<LogEntry> { int SIZE_LENGTH = 4; int OFFSET_LENGTH = 8; int LOG_OVERHEAD = SIZE_LENGTH + OFFSET_LENGTH; }从以上源码可以看出,消息集合中 offset 占用了 8 字节,消息集合大小字段占用了 4 字节。那么我们就可以画出 V0、V1 消息集合格式的样子:以上,message 字段也被 Kafka 称作浅层消息(shallow message),如果消息未进行压缩,那么该字段保存的消息即是它本身,如果消息进行压缩,Kafka 会将多条消息压缩在一起放入到该字段中。值得注意的一点是:如果消息未被压缩,那么 offset 的值就是消息本身在分区日志中的 offset,如果多条消息被压缩放入到该字段中,则 offset 表示这批消息中最后一条消息在分区日志中的 offset。从这里我们也可以看出,在 V0、V1 版本的日志项中搜索位移是一件很困难的事情,我们需要解压并进行计算,代价非常高。现在如果我们使用 V1 版本举例的那条消息放入消息集合中(未使用压缩),那么消息集合的大小为:8 + 4 + 30 = 42 字节。V0、V1 版本消息格式的缺陷经过上面我们分析并画出的 V0、V1 版本消息格式,我们会发现它们在设计上的一些缺陷,比如:空间使用率低:无论 key 或 value 是否有记录,都需要一个固定大小 4 字节去保存它们的长度信息,当消息足够多时,会浪费非常多的存储空间;消息长度没有保存:每次计算单条消息的总字节书都需要通过实时计算得出,效率低下;只保存最新消息位移:上面内容也提到过,如果消息使用压缩,那么消息集合中的 offset 字段只会保存最后一条消息在分区日志中的 offset;冗余的消息级 CRC 校验:即使是批次发送消息,每条消息也需要单独保存 CRC,在 V2 版本中已经将 CRC 放到消息集合了,下面会说到。V2 版本消息格式针对 V0、V1 版本消息格式的缺陷,Kafka 在 0.11.0.0 版本对消息格式进行了大幅度重构,使用可变长度解决了空间使用率低的问题,增加了消息总长度字段,使用增量的形式保存时间戳和位移,并且把一些字段统一抽取到消息集合中,下面我们来看下 V2 版本的消息格式具体有哪些参数:org.apache.kafka.common.record.DefaultRecordsizeInBytes:消息总长度字段;attributes:消息属性字段offset:位移增量timestamp:时间戳增量sequence:用于支持消息的幂等性;key:Key 值value:value 值headers:消息头部属性。再看下 Kafka 是如何将消息构建成 Buffer 的:org.apache.kafka.common.record.DefaultRecord#writeTopublic static int writeTo(DataOutputStream out, int offsetDelta, long timestampDelta, ByteBuffer key, ByteBuffer value, Header[] headers) throws IOException { // 消息总数 int sizeInBytes = sizeOfBodyInBytes(offsetDelta, timestampDelta, key, value, headers); ByteUtils.writeVarint(sizeInBytes, out); // 属性 byte attributes = 0; // there are no used record attributes at the moment out.write(attributes); // 时间增量 ByteUtils.writeVarlong(timestampDelta, out); // 位移增量 ByteUtils.writeVarint(offsetDelta, out); // key if (key == null) { ByteUtils.writeVarint(-1, out); } else { int keySize = key.remaining(); // key size ByteUtils.writeVarint(keySize, out); // key Utils.writeTo(out, key, keySize); } // Value if (value == null) { ByteUtils.writeVarint(-1, out); } else { int valueSize = value.remaining(); // value size ByteUtils.writeVarint(valueSize, out); // value Utils.writeTo(out, value, valueSize); } // header ByteUtils.writeVarint(headers.length, out); for (Header header : headers) { // header key String headerKey = header.key(); byte[] utf8Bytes = Utils.utf8(headerKey); // header key 长度 ByteUtils.writeVarint(utf8Bytes.length, out); // header key 值 out.write(utf8Bytes); // header value byte[] headerValue = header.value(); if (headerValue == null) { ByteUtils.writeVarint(-1, out); } else { // header value 长度 ByteUtils.writeVarint(headerValue.length, out); // header value 值 out.write(headerValue); } } return ByteUtils.sizeOfVarint(sizeInBytes) + sizeInBytes; }根据以上代码逻辑,我用以下图表示 V2 版本消息格式的样子:Kafka 可变长度的具体做法借鉴了 Google ProtoBuffer 中的 Zig-zag 编码方式,这个我也没研究过,感兴趣的小伙伴可以研究下。但根据 Kafka 官方的描述,使用 Zig-zag 编码之后,例如一般的 key 只需要 1 字节保存即可,相比 V0、V1 版本需要 4 字节保存节省了 3 字节。那么我们来总结一下 V2 版本具有哪些变化:增加消息总长度字段:在消息格式的头部增加了消息总长度字段;保存时间戳增量:不再使用 8 字节保存时间戳信息,更改成使用可变长度的字段保存增量信息,增量的起始时间戳值被保存在 V2 版本中的起始时间戳字段中,后面会降到;保存位移增量:同理时间戳增量的做法;增加消息头部:有了消息头部,就可以满足用户一些定制化需求了,用户可在消息头部保存一些自定义的元数据信息;去除消息级别的 CRC 校验:移除消息级别的 CRC 校验,将 CRC 校验迁移到消息集合中。还是以 V0 举例的消息为例,假设该条消息改成 V2 版本,那么该条消息的大小为:1(sizeInBytes) + 1(attributes) + 1(timestamp) + 1(offset) + 1(key length) + 3(key) + 1(value length) + 5(value) + 1(headers length) = 15 字节。可以看出,V2 版本的消息占用的空间会比 V0、V1 版本的消息要小很多。V2 版本消息集合格式V2 版本的消息集合相比 V0、V1 版本要复杂得多,在 V2 版本的消息集合被称作“消息批次”,根据消息批次类中的注释:org.apache.kafka.common.record.DefaultRecordBatchRecordBatch => BaseOffset => Int64 Length => Int32 PartitionLeaderEpoch => Int32 Magic => Int8 CRC => Uint32 Attributes => Int16 LastOffsetDelta => Int32 // also serves as LastSequenceDelta FirstTimestamp => Int64 MaxTimestamp => Int64 ProducerId => Int64 ProducerEpoch => Int16 BaseSequence => Int32 Records => [Record]我们可以清除地看到 V2 版本中消息格式的具体字段与大小,我用以下图表示 V2 版本消息批次的样子:从以上图可看出,V2 版本的消息批次,相比 V0、V1 版本主要有以下变动:CRC 值从消息中移除,被迁移到消息批次中;增加了 PID、producer epoch、序列号等信息主要是为了支持幂等性以及事物引入的;消息批次最小为 61 字节,相比 V0、V1 版本要大很多。还是以之前举例的消息,将它放入 V2 版本消息批次的大小:61 + 15 = 76 字节,这比放入 V0、V1 版本的日志项 42 字节要大很多,看起来貌似比之前还要占用空间,其实这只是我们在举例时,只有一条消息,由于 V2 版本的消息格式要比 V0、V1 版本的消息格式要小,而 V2 版本的消息批次无论是否使用压缩,都可以放入多条消息,因此在批量发送消息时,V2 是要比 V0、V1 节约空间的。总结从以上文章内容得出,V2 版本主要是通过可变长度提高了消息格式的空间使用率,并将某些字段移到消息批次中,同时消息批次可容纳多条消息,从而在批量发送消息时,大幅度地节省了磁盘空间。
前面我写了一篇 「使用 Hexo + Gitee 快速搭建属于自己的博客」,很多人问起如果使用 md 写文章,图片如何快速地插入 md 文件中,我们都知道,md 格式与富文本格式不一样,md 的需要插入图片的访问地址,如果图片在本地,那么可以使用图片的本地存放地址,但如果你将 md 文件发给别人之后,图片的链接就失效了,这时我们就需要将图片存放在一个大家都能访问的地方,将这个地方的链接插入 md 文件即可,这就是图床。但问题又来了,每次插入一张图片,我们总是要先将图片上传到图床,获取链接之后再将链接插入到 md 文件中,这个过程过于繁琐,且每次插入都在做重复的工作,今天我就跟大家分享一下,我是如何使用 PicGo 图床工具高效地解决上面的问题。首先我们需要在本地安装 PicGo,PicGo 下载链接:https://github.com/Molunerfinn/PicGo/releases/PicGo 本身支持很多图床,比如阿里云、七牛等等,但这些都需要钱,使用 GitHub 虽然免费但是访问速度太慢。这时我们又想到了 Gitee,但 PicGo 本身不支持,需要安装第三方图床插件,于是我们打开插件设置,搜索 gitee,安装 gitee 插件:PicGo 更多插件可以在这里找到:https://github.com/PicGo/Awesome-PicGo登录 Gitee,然后创建一个仓库,接着在个人设置中生成一个私人令牌,紧接着我们回到 PicGo,在 Gitee 图床设置栏中找到 Gitee 图床,进行相关设置:owner:你的 Gitee ID;repo:你刚刚创建的那个用于保存图片的仓库名称;path:你需要将图片保存到仓库哪个目录中,如果在根目录就不需要填写;token:刚刚在个人设置中生成的私人令牌;message:默认即可。设置好之后保存,并且设置为默认图床。这时我们就可以使用 PicGo 将图片上传到 Gitee 仓库中并且返回图片链接了:但是每次都要在这个页面进行上传操作,不是很方便,我们可以设置一个上传快捷键:这样,你截图之后,再通过快捷键即可将图片上传到 Gitee 了,然后你就可以通过粘贴快捷键,快速地将图片以 md 图片链接的形式粘贴到你的文中:可以作一下对比:md:截图 -> 快捷键上传图片 -> 粘贴图片链接富文本:截图 -> 粘贴通过 PicGo 图床工具,我们几乎可以做到与平时我们复制粘贴图片那样方便。如果此时你使用的 md 编辑器是 typora,还可以在 typora 中设置 PicGo 自动上传图片:如上图的设置,截图之后,直接在 typora 编辑器中粘贴即可自动将图片上传至 Gitee,并且自动包装成 md 图片链接的形式。通过这个设置,与我们平时复制粘贴图片的方式就没有任何区别了!PicGo + Typora + Gitee,简直就是程序员写文章的三大利器!这篇文章加上前面那篇关于如何搭建博客的文章都不算是实战干货分享,但受众面很广,而且非常实用。正所谓 “工欲善其事,先利其器 ”。
本文原创来自我部门框架组核心开发李文龙先看下发现这个bug的一个背景,但背景中的问题,并非这个bug导致:最近业务部门的一位开发同事找过来说,自己在使用公司的框架向数据库新增数据时,新增的数据被莫名其妙的回滚了,并且本地开发环境能够复现这个问题。公司的框架是基于SpringBoot+Mybatis整合实现,按道理这么多项目已经在使用了, 如果是bug那么早就应该出现问题。我的第一想法是不是他的业务逻辑有啥异常导致事务回滚了,但是也并没有出现什么明显的异常,并且新增的数据在数据库中是可以看到的。于是猜测有定时任务在删数据。询问了这位同事,得到的答案却是否定的。没有办法,既然能本地复现那便是最好解决了,决定在本地开发环境跟源码找问题。刚开始调试时只设置了几个断点,代码执行流程一切正常,查看数据库中新增的数据也确实存在,但是当代码全部执行完成后,数据库中的数据却不存在了,程序也没有任何异常。继续深入断点调试,经过十几轮的断点调试发现偶尔会出现org.apache.ibatis.executor.ExecutorException: Executor was closed.,但是程序跳过一些断点时,就一切正常。在经过n轮调试未果之后,还是怀疑数据库有定时任务或者数据库有问题。于是重新创建一个测试库新增数据,这次数据新增一切正常,此时还是满心欢喜,至少已经定位出问题的大致原因了,赶紧找了DBA帮忙查询是否有SQL在删数据,果然证实了自己的想法。后来让这位开发同事再次确认是否在开发环境的机器上有定时任务有删除数据的服务。这次尽然告诉我确实有定时任务删数据,问题得以解决,原来他是新接手这个项目,对项目不是很熟悉,真的。。。。。。现在我们回到标题重点没有考虑Interceptor线程安全,导致断点调试时才会出现的bug晚上下班后,突然想到调试中遇到的org.apache.ibatis.executor.ExecutorException: Executor was closed.是啥情况?难道这地方还真的是有bug?马上双十一到了,这要是在双十一时整个大bug,那问题可大了。第二天上班后,决定要深入研究一下这个问题。由于不知道是什么情况下才能触发这个异常,只能还是一步一步断点调试。首先看实现的Mybatis拦截器,主要代码如下:@Intercepts({ @Signature(method = "query", type = Executor.class, args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), @Signature(method = "query", type = Executor.class, args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}), @Signature(method = "update", type = Executor.class, args = {MappedStatement.class, Object.class}) }) public class MybatisExecutorInterceptor implements Interceptor { private static final String DB_URL = "DB_URL"; private Executor target; private ConcurrentHashMap<Object, Object> cache = new ConcurrentHashMap<>(); @Override public Object intercept(Invocation invocation) throws Throwable { Object proceed = invocation.proceed(); //Executor executor = (Executor) invocation.getTarget(); Transaction transaction = target.getTransaction(); if (cache.get(DB_URL) != null) { //其他逻辑处理 System.out.println(cache.get(DB_URL)); } else if (transaction instanceof SpringManagedTransaction) { Field dataSourceField = SpringManagedTransaction.class.getDeclaredField("dataSource"); ReflectionUtils.makeAccessible(dataSourceField); DataSource dataSource = (DataSource) ReflectionUtils.getField(dataSourceField, transaction); String dbUrl = dataSource.getConnection().getMetaData().getURL(); cache.put(DB_URL, dbUrl); //其他逻辑处理 System.out.println(cache.get(DB_URL)); } //其他逻辑略... return proceed; } @Override public Object plugin(Object target) { if (target instanceof Executor) { this.target = (Executor) target; return Plugin.wrap(target, this); } return target; } }调试过程中,一步步断点,便会出现如下异常:Caused by: org.apache.ibatis.executor.ExecutorException: Executor was closed. at org.apache.ibatis.executor.BaseExecutor.getTransaction(BaseExecutor.java:78) at org.apache.ibatis.executor.CachingExecutor.getTransaction(CachingExecutor.java:51) at com.bruce.integration.mybatis.plugin.MybatisExecutorInterceptor.intercept(MybatisExecutorInterceptor.java:37) at org.apache.ibatis.plugin.Plugin.invoke(Plugin.java:61)根据异常信息,将代码定位到了org.apache.ibatis.executor.BaseExecutor.getTransaction() 方法@Override public Transaction getTransaction() { if (closed) { throw new ExecutorException("Executor was closed."); } return transaction; }发现当变量closed为true时会抛出异常。那么只要定位到修改closed变量值的方法不就知道了。通过idea工具的搜索只找到了一个修改该变量值的地方。那就是org.apache.ibatis.executor.BaseExecutor#close()方法@Override public void close(boolean forceRollback) { try { ....省略 } catch (SQLException e) { // Ignore. There's nothing that can be done at this point. log.warn("Unexpected exception on closing transaction. Cause: " + e); } finally { ....省略 closed = true; //只有该处修改为true } }于是将断点添加到finally代码块中,看看什么时候会走到这个方法。当一步步debug时,发现还没有走到close方法时,closed的值已经被修改为true,又抛出了Executor was closed.异常。奇怪了?难道还有其他代码会反射修改这个变量,按道理Mybatis要是修改自己代码中的变量值,不至于用这种方式啊,太不优雅了,还增加代码复杂度。没办法,又是经过n次一步步的断点调试。终于偶然的发现在idea debug窗口显示出这样的提示信息。Skipped breakpoint at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor:423 because it happened inside debugger evaluation从提示上看,不过是跳过了某个断点而已,其实之前就已经注意到这个提示,但是这次怀着好奇搜索了下解决方案。原来idea在展示类的成员变量,或者方法参数时会调用对象的toString(),怀着试试看的心态,去掉了idea中的toString选项。再次断点调试,这次竟然不再出现异常,原来是idea显示变量时调用对象的toString()方法搞得鬼???难怪在BaseExecutor#close()方法中的断点一直进不去,却修改了变量值。那为什么idea展示变量,调用toString()方法会导致此时查询所使用Executor被close呢?根据上面的提示,查看org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor源码,看看具体是什么逻辑private class SqlSessionInterceptor implements InvocationHandler { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { SqlSession sqlSession = getSqlSession(SqlSessionTemplate.this.sqlSessionFactory, SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator); try { Object result = method.invoke(sqlSession, args); if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) { // force commit even on non-dirty sessions because some databases require // a commit/rollback before calling close() sqlSession.commit(true); } return result; } catch (Throwable t) { Throwable unwrapped = unwrapThrowable(t); if (SqlSessionTemplate.this.exceptionTranslator != null && unwrapped instanceof PersistenceException) { // release the connection to avoid a deadlock if the translator is no loaded. See issue #22 closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory); sqlSession = null; Throwable translated = SqlSessionTemplate.this.exceptionTranslator .translateExceptionIfPossible((PersistenceException) unwrapped); if (translated != null) { unwrapped = translated; } } throw unwrapped; } finally { if (sqlSession != null) { closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory); } } } }从代码上看,这是jdk动态代理中的一个拦截器实现类,因为通过jdk动态代理,代理了Mybatis中的SqlSession接口,在idea中变量视图展示时被调用了toString()方法,导致被拦截。而invoke()方法中最后一定会在finally中关闭当前线程所关联的sqlSession,导致调用BaseExecutor.close()方法。为了验证这个想法,在SqlSessionInterceptor中对拦截到的toString()方法做了如下处理,如果是toString()方法不再向下继续执行,只要返回是哪些接口的代码类即可.private class SqlSessionInterceptor implements InvocationHandler { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (args == null && "toString".equals(method.getName())) { return Arrays.toString(proxy.getClass().getInterfaces()); } ... 其他代码省略 } }恢复idea中的设置,再次调试,果然不会再出现Executor was closed.异常。这看似mybatis-spring在实现SqlSessionInterceptor 时考虑不周全导致的一个bug,为了不泄露公司的框架代码还原这个bug,于是单独搭建了SpringBoot+Mybatis整合工程,并且写了一个类似逻辑的拦截器。代码如下:@Intercepts({ @Signature(method = "query", type = Executor.class, args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), @Signature(method = "query", type = Executor.class, args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}), @Signature(method = "update", type = Executor.class, args = {MappedStatement.class, Object.class}) }) public class MybatisExecutorInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object proceed = invocation.proceed(); Executor executor = (Executor) invocation.getTarget(); Transaction transaction = executor.getTransaction(); //其他逻辑略... return proceed; } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } }再次在SqlSessionInterceptor中断点执行,经过几次debug,尝试还原这个bug时,程序竟然一路畅通完美通过,没有任何异常。此刻我立刻想起了之前观察到的一段不合理代码,在文章开头的实例代码中Executor被做为成员变量保存,但是mybatis中Interceptor实现类是在程序启动时就被实例化的,并且是一个单实例对象。而在每次执行SQL时都会去创建一个新的Executor对象并且会经过Interceptor的public Object plugin(Object target),用于判断是否需要对该Executor对象进行代理。而示例中重写的plugin方法,每次都对Executor重新赋值,实际上这是线程不安全的。由于在idea中debug时展示变量调用了toString()方法,同样会创建SqlSession、Executor经过plugin方法,导致Executor成员变量实际上是被替换的。解决方案:直接通过invocation.getTarget()去获取被代理对象即可,而不是使用成员变量。为什么线上程序没有报Executor was closed问题?因为线上不会像在idea中一样去调用toString() 方法代码中使用了缓存,当使用了Executor 获取到url后,下次请求过来就不会再使用Executor对象,也就不会出现异常。程序刚启动时并发量不够大,如果在程序刚起来时,立刻有足够的请求量,仍然会抛出异常,但是只要有一次结果被缓存,后续也就不会出现异常。总结:实际上还是MybatisExecutorInterceptor中将Executor做为成员变量,对Executor更改,出现线程不安全导致的异常。而idea中显示变量值调用toString()方法只是让异常发生的诱因。原文链接:https://blog.csdn.net/u013202238/article/details/108249483
程序员总会有一些技术文章的输出总结,很多人会选择各大第三方博客平台,但某些第三方博客平台的 UI 简直惨不忍睹,广告巨多,且对 md 格式支持得不够好等等,基于这些原因,我们有足够的理由搭建一套属于我们自己的博客。我早在 17 年的时候就已经在 GitHub Pages 搭建了一套属于自己的博客,当时使用的 GitHub Pages 官方支持的 JekyII 工具进行部署,体验真的不是很好。后来的 Hexo 比它优秀太多了。现在 Gitee 也推出了自己的 Gitee Pages,由于是 Gitee Pages 的服务器是在国内的,因此访问速度非常快,而 GitHub 在国内的访问速度实在是惨不忍睹,于是我使用了 Hexo 在 Gtiee 搭建部署了另一个博客,下面我将搭建的整个过程总结写下来,也许能够帮助正在使用 Hexo 搭建博客的你。安装 HexoHexo 是一套基于 NodeJS 的博客框架,以 MarkDown 的写文方式,快速生成属于自己的静态博客系统。在使用 Hexo 之前,我们需要在系统中安装 NodeJS(以下使用 MacOS 系统环境):brew install node安装好之后,使用命令 npm --version,若显示有版本信息,说明 NodeJs 已经安装成功。安装 Hexo 工具:npm -g install hexo-cli随后运行 hexo --version,若出现以下信息:则说明 Hexo 已经安装成功。初始化博客安装好 Hexo 之后,接着我们使用 Hexo 生成博客源码文件,首先创建博客源码文件存放的目录:mkdir -p ~/blog && cd ~/blog博客的目录地址可以是任意目录,这里我放在用户根目录。初始化博客:hexo init ./这个步骤可能会因为网络情况,耗时可能会很长,过程中如果出现了 Err 字样的错误问题,说明初始化出错了,这是你需要将目录中所有的文件删除后再试试,科学上网也许能解决这个问题。当然你也可以试试以下这个方法:npm config set registry https://registry.npm.taobao.org表示初始化成功的提示:在博客源码文件目录下(一定要在当前目录),运行:hexo sHexo 会在本地启动一个 Http 服务器。按照提示,我们访问 http://localhost:4000:如果能够向上面这样正常打开,则说明博客已经在本地启动成功了!编写文章可以快速地使用以下命令创建一篇文章:hexo new post 'post name'如上,我们创建了一篇名为《test-post》的文章,hexo 会将他文章输出到{blog_path}/source/_posts/,也因此我们知道了 Hexo 管理下的博客所有文章都会被放在 source/_posts 目录中。我们使用 Finder 打开目录:open ./source/_posts这时我们就可以使用 md 编辑器写文章了,这里我强烈推荐 Typora,在我心中是最强 md 编辑器没有之一!文章开头的格式大致有以下几个选项:--- title: 文章标题 date: 文章的编写日期,格式:Y-m-d H:i tags: 文章标签,格式:[标签1, 标签2, 标签3,……] categories: 文章分类,格式:[分类名1, 分类名2, 分类名3, ……] description: 文章描述 ---其中 title 是必要的,其他可省略。Hexo 的文章模版会放在 ./scaffolds 目录中:Hexo 默认有三个 模版,刚刚我们执行的 hexo new post 命令使用的默认即 post.md,我们可以使用其它模版:hexo new page写好文章之后,运行 hexo s:自定义主题如果官方自带的主题不符合你的胃口,你可以去 Hexo 主题库 https://hexo.io/themes/ 寻觅你喜欢的主题,这里我使用 nexT 主题进行演示(经过我对大量主题的使用对比,nexT 是一款非常优秀的主题):git clone https://github.com/theme-next/hexo-theme-next themes/next这里可能需要小等一会。下载好之后,会保存在 ./themes 目录中,该目录也是存在自定义主题的地方:启用主题:vim _config.yml将 theme 选项配置为我们刚下好的主题名称(./themes 目录中的主题目录名称)。再使用 hexo s 启动博客本地服务器:此时可以看到博客主题已经换成 nexT 主题了。这里需要特别说明一下,在 Hexo 中,一共有两个 _config.yaml:1、./_config.yaml为 Hexo 博客的配置文件,定义主题样式的根基,博客名称、文章展示规则、主题切换配置、插件配置、部署配置等等都在这里设置。1、./themes/xxx/_config.yaml为主题的配置,不同的主题有不同的选项,交由主题设计者自行设置选项,nexT 主题的配置选项特别多,从这方面也可以看出 nexT 主题功能丰富的一面。安装插件Hexo 最强大的地方在于它的插件体系,它具备丰富的插件,比如访客统计插件,博客本地搜索插件、文章字数统计插件等等。如果你想要扩展你的博客功能,可以去 Hexo 的插件库https://hexo.io/plugins/搜索一下,说不定有意外的惊喜。安装完插件之后,记得在 ./_config.yaml中配置一下:plugins: plugin-name: xxx: xxxx:发布博客前面我们操作了这么多,都只是在本地可以访问我们的博客,因此我们需要将我们的博客发布到具备公网 IP 的服务器上面,但我们可以使用 GitHub Pages 或者 Gitee Pages 进行托管,免去了花钱购买服务器的步骤。以下使用 Gitee Pages 演示。在发布博客之前,我们需要将博客内容生成一份静态资源文件,使用以下命令:hexo generate # 或者 hexo g静态文件生成好之后会保存到 ./public 目录中。在 Gitee 中,创建一个与 Gitee ID 同名的仓库,例如我的 Gitee 博客仓库 https://gitee.com/objcoding/objcoding还需要在 ./_config.yaml 中配置部署信息:运行以下命令:hexo deploy # 或者 hexo d这时直接将 ./public 目录中的文件提交到配置的仓库中。这时我们在仓库中找到:服务 -> Gitee Pages:部署好之后,会提示你的 Gitee Pages 的地址,例如我的博客 Gitee Pages 地址是:https://objcoding.gitee.io/。这时别人就可以通过该链接访问你的博客了。同理,如果你不想使用 GitHub Pages 或者 Gitee Pages,我需要使用自己买的服务器进行搭建,你只需要将 ./public目录中的内容拷贝到服务器中,服务器再安装一个 Nginx 服务器配置到该目录即可(./public 目录中已经有 index.html)。这里需要提醒一下大家,我们刚刚只是将 ./public 中的静态文件 push 到 gitee 仓库中,其它目录是没有任何备份的,需要你自行对其它源码内容进行备份,要不然你的电脑磁盘出问题,文章源文件就丢失了。此时你可以将博客目录也当作一个 Git 仓库,在 Gitee 中创建一个仓库保存即可。
以前我们讨论的消费组,都是 group 的形式,group 可以自动地帮助消费者分配分区,且在发生异常时,还能自定地进行重平衡(Rebalance)。正常来说,group 帮助用户实现自动监听分区消费,但是在用户需要指定分区进行精确消费的场景下,由于 group 的重平衡机制,会打破这种消费方式,这不前段时间某项目就有个需求是这样的:消息源端有若干个,每个消息源都会产生不同的消息,目标端也有若干个,每个目标端需要消费指定的消息源类型。在以往,由于消费组的重平衡机制会打乱这种消费方式,只能申请多个主题对消息进行隔离,每个消息源将消息发送到指定主题,目标端监听指定的主题。这么做肯定没有指定分区消费这么优雅了,每增加一种消息源,都需要新增一个 topic,且消费粒度不能灵活组合。针对以上问题,Kafka 的提供了独立消费者模式,可以消费者可以指定分区进行消费,如果只用一个 topic,每个消息源启动一个生产者,分别发往不同的分区,消费者指定消费相关的分区即可,用如下图所示:但是 Kafka 独立消费者也有它的限定场景:1、 Kafka 独立消费者模式下,Kafka 集群并不会维护消费者的消费偏移量,需要每个消费者维护监听分区的消费偏移量,因此,独立消费者模式与 group 模式切勿混合使用!2、group 模式的重平衡机制在消费者异常时可将其监听的分区重分配给其它正常的消费者,使得这些分区不会停止被监听消费,但是独立消费者由于是手动进行监听指定分区,因此独立消费者发生异常时,并不会将其监听的分区进行重分配,这就会造成某些分区消息堆积。因此,在该模式下,独立消费者需要实现高可用,例如独立消费者使用 K8s Deployment 进行部署。下面将演示如何使用 Kafka#assgin 方法手动订阅指定分区进行消费:public static void main(String[] args) { Properties kafkaProperties = new Properties(); kafkaProperties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer"); kafkaProperties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArrayDeserializer"); kafkaProperties.put("bootstrap.servers", "localhost:9092"); KafkaConsumer<String, byte[]> consumer = new KafkaConsumer<>(kafkaProperties); List<TopicPartition> partitions = new ArrayList<>(); partitions.add(new TopicPartition("test_topic", 0)); partitions.add(new TopicPartition("test_topic", 1)); consumer.assign(partitions); while (true) { ConsumerRecords<String, byte[]> records = consumer.poll(Duration.ofMillis(3000)); for (ConsumerRecord<String, byte[]> record : records) { System.out.printf("topic:%s, partition:%s%n", record.topic(), record.partition()); } } }
最近,遇到某个集群的生产端发送延迟特别高,而且吞吐量上不去,检查集群负载却很低,且集群机器配置非常好,网络带宽也很大,于是使用 Kafka 压测脚本进行了压测。昨天凌晨,在生产环境进行实战调优,经过不断参数改动,现将生产者相关参数设置为以下配置:linger.ms=50 batch.size=524288 compression.type=lz4 acks=1(用户要求消息至少要发送到分区 leader) max.request.size=5242880 buffer.memory=268435456在生产环境的一台服务器上,使用以上参数对集群进行生产发送性能压测:从上图可以看到,使用平均 4k 大小的消息体对集群进行压测,单个 Producer 平均吞吐量达到 2000MB/s,50w/s+!作为对比,我还是使用同一台服务器,将调优参数去掉,再压一遍:可以看到,最高的吞吐量也不过 500M/s,最低已经来到 2M/s 了。虽然说实际客户端环境比压测环境复杂很多,但是使用压测工具已经能够证明,该集群的负载目前现在还远远没有达到瓶颈,且生产端还有待优化。以上参数调优思想是:1、buffer.memory=268435456由于发送端发送频率非常快,加上由于 Spark 客户端频繁断开连接导致生产端 Sender 线程发送延迟增高,这就会造成客户端发送速率 > Sender线程的发送速率。它们之间会有一个缓冲池,如果客户端发送速率 > Sender 线程的发送速率,缓冲池会很快塞满,阻塞当前发送进程,导致发送延迟增高。注:如果 Java 进程的堆内存大小允许,可以适当再调大一些。2、batch.size=524288我们的客户端消息大小普遍 4k 左右,而 batch.size 默认大小为 16k,如果直接使用默认的大小,每个批次很容易被塞满,达不到缓冲的作用。而且,如果消息大小 > batch.size,则缓冲池不会对该消息产生作用,导致内存频繁被 JVM GC 回收,具体详情请看这篇文章:「深度剖析 Kafka Producer 的缓冲池机制【图解 + 源码分析】」。3、max.request.size=5242880该参数主要作用是限定每次发送到 broker 的数据大小,默认值为 1M,如果太小,会导致生产端与 broker 的网络交互增多,加上加上由于 Spark 客户端频繁断开连接导致生产端 Sender 线程发送延迟增高。如上图,当 max.request.size=5242880 时,请求 broker 发送的数据量不大于 max.request.size。如果频繁地进行网络交互,延迟也会随之增高,该值可以根据集群网络带宽适当设置更大一些,我们的集群带宽非常充足,可以适当再调大些。4、linger.ms=50为了防止某些时候发送速率很低,batch 没有装满导致不发送消息的情况,需要适当调整该值,与 batch.size 的大小适当调整为最佳大小。注:以上参数仅仅是根据我的生产集群实际情况给出的值,具体参数还是需要结合你的集群本身的情况,机器的配置,网络的带宽不同,都会影响参数的值。
上次跟大家分享的文章「Kafka Producer 异步发送消息居然也会阻塞?」中提到了缓冲池,后面再经过一番阅读源码后,发现了这个缓冲池设计的很棒,被它的设计思想优雅到了,所以忍不住跟大家继续分享一波。在新版的 Kafka Producer 中,设计了一个消息缓冲池,在创建 Producer 时会默认创建一个大小为 32M 的缓冲池,也可以通过 buffer.memory 参数指定缓冲池的大小,同时缓冲池被切分成多个内存块,内存块的大小就是我们创建 Producer 时传的 batch.size 大小,默认大小 16384,而每个 Batch 都会包含一个 batch.size 大小的内存块,消息就是存放在内存块当中。整个缓冲池的结构如下图所示:客户端将消息追加到对应主题分区的某个 Batch 中,如果 Batch 已经满了,则会新建一个 Batch,同时向缓冲池(RecordAccumulator)申请一块大小为 batch.size 的内存块用于存储消息。当 Batch 的消息被发到了 Broker 后,Kafka Producer 就会移除该 Batch,既然 Batch 持有某个内存块,那必然就会涉及到 GC 问题,如下:以上,频繁的申请内存,用完后就丢弃,必然导致频繁的 GC,造成严重的性能问题。那么,Kafka 是怎么做到避免频繁 GC 的呢?前面说过了,缓冲池在设计逻辑上面被切分成一个个大小相等的内存块,当消息发送完毕,归还给缓冲池不就可以避免被回收了吗?缓冲池的内存持有类是 BufferPool,我们先来看下 BufferPool 都有哪些成员:public class BufferPool { // 总的内存大小 private final long totalMemory; // 每个内存块大小,即 batch.size private final int poolableSize; // 申请、归还内存的方法的同步锁 private final ReentrantLock lock; // 空闲的内存块 private final Deque<ByteBuffer> free; // 需要等待空闲内存块的事件 private final Deque<Condition> waiters; /** Total available memory is the sum of nonPooledAvailableMemory and the number of byte buffers in free * poolableSize. */ // 缓冲池还未分配的空闲内存,新申请的内存块就是从这里获取内存值 private long nonPooledAvailableMemory; // ... }从 BufferPool 的成员可看出,缓冲池实际上由一个个 ByteBuffer 组成的,BufferPool 持有这些内存块,并保存在成员 free 中,free 的总大小由 totalMemory 作限制,而 nonPooledAvailableMemory 则表示还剩下缓冲池还剩下多少内存还未被分配。当 Batch 的消息发送完毕后,就会将它持有的内存块归还到 free 中,以便后面的 Batch 申请内存块时不再创建新的 ByteBuffer,从 free 中取就可以了,从而避免了内存块被 JVM 回收的问题。接下来跟大家一起分析申请内存和归还内存是如何实现的。1、申请内存申请内存的入口:org.apache.kafka.clients.producer.internals.BufferPool#allocate1)内存足够的情况当用户请求申请内存时,如果发现 free 中有空闲的内存,则直接从中取:if (size == poolableSize && !this.free.isEmpty()){ return this.free.pollFirst(); }这里的 size 即申请的内存大小,它等于 Math.max(this.batchSize, AbstractRecords.estimateSizeInBytesUpperBound(maxUsableMagic, compression, key, value, headers));即如果你的消息大小小于 batchSize,则申请的内存大小为 batchSize,那么上面的逻辑就是如果申请的内存大小等于 batchSize 并且 free 不空闲,则直接从 free 中获取。我们不妨想一下,为什么 Kafka 一定要申请内存大小等于 batchSize,才能从 free 获取空闲的内存块呢?前面也说过,缓冲池的内存块大小是固定的,它等于 batchSize,如果申请的内存比 batchSize 还大,说明一条消息所需要存放的内存空间比内存块的内存空间还要大,因此不满足需求,不满组需求怎么办呢?我们接着往下分析:// now check if the request is immediately satisfiable with the // memory on hand or if we need to block int freeListSize = freeSize() * this.poolableSize; if (this.nonPooledAvailableMemory + freeListSize >= size) { // we have enough unallocated or pooled memory to immediately // satisfy the request, but need to allocate the buffer freeUp(size); this.nonPooledAvailableMemory -= size; }freeListSize:指的是 free 中已经分配好并且已经回收的空闲内存块总大小;nonPooledAvailableMemory:缓冲池还未分配的空闲内存,新申请的内存块就是从这里获取内存值;this.nonPooledAvailableMemory + freeListSize:即缓冲池中总的空闲内存空间。如果缓冲池的内存空间比申请内存大小要大,则调用 freeUp(size); 方法,接着将空闲的内存大小减去申请的内存大小。private void freeUp(int size) { while (!this.free.isEmpty() && this.nonPooledAvailableMemory < size) this.nonPooledAvailableMemory += this.free.pollLast().capacity(); }freeUp 这个方法很有趣,它的思想是这样的:如果未分配的内存大小比申请的内存还要小,那只能从已分配的内存列表 free 中将内存空间要回来,直到 nonPooledAvailableMemory 比申请内存大为止。2)内存不足的情况在我的「Kafka Producer 异步发送消息居然也会阻塞?」这篇文章当中也提到了,当缓冲池的内存块用完后,消息追加调用将会被阻塞,直到有空闲的内存块。阻塞等待的逻辑是怎么实现的呢?// we are out of memory and will have to block int accumulated = 0; Condition moreMemory = this.lock.newCondition(); try { long remainingTimeToBlockNs = TimeUnit.MILLISECONDS.toNanos(maxTimeToBlockMs); this.waiters.addLast(moreMemory); // loop over and over until we have a buffer or have reserved // enough memory to allocate one while (accumulated < size) { long startWaitNs = time.nanoseconds(); long timeNs; boolean waitingTimeElapsed; try { waitingTimeElapsed = !moreMemory.await(remainingTimeToBlockNs, TimeUnit.NANOSECONDS); } finally { long endWaitNs = time.nanoseconds(); timeNs = Math.max(0L, endWaitNs - startWaitNs); recordWaitTime(timeNs); } if (waitingTimeElapsed) { throw new TimeoutException("Failed to allocate memory within the configured max blocking time " + maxTimeToBlockMs + " ms."); } remainingTimeToBlockNs -= timeNs; // check if we can satisfy this request from the free list, // otherwise allocate memory if (accumulated == 0 && size == this.poolableSize && !this.free.isEmpty()) { // just grab a buffer from the free list buffer = this.free.pollFirst(); accumulated = size; } else { // we'll need to allocate memory, but we may only get // part of what we need on this iteration freeUp(size - accumulated); int got = (int) Math.min(size - accumulated, this.nonPooledAvailableMemory); this.nonPooledAvailableMemory -= got; accumulated += got; } }以上源码的大致逻辑:首先创建一个本次等待 Condition,并且把它添加到类型为 Deque 的 waiters 中(后面在归还内存中会唤醒),while 循环不断收集空闲的内存,直到内存比申请内存大时退出,在 while 循环过程中,调用 Condition#await 方法进行阻塞等待,归还内存时会被唤醒,唤醒后会判断当前申请内存是否大于 batchSize,如果等与 batchSize 则直接将归还的内存返回即可,如果当前申请的内存大于 大于 batchSize,则需要调用 freeUp 方法从 free 中释放空闲的内存出来,然后进行累加,直到大于申请的内存为止。2、归还内存申请内存的入口:org.apache.kafka.clients.producer.internals.BufferPool#deallocate(java.nio.ByteBuffer, int)public void deallocate(ByteBuffer buffer, int size) { lock.lock(); try { if (size == this.poolableSize && size == buffer.capacity()) { buffer.clear(); this.free.add(buffer); } else { this.nonPooledAvailableMemory += size; } Condition moreMem = this.waiters.peekFirst(); if (moreMem != null) moreMem.signal(); } finally { lock.unlock(); } }归还内存块的逻辑比较简单:如果归还的内存块大小等于 batchSize,则将其清空后添加到缓冲池的 free 中,即将其归还给缓冲池,避免了 JVM GC 回收该内存块。如果不等于呢?直接将内存大小累加到未分配并且空闲的内存大小值中即可,内存就无需归还了,等待 JVM GC 回收掉,最后唤醒正在等待空闲内存的线程。经过以上的源码分析之后,给大家指出需要注意的一个问题,如果设置不当,会给 Producer 端带来严重的性能影响:如果你的消息大小比 batchSize 还要大,则不会从 free 中循环获取已分配好的内存块,而是重新创建一个新的 ByteBuffer,并且该 ByteBuffer 不会被归还到缓冲池中(JVM GC 回收),如果此时 nonPooledAvailableMemory 比消息体还要小,还会将 free 中空闲的内存块销毁(JVM GC 回收),以便缓冲池中有足够的内存空间提供给用户申请,这些动作都会导致频繁 GC 的问题出现。因此,需要根据业务消息的大小,适当调整 batch.size 的大小,避免频繁 GC。
Kafka 一直以来都以高吞吐量的特性而家喻户晓,就在上周,在一个性能监控项目中,需要使用到 Kafka 传输海量消息,在这过程中遇到了一个 Kafka Producer 异步发送消息会被阻塞的问题,导致生产端发送耗时很大。是的,你没听错,Kafka Producer 异步发送消息也会发生阻塞现象,那究竟是怎么回事呢?在新版的 Kafka Producer 中,设计了一个消息缓冲池,客户端发送的消息都会被存储到缓冲池中,同时 Producer 启动后还会开启一个 Sender 线程,不断地从缓冲池获取消息并将其发送到 Broker,如下图所示:这么看来,Kafka 的所有发送,都可以看作是异步发送了,因此在新版的 Kafka Producer 中废弃掉异步发送的方法了,仅保留了一个 send 方法,同时返回一个 Futrue 对象,需要同步等待发送结果,就使用 Futrue#get 方法阻塞获取发送结果。而我在项目中直接调用 send 方法,为何还会发送阻塞呢?我们在构建 Kafka Producer 时,会有一个自定义缓冲池大小的参数 buffer.memory,默认大小为 32M,因此缓冲池的大小是有限制的,我们不妨想一下,缓冲池内存资源耗尽了会怎么样?Kafka 源码的注释是非常详细的,RecordAccumulator 类是 Kafka Producer 缓冲池的核心类,而 RecordAccumulator 类就有那么一段注释:The accumulator uses a bounded amount of memory and append calls will block when that memory is exhausted, unless this behavior is explicitly disabled.大概的意思是:当缓冲池的内存块用完后,消息追加调用将会被阻塞,直到有空闲的内存块。由于性能监控项目每分钟需要发送几百万条消息,只要 Kafka 集群负载很高或者网络稍有波动,Sender 线程从缓冲池捞取消息的速度赶不上客户端发送的速度,就会造成客户端发送被阻塞。我写个例子让大家直观感受一下被阻塞的现象:public static void main(String[] args) { Properties properties = new Properties(); properties.put(ProducerConfig.ACKS_CONFIG, "0"); properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer"); properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArraySerializer"); properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092,localhost:9093,localhost:9094"); properties.put(ProducerConfig.LINGER_MS_CONFIG, 1000); properties.put(ProducerConfig.BATCH_SIZE_CONFIG, 1024 * 1024); properties.put(ProducerConfig.MAX_REQUEST_SIZE_CONFIG, 5242880); properties.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4"); KafkaProducer<String, byte[]> producer = new KafkaProducer<>(properties); String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; List<byte[]> bytesList = new ArrayList<>(); Random random = new Random(); for (int j = 0; j < 1024; j++) { int i1 = random.nextInt(10); if (i1 == 0) { i1 = 1; } byte[] bytes = new byte[1024 * i1]; for (int i = 0; i < bytes.length - 1; i++) { bytes[i] = (byte) str.charAt(random.nextInt(62)); } bytesList.add(bytes); } while (true) { long start = System.currentTimeMillis(); producer.send(new ProducerRecord<>("test_topic", bytesList.get(random.nextInt(1023)))); long end = System.currentTimeMillis() - start; if (end > 100) { System.out.println("发送耗时:" + end); } // Thread.sleep(10); } }以上例子构建了一个 Kafka Producer 对象,同时使用死循环不断地发送消息,这时如果把 Thread.sleep(10);注释掉,则会出现发送耗时很长的现象:使用 JProfiler 可以查看到分配内存的地方出现了阻塞:跟踪到源码:发现在 org.apache.kafka.clients.producer.internals.BufferPool#allocate 方法中,如果判断缓冲池没有空闲的内存了,则会阻塞内存分配,直到有空闲内存为止。如果不注释 Thread.sleep(10);这段代码则不会发生阻塞现象,打断点到阻塞的地方,也不会被 Debug 到,从现象能够得知,Thread.sleep(10);使得发送消息的频率变低了,此时 Sender 线程发送的速度超过了客户端的发送速度,缓冲池一直处于未满状态,因此不会产生阻塞现象。除了以上缓冲池内存满了会发生阻塞之外,Kafka Produer 其它情况都不会发生阻塞了吗?非也,其实还有一个地方,也会发生阻塞!Kafka Producer 通常在第一次发送消息之前,需要获取该主题的元数据 Metadata,Metadata 内容包括了主题相关分区 Leader 所在节点信息、副本所在节点信息、ISR 列表等,Kafka Producer 获取 Metadata 后,便会根据 Metadata 内容将消息发送到指定的分区 Leader 上,整个获取流程大致如下:如上图所示,Kafka Producer 在发送消息之前,会检查主题的 Metadata 是否需要更新,如果需要更新,则会唤醒 Sender 线程并发送 Metatadata 更新请求,此时 Kafka Producer 主线程则会阻塞等待 Metadata 的更新。如果 Metadata 一直无法更新,则会导致客户端一直阻塞在那里。
DataX 是阿里巴巴开源的一个异构数据源离线同步工具,致力于实现包括关系型数据库(MySQL、Oracle 等)、HDFS、Hive、ODPS、HBase、FTP 等各种异构数据源之间稳定高效的数据同步功能。前段时间我在 K8s 相关文章中有提到过数据同步的项目,该项目就是基于 DataX 内核构建的,由于公司数据同步的需求,还需要在 DataX 原有的基础上支持增量同步功能,同时支持分布式调度,在「使用 K8s 进行作业调度实战分享」这篇文章中已经详细描述其中的实现。基于我在项目中对 DataX 的实践过程,给大家分享我所理解的 DataX 核心设计原理。设计理念异构数据源离线同步是将源端数据同步到目的端,但是端与端的数据源类型种类繁多,在没有 DataX 之前,端与端的链路将组成一个复杂的网状结构,非常零散无法将同步核心逻辑抽象出来,DataX 的理念就是作为一个同步核心载体连接连接各类数据源,当我们需要数据同步时,只需要以插件的形式接入到 DataX 即可,将复杂的网状结构链路变成了一个星型结构,如下图所示:架构设计用过 IDEA 的小伙都知道,IDEA 有很多非常棒的插件,用户可根据自身编程需求,下载相关的插件,DataX 也是使用这种可插拔的设计,采用了 Framework + Plugin 的架构设计,如下图所示:有了插件,DataX 可支持任意数据源到数据源,只要实现了 Reader/Writer Plugin,官方已经实现了主流的数据源插件,比如 MySQL、Oracle、SQLServer 等,当然我们也可以开发一个 DataX 插件。核心概念DataX 核心主要由 Job、Task Group、Task、Channel 等概念组成:1、Job在 DataX 中用来描述一个源端到一个目的端的同步作业,是 DataX 数据同步面向用户的最小业务单元。一个Job 对应 一个 JobContainer, JobContainer 负责 Job 的全局切分、调度、前置语句和后置语句等工作。2、Task Group一组 Task 的集合,根据 DataX 的公平分配策略,公平地分配 Task 到对应的 TaskGroup 中。一个 TaskGroup 对应一个 TaskGroupContainer,负责执行一组 Task。3、TaskJob 的最小执行单元,一个 Job 可根据 Reader 端切分策略,且分成若干个 Task,以便于并发执行。Job、Task Group、Task 三者之间的关系可以用如下图表示:根据切分策略将一个 Job 切分成多个 Task,根据分配策略将多个 Task 组成一个 TaskGroup。4、ChannelDataX 会单独启动一条线程运行运行一个 Task,而 Task 会持有一个 Channel,用作 Reader 与 Writer 的数据传输媒介,DataX 的数据流向都是按照 Reader—>Channel—>Writer 的方向流转,用如下图表示:Channel 作为传输通道,即能充当缓冲层,同时还能对数据传输进行限流操作。5、TransformerDataX 的 transformer 模式同时还提供了强大的数据转换功能,DataX 默认提供了丰富的数据转换实现类,用户还可以根据项目自身需求,扩展数据转换。调度流程DataX 将用户的 job.json 同步作业配置解析成一个 Job,DataX 通过 JobContainer 完成全局切分、调度、前置语句和后置语句等工作,整体调度流程用如下图表示:1、切分策略1)计算并发量(即 needChannelNumber 大小)DataX有流控模式,其中,可以设置 bps 限速,tps 限速:bps 限速:needChannelNumber = 总 byteLimit / 单个 Channel byteLimittps 限速:needChannelNumber = 总 recordLimit / 单个 Channel recordLimit如果以上都没有设置,则会根据用户在 job.setting.speed.channel 配置的并发数量设置 needChannelNumber。2)根据 needChannelNumber 将 Job 切分成多个 Task这个步骤的具体切分逻辑交由相关插件去完成,例如 Rdb 对数据的拆分主要分成两类:如果用户配置了具体的 Table 数量,那么就按照 Table 为最小单元进行拆分(即一个 Table 对应一个 Task),并生成对应的 querySql;如果用户还配置了 splitPk,则会根据 splitPk 进行切分,具体逻辑是根据 splitPk 区间对 Table 进行拆分,并生成对应的 querySql。2、公平分配策略DataX 在执行调度之前,会调用 JobAssignUtil#assignFairly方法对切分好的 Task 公平分配给每个 TaskGroup。在分配之前,会计算 TaskGroup 的数量,具体公式:int taskGroupNumber = (int) Math.ceil(1.0 * channelNumber / channelsPerTaskGroup);channelNumber 即为在切分策略中根据用户配置计算得到的 needChannelNumber 并发数量大小,channelsPerTaskGroup 为每个 TaskGroup 需要的并发数量,默认为 5。求出 TaskGroup 的数量之后,就会执行公平分配策略,将 Task 平均分配个每个 TaskGroup,最后执行调度,完成整个同步作业。举个公平分配策略的例子:假设 A 库有表 0、1、2,B 库上有表 3、4,C 库上有表 5、6、7,如果此时有 4 个 TaskGroup,则 assign 后的结果为:taskGroup-0: 0, 4, taskGroup-1: 3, 6, taskGroup-2: 5, 2, taskGroup-3: 1, 7举个例子来描述 Job、Task、Task Group 之间的关系:用户构建了一个数据同步作业,该作业的目的是将 MySql 的 100 张表同步到 Oracle 库中,假设此时用户设置了 20 个并发(即 channelNumber=20):DataX 根据表的数量切分成 100 个 Task;DataX 默认给每个 TaskGroup 分配 5 个 Channel,因此 taskGroupNumber = channelNumber / channelsPerTaskGroup = 20 / 5 = 4;根据 DataX 的公平分配策略,会将 100 个 Task 平均分配给每个 TaskGroup,因此每个 TaskGroup 处理 taskNumber / taskGroupNumber = 100 / 4 = 25 个 Task。以上的例子用如下图表示:由于一个 Channel 对应一个线程执行,因此 DataX 的线程模型可以用如下图表示:
背景在 2 月10 号下午大概 1 点半左右,收到用户方反馈,发现日志 kafka 集群 A 主题 的 34 分区选举不了 leader,导致某些消息发送到该分区时,会报如下 no leader 的错误信息:In the middle of a leadership election, there is currently no leader for this partition and hence it is unavailable for writes.由于 A 主题 34 分区的 leader 副本在 broker0,另外一个副本由于速度跟不上 leader,已被踢出 ISR,0.11 版本的 kafka 的 unclean.leader.election.enable 参数默认为 false,表示分区不可在 ISR 以外的副本选举 leader,导致了 A 主题发送消息持续报 34 分区 leader 不存在的错误,且该分区还未消费的消息不能继续消费了。接下来运维在 kafka-manager 查不到 broker0 节点了处于假死状态,但是进程依然还在,重启了好久没见反应,然后通过 kill -9 命令杀死节点进程后,接着重启失败了,导致了如下问题:Kafka 日志分析查看了 KafkaServer.log 日志,发现 Kafka 重启过程中,产生了大量如下日志:发现大量主题索引文件损坏并且重建索引文件的警告信息,定位到源码处:kafka.log.OffsetIndex#sanityCheck按我自己的理解描述下:Kafka 在启动的时候,会检查 kafka 是否为 cleanshutdown,判断依据为 ${log.dirs} 目录中是否存在 .kafka_cleanshutDown 的文件,如果非正常退出就没有这个文件,接着就需要 recover log 处理,在处理中会调用 sanityCheck() 方法用于检验每个 log sement 的 index 文件,确保索引文件的完整性:entries:由于 kafka 的索引文件是一个稀疏索引,并不会将每条消息的位置都保存到 .index 文件中,因此引入了 entry 模式,即每一批消息只记录一个位置,因此索引文件的 entries = mmap.position / entrySize;lastOffset:最后一块 entry 的位移,即 lastOffset = lastEntry.offset;baseOffset:指的是索引文件的基偏移量,即索引文件名称的那个数字。索引文件与日志文件对应关系图如下:判断索引文件是否损坏的依据是:_entries == 0 || _lastOffset > baseOffset = false // 损坏 _entries == 0 || _lastOffset > baseOffset = true // 正常这个判断逻辑我的理解是:entries 索引块等于零时,意味着索引没有内容,此时可以认为索引文件是没有损坏的;当 entries 索引块不等于 0,就需要判断索引文件最后偏移量是否大于索引文件的基偏移量,如果不大于,则说明索引文件被损坏了,需要用重新构建。那为什么会出现这种情况呢?我在相关 issue 中似乎找到了一些答案:https://issues.apache.org/jira/browse/KAFKA-1112https://issues.apache.org/jira/browse/KAFKA-1554总的来说,非正常退出在旧版本似乎会可能发生这个问题?有意思的来了,导致开机不了并不是这个问题导致的,因为这个问题已经在后续版本修复了,从日志可看出,它会将损坏的日志文件删除并重建,我们接下来继续看导致重启不了的错误信息:问题就出在这里,在删除并重建索引过程中,就可能出现如上问题,在 issues.apache.org 网站上有很多关于这个 bug 的描述,我这里贴两个出来:https://issues.apache.org/jira/browse/KAFKA-4972https://issues.apache.org/jira/browse/KAFKA-3955这些 bug 很隐晦,而且非常难复现,既然后续版本不存在该问题,当务之急还是升级 Kafka 版本,后续等我熟悉 scala 后,再继续研究下源码,细节一定是会在源码中呈现。解决思路分析针对背景两个问题,矛盾点都是因为 broker0 重启失败导致的,那么我们要么把 broker0 启动成功,才能恢复 A 主题 34 分区。由于日志和索引文件的原因一直启动不起来,我们只需要将损坏的日志和索引文件删除并重启即可。但如果出现 34 分区的日志索引文件也损坏的情况下,就会丢失该分区下未消费的数据,原因如下:此时 34 分区的 leader 还处在 broker0 中,由于 broker0 挂掉了且 34 分区 isr 只有 leader,导致 34 分区不可用,在这种情况下,假设你将 broker0 中 leader 的数据清空,重启后 Kafka 依然会将 broker0 上的副本作为 leader,那么就需要以 leader 的偏移量为准,而这时 leader 的数据清空了,只能将 follower 的数据强行截断为 0,且不大于 leader 的偏移量。这似乎不太合理,这时候是不是可以提供一个操作的可能:在分区不可用时,用户可以手动设置分区内任意一个副本作为 leader?后续集群的优化制定一个升级方案,将集群升级到 2.x 版本;每个节点的服务器将 systemd 的默认超时值为 600 秒,因为我发现运维在故障当天关闭 33 节点时长时间没反应,才会使用 kill -9 命令强制关闭。但据我了解关闭一个 Kafka 服务器时,Kafka 需要做很多相关工作,这个过程可能会存在相当一段时间,而 systemd 的默认超时值为 90 秒即可让进程停止,那相当于非正常退出了。将 broker 参数 unclean.leader.election.enable 设置为 true(确保分区可从非 ISR 中选举 leader);将 broker 参数 default.replication.factor 设置为 3(提高高可用,但会增大集群的存储压力,可后续讨论);将 broker 参数 min.insync.replicas 设置为 2(这么做可确保 ISR 同时有两个,但是这么做会造成性能损失,是否有必要?因为我们已经将 unclean.leader.election.enable 设置为 true 了);发送端发送 acks=1(确保发送时有一个副本是同步成功的,但这个是否有必要,因为可能会造成性能损失)。从源码中定位到问题的根源首先把导致 Kafka 进程退出的异常栈贴出来:注:以下源码基于 kafka 0.11.x 版本。我们直接从 index 文件损坏警告日志的位置开始:kafka.log.Log#loadSegmentFiles从前一篇文章中已经说到,Kafka 在启动的时候,会检查kafka是否为 cleanshutdown,判断依据为 ${log.dirs} 目录中是否存在 .kafka_cleanshutDown 的文件,如果非正常退出就没有这个文件,接着就需要 recover log 处理,在处理中会调用 。在 recover 前,会调用 sanityCheck() 方法用于检验每个 log sement 的 index 文件,确保索引文件的完整性 ,如果发现索引文件损坏,删除并调用 recoverSegment() 方法进行索引文件的重构,最终会调用 recover() 方法:kafka.log.LogSegment#recover源码中相关变量说明:log:当前日志 Segment 文件的对象;batchs:一个 log segment 的消息压缩批次;batch:消息压缩批次;indexIntervalBytes:该参数决定了索引文件稀疏间隔打底有多大,由 broker 端参数 log.index.interval.bytes 决定,默认值为 4 KB,即表示当前分区 log 文件写入了 4 KB 数据后才会在索引文件中增加一个索引项(entry);validBytes:当前消息批次在 log 文件中的物理地址。知道相关参数的含义之后,那么这段代码的也就容易解读了:循环读取 log 文件中的消息批次,并读取消息批次中的 baseOffset 以及在 log 文件中物理地址,将其追加到索引文件中,追加的间隔为 indexIntervalBytes 大小。我们再来解读下消息批次中的 baseOffset:我们知道一批消息中,有最开头的消息和末尾消息,所以一个消息批次中,分别有 baseOffset 和 lastOffset,源码注释如下:其中最关键的描述是:它可以是也可以不是第一条记录的偏移量。kafka.log.OffsetIndex#append以上是追加索引块核心方法,在这里可以看到 Kafka 异常栈的详细信息,Kafka 进程也就是在这里被异常中断退出的(这里吐槽一下,为什么一个分区有损坏,要整个 broker 挂掉?宁错过,不放过?就不能标记该分区不能用,然后让 broker 正常启动以提供服务给其他分区吗?建议 Kafka 在日志恢复期间加强异常处理,不知道后续版本有没有优化,后面等我拿 2.x 版本源码分析一波),退出的条件是:_entries == 0 || offset > _lastOffset = false也就是说,假设索引文件中的索引条目为 0,说明索引文件内容为空,那么直接可以追加索引,而如果索引文件中有索引条目了,需要消息批次中的 baseOffset 大于索引文件最后一个条目中的位移,因为索引文件是递增的,因此不允许比最后一个条目的索引还小的消息位移。现在也就很好理解了,产生这个异常报错的根本原因,是因为后面的消息批次中,有位移比最后索引位移还要小(或者等于)。前面也说过了,消息批次中的 baseOffset 不一定是第一条记录的偏移量,那么问题是不是出在这里?我的理解是这里有可能会造成两个消息批次获取到的 baseOffset 有相交的值?对此我并没有继续研究下去了,但我确定的是,在 kafka 2.x 版本中,append() 方法中的 offset 已经改成 消息批次中的 lastOffset 了:这里我也需要吐槽一下,如果出现这个 bug,意味着这个问题除非是将这些故障的日志文件和索引文件删除,否则该节点永远启动不了,这也太暴力了吧?我花了非常多时间去专门看了很多相关 issue,目前还没看到有解决这个问题的方案?或者我需要继续寻找?我把相关 issue 贴出来:https://issues.apache.org/jira/browse/KAFKA-1211https://issues.apache.org/jira/browse/KAFKA-3919https://issues.apache.org/jira/browse/KAFKA-3955严重建议各位尽快把 Kafka 版本升级到 2.x 版本,旧版本太多问题了,后面我着重研究 2.x 版本的源码。下面我从日志文件结构中继续分析。从日志文件结构中看到问题的本质我们用 Kafka 提供的 DumpLogSegments 工具打开 log 和 index 文件:$ ~/kafka_2.1x-0.11.x/bin/kafka-run-class.sh kafka.tools.DumpLogSegments --files {log_path}/secxxx-2/00000000000110325000.log > secxxx.log $ ~/kafka_2.1x-0.11.x/bin/kafka-run-class.sh kafka.tools.DumpLogSegments --files {log_path}/secxxx-2/00000000000110325000.index > secxxx-index.log用 less -Nm 命令查看,log 和 index 对比:如上图所示,index最后记录的 offset = 110756715,positioin=182484660,与异常栈显示的一样,说明在进行追加下一个索引块的时候,发现下一个索引块的 offset 索引不大于最后一个索引块的 offset,因此不允许追加,报异常并退出进程,那么问题就出现在下一个消息批次的 baseOffset,根据 log.index.interval.bytes 默认值大小为 4 KB(4096),而追加的条件前面也说了,需要大于 log.index.interval.bytes,因此我们 DumpLogSegments 工具查询:从 dump 信息中可知,在 positioin=182484660 往后的几个消息批次中,它们的大小加起来大于 4096 的消息批次的 offset=110756804,postion=182488996,它的 baseOffset 很可能就是 110756715,与索引文件最后一个索引块的 Offset 相同,因此出现错误。接着我们继续用 DumpLogSegments 工具查看消息批次内容:我们先查看 offset = 110756715,positioin=182484660 的消息块详情:接着寻找 offset = 110756715,的消息批次块:终于找到你了,跟我预测的一样!postion=182488996,在将该消息批次追加到索引文件中,发生 offset 混乱了。如果还是没找到官方的处理方案,就只能删除这些错误日志文件和索引文件,然后重启节点?非常遗憾,我在查看了相关的 issue 之后,貌似还没看到官方的解决办法,所幸的是该集群是日志集群,数据丢失也没有太大问题。我也尝试发送邮件给 Kafka 维护者,期待大佬的回应:不过呢,0.11.x 版本属于很旧的版本了,因此,升级 Kafka 版本才是长久之计啊!我已经迫不及待地想撸 kafka 源码了!经过以上问题分析与排查之后,我专门对分区不可用进行故障重现,并给出我的一些骚操作来尽量减少数据的丢失。故障重现下面我用一个例子重现现分区不可用且 leader 副本被损坏的例子:使用 unclean.leader.election.enable = false 参数启动 broker0;使用 unclean.leader.election.enable = false 参数启动 broker1;创建 topic-1,partition=1,replica-factor=2;将消息写入 topic-1;此时,两个 broker 上的副本都处于 ISR 中,broker0 的副本为 leader 副本;停止 broker1,此时 topic-1 的 leader 依然时 broker0 的副本,而 broker1 的副本从 ISR 中剔除;停止 broker0,并且删除 broker0 上的日志数据;重启 broker1,topic-1 尝试连接 leader 副本,但此时 broker0 已经停止运行,此时分区处于不可用状态,无法写入消息;恢复 broker0,broker0 上的副本恢复 leader 职位,此时 broker1 尝试加入 ISR,但此时由于 leader 的数据被清除,即偏移量为 0,此时 broker1 的副本需要截断日志,保持偏移量不大于 leader 副本,此时分区的数据全部丢失。向 Kafka 官方提的建议在遇到分区不可用时,是否可以提供一个选项,让用户可以手动设置分区内任意一个副本作为 leader?因为集群一旦设置了 unclean.leader.election.enable = false,就无法选举 ISR 以外的副本作为 leader,在极端情况下仅剩 leader 副本还在 ISR 中,此时 leader 所在的 broker 宕机了,那如果此时 broker 数据发生损坏这么办?在这种情况下,能不能让用户自己选择 leader 副本呢?尽管这么做也是会有数据丢失,但相比整个分区的数据都丢失而言,情况还是会好很多的。如何尽量减少数据丢失?首先你得有一个不可用的分区(并且该分区 leader 副本数据已损失),如果是测试,可以以上故障重现 1-8 步骤实现一个不可用的分区(需要增加一个 broker):此时 leader 副本在 broker0,但已经挂了,且分区不可用,此时 broker2 的副本由于掉出 ISR ,不可选为 leader,且 leader 副本已损坏清除,如果此时重启 broker0,follower 副本会进行日志截断,将会丢失该分区所有数据。经过一系列的测试与实验,我总结出了以下骚操作,可以强行把 broker2 的副本选为 leader,尽量减少数据丢失:1、使用 kafka-reassign-partitions.sh 脚本对该主题进行分区重分配,当然你也可以使用 kafka-manager 控制台对该主题进行分区重分配,重分配之后如下:此时 preferred leader 已经改成 broker2 所在的副本了,但此时的 leader 依然还是 broker0 的副本。需要注意的是,分区重分配之后的 preferred leader 一定要之前那个踢出 ISR 的副本,而不是分区重分配新生成的副本。因为新生成的副本偏移量为 0,如果自动重分配不满足,那么需要编写 json 文件,手动更改分配策略。2、进入 zk,查看分区状态并修改它的内容:修改 node 内容,强行将 leader 改成 2(与重分配之后的 preferred leader 一样),并且将 leader_epoch 加 1 处理,同时 ISR 列表改成 leader,改完如下:此时,kafka-manager 控制台会显示成这样:但此时依然不生效,记住这时需要重启 broker 0。3、重启 broker0,发现分区的 lastOffset 已经变成了 broker2 的副本的 lastOffset:成功挽回了 46502 条消息数据,尽管依然丢失了 76053 - 46502 = 29551 条消息数据,但相比全部丢失相对好吧!以上方法的原理其实很简单,就是强行把 Kafka 认定的 leader 副本改成自己想要设置的副本,然后 lastOffset 就会以我们手动设置的副本 lastOffset 为基准了。
最近在公司的数据同步项目(以下简称 ZDTP)中,需要使用到分布式调度数据同步执行单元,目前使用的方案是将数据同步执行单元打包成镜像,使用 K8s 进行调度。在 ZDTP 中,数据同步的动作可抽象成一个执行单元(以下称为 worker),类似于线程执行单元 Runnable ,Runnable 放入一个队列中等待线程的调度执行,执行完 Runnable 即完成了它的使命。当用户在 ZDTP 控制台中创建同步任务并启动任务时,会根据同步任务的配置,产生若干个用于该任务的 worker,假设这些 worker 都在本地执行,可以将其包装成一个 Runnable,然后创建一个线程执行,如下图表示:但是在单机模式下,就会遇到性能瓶颈,此时就需要分布式调度,将 worker 调度到其他机器执行:问题是我们如何将 worker 更好地调度到其它机器中执行呢?Worker 部署方式调研1、基于虚拟机部署 WorkerWorker 在提前创建好的虚拟机中运行, 任务启动时需要根据当前 Worker 负载情况进行选择空闲的 Worker,相当于 Worker 是以 Agent 的形式运行,如下图表示:伴随而来的缺点主要有以下几点:Worker Agent 数量相对固定,虚拟机创建成本高,扩/缩容麻烦;任务运行情况依赖 zk 监听机制,如果某个任务在运行中挂掉了,需要自行实现故障转移与自动重启机制,增加开发周期;Worker Agent 负载获取逻辑需要项目实现,精确获取负载信息实现难度大,增加开发周期。2、基于 K8s 部署 Worker将 Worker 打包成 Docker 镜像,使用 K8s 对 worker 容器进行调度作业,并且一个 Worker 只运行一个任务,如下图表示:使用 K8s 的优点如下:使用 K8s 集群调度的 Worker 容器具备故障恢复功能,只要将 Pod 的重启策略设置为 restartPolicy=Always,无论 Worker 容器在运行过程中发生什么异常,K8s 都会自动尝试重启 Worker 容器,大大减少了运维成本,提高了数据同步的高可用性;自动实现负载,比如当某个节点负载高,就会将 Worker 容器调度到负载低的节点上,更重要的是,某个节点宕机,其上的工作负载会被 K8s 自动将其转移到其它节点上面;Worker 的生命周期完全交由 K8s 集群管理,只需调用相关接口即可清楚 Worker 运行情况,大大减少开发周期。K8s 容器调度方案调研K8s 集群的调度对象是 Pod,调度方式有多种,这里主要介绍以下几种方式:1、Deployment(全自动调度)在讲 Deployment 前,先来说下 Replica Set,它是 K8s 一个非常重要的概念,它是在 Pod 这个抽象上更为上层的一个抽象,一般大家用 Deployment 这个抽象来做应用的真正的管理,而 Pod 是组成 Deployment 最小的单元。它可以定义某种 Pod(比如包装了 ZDTP Worker 容器的 Pod)在任意时刻都保持符合 Replica Set 设定的预期值, 比如 Replica Set 可预期设定 Pod 副本数,当 k8s 集群定期巡检发现某种 Pod 的副本数少于 Replica Set 设定的预期值,它就会按照 Replica Set 设定的 Pod 模版创建 Pod 实例,使得 Pod 的数量维持在预期值,也是通过 Replica Set 的特性,实现了集群的高可用性,同时减少了运维成本。Deployment 内部使用了 Replica Set 来实现,他们之间高度相似,也可以将 Deployment 看作是 Replica Set 的升级版本。2、Job(批处理调度)我们可以通过 k8s Job 资源对象定义并启动一个批处理任务,并行或者串行处理一批工作项(Work item),处理完成后任务就结束。1)Job Template Expansion 模式根据 ZDTP Worker 运行方式,我们可以使用一个 Job 对像对应一个 Worker,有多少个 worker 就创建多少个 Job,除非 Pod 异常,才会重启该 Pod,正常执行完后 Job 就退出,如下图表示:2)Queue with Pod Per Work Item 模式这种模式将客户端生成的 worker 存放在一个队列中,然后只会创建一个 job 去消费队列中的 worker item,通过设置 parallelism 参数可以同时启动多少个 worker Pod 同时处理 worker,值得一体的是,这种模式下的 Worker 处理程序逻辑只会从队列拉取 worker 处理,处理完就立即退出,completions 参数用于控制正常退出的 Pod 数量,当退出的 Pod 数量达到了 completions 后,Job 结束,即 completions 参数可以控制 Job 的处理 Worker 的数量。如下图所示:3)Queue with Variable Pod Count 模式这种调度模式看起来跟 Queue with Pod Per Work Item 模式差不多,其实不然,Queue with Variable Pod Count 模式的 Job 只要有一个 Pod 正常退出,即说明 Job 已经处理完数据,处于终止状态了,因为它的每个 Pod 都有查询队列是否还有 worker 的逻辑,一旦发现队列中没有了 worker,Pod 正常退出,因此 Queue with Variable Pod Count 模式 completions 参数只能设置 1, parallelism 参数可以同时启动多少个 worker Pod 同时处理 worker。这种模式也要求队列能够让 Pod 感知是否还存在 worker,像 RocketMQ/Kafka 之类的消息中间件并不能做到,只会让客户端一直等待,因此这种模式不能选用 RocketMQ/Kafka,可以选择数据库或者 Redis 来实现。如下图所示:当然如果后面还有定时执行 Worker 的需求,使用 K8s 的 cronjob(定时任务调度)是一个非常好的选择。3、Pod(默认调度)直接通过 kind=pod 的方式启动容器,这种方式不能设置容器的运行实例数,即 replicas = 1,通常生产应用集群都不会通过这个方式启动容器,因为这种方式启动容器不具备 Pod 自动扩缩容的特性。值得一提的是,即使你的 Pod 副本只有 1 个,官方也推荐使用 Replica Set 的方式进行部署。Pod 重启策略分析Pod 的重启策略包括 Always、onFailure、Never:Always:当容器失效时,k8s 自动重启该容器;onFailure:当容器终止运行时并且退出码不为 0 时,k8s 自动重启该容器;Never:不论容器运行状态如何,k8s 都不会重启该容器Deployment/Replica Set 必须设置为 Always(因为它们都需要保持 Pod 期待的副本数),而 Job 只能设置为 onFailure 和 Never,以确保容器执行完成后不再重启,直接 Pod 启动容器以上三个重启策略都可以设置。这里需要说明一点,如果使用 Job,情况可能稍微复杂些:1)Pod 重启策略 RestartPolicy=Never假设 Job 调度过程中 Pod 发生非正常退出,尽管此时容器不再重启,由于 Job 需要至少一个 Pod 执行完成(即 completions 最少等于 1),Job 才算完成。因此,虽然非正常退出的 Pod 不再重启,但 Job 会尝试重新启动一个 Pod 执行,直到 Pod 正常完成的数量为 completions。$ kubectl get pod --namespace zdtp-namespace NAME READY STATUS RESTARTS AGE zdtp-worker-hc6ld 0/1 ContainerCannotRun 0 64s zdtp-worker-hfblk 0/1 ContainerCannotRun 0 60s zdtp-worker-t9f6v 0/1 ContainerCreating 0 11s zdtp-worker-v2g7s 0/1 ContainerCannotRun 0 31s2)Pod 重启策略 RestartPolicy=onFailure当 RestartPolicy=onFailure,Pod 发生非正常退出时,Pod 会尝试重启,直到该 Pod 正常执行完成,此时 Job 就不会重新启动一个 Pod 执行了,如下:$ kubectl get pod --namespace zdtp-namespace NAME READY STATUS RESTARTS AGE zdtp-worker-5tbxw 0/1 CrashLoopBackOff 5 67s如何选择 K8s 调度策略?以上内容把 K8s 的调度方案与 Pod 的重启策略都研究了一番后,接下来就需要针对项目的调度需求选择合适的调度方式。1、增量同步 Worker增量同步 Worker 会一直同步下去,中途不停止,这意味着 Pod 的重启策略必须为 RestartPolicy=Always,那么这种方式只能选择 Deployment 调度或者直接创建 Pod 部署,但建议使用 Deployment,官方已经说明了即使 Pod 副本为 1,依然建议使用 Deployment 进行部署。2、 全量同步 Worker全量同步 Worker 在数据同步完就退出,看起来 Job 调度或者直接创建 Pod 部署都可以满足,但现阶段由于全量同步暂时没有记录同步进度,因此要求中途发生任何错误容器退出后都不能自动重启,目前的做法是当 Worker 执行过程中发生非正常退出时,需要用户自行删除已同步的资源,再手动启动 Worker 再次进行全量同步。因此,Job 目前还还不适合调度 Worker Pod,全量同步 Worker 现阶段只适合直接使用 Pod 进行部署,且需要设置 Pod 重启策略 RestartPolicy=Never。
集群管理(1)启动 broker$ bin/kafka-server-start.sh daemon <path>/server.properties(2)关闭 broker$ bin/kafka-server-stop.shtopic 管理kafka-topics.sh 脚本# 创建主题 $ bin/kafka-topics.sh --create --zookeeper localhost:2181 --partitions 64 --replication-factor 3 --topic test-topic --config xxxxx # 删除主题(delete.topic.enable=true) $ bin/kafka-topics.sh --delete --zookeeper localhost:2181 --topic test-topic # 查询主题列表 $ bin/kafka-topics.sh --zookeeper localhost:2181 --list # 查询主题详情 $ bin/kafka-topics.sh --zookeeper localhost:2181 --describe --topic test-topic # 修改主题 $ bin/kafka-topics.sh --alter --zookeeper localhost:2181 --partitions 64 --topic test-topic # ...consumer 相关管理(1)查询消费组kafka-consumer-groups.sh$ bin/kafka-consumer-groups.sh --bootstrap-server localhost:9200 --group test-group --describe(2)重设消费组位移$ bin/kafka-consumer-groups.sh --bootstrap-server localhost:9200 --group test-group --reset-offsets --topic test-topic --to-earliest --execute(3)删除消费组$ bin/kafka-consumer-groups.sh --bootstrap-server localhost:9200 --delete --group test-grouptopic 分区管理(1)preferred leader 选举$ bin/kafka-preferred-replica-election.sh --zookeeper localhost:2181 --path-to-json-file <path>/preferred-leader-plan.json # {"partitions":[{"topic":"test-topic","partition":0}]}(2)分区重分配# 生成分配策略 $ bin/kafka-reassign-partitions.sh --zookeeper localhost:2181 --topics-to-move-json-file topics-to-move.json --broker-list "5,6" --generate # 执行分配策略 $ bin/kafka-reassign-partitions.sh --zookeeper localhost:2181 --reassignment-json-file cluster-reassignment.json --execute # 验证分配 $ bin/kafka-reassign-partitions.sh --zookeeper localhost:2181 --reassignment-json-file cluster-reassignment.json --verify # 可通过编写分配策略,增加副本因子 略Kafka 常见脚本工具(1)kafka-console-producer.sh$ bin/kafka-console-producer.sh --broker-list localhost:9200 --topic test --request-required-acks all --timeout 3000 --message-send-max-retries 3(2)kafka-console-consumer.sh$ bin/kafka-console-consumer.sh --bootstrap-server localhost:9200 --topic test --from-beginning(3)生产者性能测试$ bin/kafka-producer-perf-test.sh --topic test-topic-5 --num-records 500000000000 --record-size 200 --throughput 200 --producer-props bootstrap.servers=localhost:9092,localhost:9093,localhost:9094 acks=-1(4)消费者性能测试$ bin/kafka-consumer-perf-test.sh --topic-list localhost:9200 --message-size 200 --messages 50000 --topic test-topic(5)查看消息元数据$ bin/kafka-run-class.sh kafka.tools.DumpLogSegments --files /dfs5/kafka/data/secLog-2/00000000000110325000.log --print-data-log --deep-iteration > secLog.log(6)获取 topic 当前消息数# 获取当前最大位移 $ bin/kafka-run-class.sh kafka.tools.GetOffsetShell --broker-list localhost:9200 --topic test --time -1 # 当前获取最早位移 $ bin/kafka-run-class.sh kafka.tools.GetOffsetShell --broker-list localhost:9200 --topic test --time -2 # 以上两个数相减,即可得出 topic 当前在集群的消息总数
我第一次接触容器编排调度工具是 Docker 自家的 Docker Swarm,主要解决当时公司内部业务项目部署繁琐的问题,我记得当时项目实现容器化之后,花在项目部署运维的时间大大减少了,当时觉得这玩意还挺新鲜的,原来自动化运维可以这么玩。后面由于工作原因,很久没碰过容器方面的知识了。最近在公司的数据同步项目中,需要使用到分布式调度数据同步执行单元,目前使用的方案是将数据同步执行单元打包成镜像,使用 K8s 进行调度,正好趁这个机会了解一下 K8s,下面我就用图解的形式将我所理解的 K8s 分享给大家。K8s 三大核心功能K8s 是一个轻便的和可扩展的开源平台,用于管理容器化应用和服务。通过 K8s 能够进行应用的自动化部署和扩缩容。K8s 是比容器更上一层的架构,它可以支持多种容器技术,比如我们熟悉的 Docker,K8s 定位是一个容器调度工具,它主要具备以下三大核心能力:1、自动调度k8s 将用户部署提交的容器放到 k8s 集群的任意一个节点中,k8s 可以根据容器所需要的资源大小,以及节点的负载情况来决定容器放在哪个节点上面。2、自动修复当 k8s 的健康检查机制发现某个节点出现问题,它会自动将该节点上的资源转移到其它节点上面完成自动恢复。3、横向自动扩缩容在 k8s 1.1+ 版本中,有一个功能叫 “ Horizontal Pod Autoscaler”,简称 “HPA”,意思是 Pod自动扩容,它可以预先定义 Pod 的负载指标,当达到预期设定的负载指标后,就会根据指标自动触发自动动态扩容/缩容行为。1)横向自动扩容节点从上面的图可以看出来,k8s 集群的节点有两个角色,分别为 Master 节点和 Node 节点,整个 K8s 集群Master 和 Node 节点关系如下图所示:1、Master 节点Master 节点也称为控制节点,每个 k8s 集群都有一个 Master 节点负责整个集群的管理控制,我们上面介绍的 k8s 三大能力都是经过 Master 节点发起的,Master 节点包含了以下几个组件:API Server:提供了 HTTP Rest 接口的服务进程,所有资源对象的增、删、改、查等操作的唯一入口;Controller Manager:k8s 集群所有资源对象的自动化控制中心;Scheduler:k8s 集群所有资源对象自动化调度控制中心;ETCD:k8s 集群注册服务发现中心,可以保存 k8s 集群中所有资源对象的数据。2、NodeNode 节点的作用是承接 Master 分配的工作负载,它主要有以下几个关键组件:kubelet:负责 Pod 对应容器的创建、启停等操作,与 Master 节点紧密协作;kube-porxy:实现 k8s 集群通信与负载均衡的组件。从图上可看出,在 Node 节点上面,还需要一个容器运行环境,如果使用 Docker 技术栈,则还需要在 Node 节点上面安装 Docker Engine,专门负责该节点容器管理工作。PodPod 是 k8s 最重要而且是最基本的一个资源对象,它的结构如下:从以上 Pod 的结构图可以看出,它其实是容器的一个上层包装结构,这也就是为什么 K8s 可以支持多种容器类型的原因,基于这方面,我理解 k8s 的定位就是一个编排与调度工具,而容器只是它调度的一个资源对象而已。Pod 可包含多个容器在里面,每个 Pod 至少会有一个 Pause 容器,其它用户定义的容器都共享该 Pause 容器,Pause 容器的主要作用是用于定义 Pod 的 ip 和 volume。Pod 在 k8s 集群中的位置如下图所示:LabelLabel 在 k8s 中是一个非常核心的概念,我们可以将 Label 指定到对应的资源对象中,例如 Node、Pod、Replica Set、Service 等,一个资源可以绑定任意个 Label,k8s 通过 Label 可实现多维度的资源分组管理,后续可通过 Label Selector 查询和筛选拥有某些 Label 的资源对象,例如创建一个 Pod,给定一个 Label,workerid=123,后续可通过 workerid=123 删除拥有该标签的 Pod 资源。Replica SetReplica Set 目的是为了定义一个期望的场景,比如定义某种 Pod 的副本数量在任意时刻都处于 Peplica Set 期望的值,假设 Replica Set 定义 Pod 的副本数目为:replicas=2,当该 Replica Set 提交给 Master 后,Master 会定期巡检该 Pod 在集群中的数目,如果发现该 Pod 挂掉了一个,Master 就会尝试依据 Replica Set 设置的 Pod 模版创建 Pod,以维持 Pod 的数量与 Replica Set 预期的 Pod 数量相同。通过 Replica Set,k8s 集群实现了用户应用的高可用性,而且大大减少了运维工作量。因此生产环境一般用 Deployment 或者 Replica Set 去控制 Pod 的生命周期和期望值,而不是直接单独创建 Pod。类似 Replica Set 的还有 Deployment,它的内部实现也是通过 Replica Set 实现的,可以说 Deployment 是 Replica Set 的升级版,它们之间的 yaml 配置文件格式大部分都相同。ServiceService 是 k8s 能够实现微服务集群的一个非常重要的概念,顾名思义,k8s 的 Service 就是我们平时所提及的微服务架构中的“微服务”,本文上面提及的 Pod、Replica Set 等都是为 Service 服务的资源, 如下图表示 Service、Pod、Replica Set 的关系:从上图可看出,Service 定义了一个服务访问的入口,客户端通过这个入口即可访问服务背后的应用集群实例,而 Service 则是通过 Label Selector 实现关联与对接的,Replica Set 保证服务集群资源始终处于期望值。以上只是一个微服务,通常来说一个应用项目会由多个不同业务能力而又彼此独立的微服务组成,多个微服务间组成了一个强大而又高可用的应用服务集群。NamespaceNamespace 顾名思义是命名空间的意思,在 k8s 中主要用于实现资源隔离的目的,用户可根据不同项目创建不同的 Namespace,通过 k8s 将资源分配到不同 Namespace 中,即可实现不同项目的资源隔离:
最近项目中有个需求,需要用到有界队列对访问请求量进行流量削峰请求,同时作为一个缓冲层对请求处理进行后续处理,Java 内置有界队列 ArrayBlockingQueue 可以满足这方面的需求,但是性能上并不满足,于是使用了 Disruptor,它是英国外汇交易公司 LMAX 开发的一个高性能队列,了解到它内部解决伪共享问题,今天就和大家一起学习缓存行与伪共享相关的知识。缓存行(Cache line)对计算机组成原理相对熟悉的小伙伴都知道,CPU 的速度比内存的速度高了几个数量级,为了 CPU 更快从内存中读取数据,设置了多级缓存机制,如下图所示:当 CPU 运算时,首先会从 L1 缓存查找所需要的数据,如果没有找到,再去 L2 缓存中去找,以此类推,直到从内存中获取数据,这也就意味着,越长的调用链,所耗费的执行时间也越长。那是不是可以从主内存拿数据的时候,顺便多拿一些呢?这样就可以避免频繁从主内存中获取数据了。聪明的计算机科学家已经想到了这个法子,这就是缓存行的由来。缓存是由多个缓存行组成的,而每个缓存行大小通常来说,大小为 64 字节,并且每个缓存行有效地引用主内存中的一块儿地址,CPU 每次从主内存中获取数据时,会将相邻的数据也一同拉取到缓存行中,这样当 CPU 执行运算时,就大大减少了与主内存的交互。下面我用一个例子让大家体会一下用缓存行和不用缓存行在性能上的差异:// 以下源码例子来源:https://tech.meituan.com/2016/11/18/disruptor.html public class CacheLineEffect { //考虑一般缓存行大小是64字节,一个 long 类型占8字节 static long[][] arr; public static void main(String[] args) { int size = 1024 * 1024; arr = new long[size][]; for (int i = 0; i < size; i++) { arr[i] = new long[8]; for (int j = 0; j < 8; j++) { arr[i][j] = 0L; } } long sum = 0L; long marked = System.currentTimeMillis(); for (int i = 0; i < size; i++) { for (int j = 0; j < 8; j++) { sum = arr[i][j]; } } System.out.println("[cache line]Loop times:" + (System.currentTimeMillis() - marked) + "ms"); marked = System.currentTimeMillis(); for (int i = 0; i < 8; i += 1) { for (int j = 0; j < size; j++) { sum = arr[j][i]; } } System.out.println("[no cache line]Loop times:" + (System.currentTimeMillis() - marked) + "ms"); } }我使用的测试运行环境配置如下:运行后结果如下:可以看到,使用缓存行比没有使用缓存行的性能提升了将近 4 倍。伪共享问题当 CPU 执行完后,还需要将数据回写到内存上,以便于别的线程可以从主内存中获取最新的数据。假设两个线程都加载了相同的 Cache line 数据,会产生什么样的影响呢?下面我用一张图解释:数据 A、B、C 被加载到同一个 Cache line,假设线程 1 在 core1 中修改 A,线程 2 在 core2 中修改 B。线程 1 首先对 A 进行修改,这时 core1 会告知其它 CPU 核,当前引用同一地址的 Cache line 已经无效,随后 core2 发起修改 B,会导致 core1 将数据回写到主内存中,core2 这时会重新从主内存中读取该 Cache line 数据。可见,如果同一个 Cache line 的内容被多个线程读取,就会产生相互竞争,频繁回写主内存,降低了性能。如何解决伪共享问题要解决伪共享这个问题最简单的做法就是将线程间共享元素分开到不同的 Cache line 中,这种做法叫用空间换取时间,具体做法如下:public final static class ValuePadding { // 前置填充对象 protected long p1, p2, p3, p4, p5, p6, p7; // value 值 protected volatile long value = 0L; // 后置填充对象 protected long p9, p10, p11, p12, p13, p14, p15; }JDK1.8 有专门的注解 @Contended 来避免伪共享,为了更加直观,我使用了对象填充的方法,其中 protected long p1, p2, p3, p4, p5, p6, p7作为前置填充对象,protected long p9, p10, p11, p12, p13, p14, p15作为后置填充对象,这样任意线程访问 ValuePadding 时,value 都处于不同的 Cache line 中,不会产生伪共享问题。下面的例子用来演示伪共享与解决伪共享后的性能差异:public class MyFalseSharing { public static void main(String[] args) throws InterruptedException { for (int i = 1; i < 10; i++) { System.gc(); final long start = System.currentTimeMillis(); runTest(Type.PADDING, i); System.out.println("[PADDING]Thread num " + i + " duration = " + (System.currentTimeMillis() - start)); } for (int i = 1; i < 10; i++) { System.gc(); final long start = System.currentTimeMillis(); runTest(Type.NO_PADDING, i); System.out.println("[NO_PADDING] Thread num " + i + " duration = " + (System.currentTimeMillis() - start)); } } private static void runTest(Type type, int NUM_THREADS) throws InterruptedException { Thread[] threads = new Thread[NUM_THREADS]; switch (type) { case PADDING: DataPadding.longs = new ValuePadding[NUM_THREADS]; for (int i = 0; i < DataPadding.longs.length; i++) { DataPadding.longs[i] = new ValuePadding(); } break; case NO_PADDING: Data.longs = new ValueNoPadding[NUM_THREADS]; for (int i = 0; i < Data.longs.length; i++) { Data.longs[i] = new ValueNoPadding(); } break; } for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(new FalseSharing(type, i)); } for (Thread t : threads) { t.start(); } for (Thread t : threads) { t.join(); } } // 线程执行单元 static class FalseSharing implements Runnable { public final static long ITERATIONS = 500L * 1000L * 100L; private int arrayIndex; private Type type; public FalseSharing(Type type, final int arrayIndex) { this.arrayIndex = arrayIndex; this.type = type; } public void run() { long i = ITERATIONS + 1; // 读取共享变量中指定的下标对象,并对其value变量不断修改 // 由于每次读取数据都会写入缓存行,如果线程间有共享的缓存行数据,就会导致伪共享问题发生 // 如果对象已填充,那么线程每次读取到缓存行中的对象就不会产生伪共享问题 switch (type) { case NO_PADDING: while (0 != --i) { Data.longs[arrayIndex].value = 0L; } break; case PADDING: while (0 != --i) { DataPadding.longs[arrayIndex].value = 0L; } break; } } } // 线程间贡献的数据 public final static class Data { public static ValueNoPadding[] longs; } public final static class DataPadding { public static ValuePadding[] longs; } // 使用填充对象 public final static class ValuePadding { // 前置填充对象 protected long p1, p2, p3, p4, p5, p6; // value 值 protected volatile long value = 0L; // 后置填充对象 protected long p9, p10, p11, p12, p13, p14, p15; } // 不填充对象 // @sun.misc.Contended public final static class ValueNoPadding { protected volatile long value = 0L; } enum Type { NO_PADDING, PADDING } }运行程序,测试结果如下:可见,当有多个线程同时操作同一个 Cache line 的数据时,伪共享问题会影响 CPU 性能。
最近有些朋友问到 Kafka 消费者消费相关的问题,如下:以上问题看出来这位朋友刚接触 Kafka,我们都知道 Kafka 相对 RocketMQ 来说,消费端是非常 “原生” 的,不像 RocketMQ 将消费线程模型都封装好,用户不用关注内部消费细节。Kafka 的消费类 KafkaConsumer 是非线程安全的,意味着无法在多个线程中共享 KafkaConsumer 对象,因此创建 Kafka 消费对象时,需要用户自行实现消费线程模型,常见的消费线程模型如下:1、每个线程维护一个 KafkaConsumer从消费消费模型可看出每个 KafkaConsumer 会负责固定的分区,因此无法提升单个分区的消费能力,如果一个主题分区数量很多,只能通过增加 KafkaConsumer 实例提高消费能力,这样一来线程数量过多,导致项目 Socket 连接开销巨大。2、单 KafkaConsumer 实例 + 多 worker 线程当 KafkaConsumer 实例与消息消费逻辑解耦后,我们不需要创建多个 KafkaConsumer 实例就可进行多线程消费,还可根据消费的负载情况动态调整 worker 线程,具有很强的独立扩展性,在公司内部使用的多线程消费模型就是用的单 KafkaConsumer 实例 + 多 worker 线程模型。中通消息服务运维平台(ZMS)使用的 Kafka 消费线程模型是第二种:单 KafkaConsumer 实例 + 多 worker 线程。以下我们来分析 ZMS 是如何实现单 KafkaConsumer 实例 + 多 worker 线程的消费线程模型的。com.zto.consumer.KafkaConsumerProxy#addUserDefinedPropertiesKafkaConsumerProxy 对 KafkaConsumer 进行了一层封装处理,是 ZMS 对外提供的 Kafka 消费对象,在创建一个 KafkaConsumerProxy 对象时,会进行以上属性赋值的具体操作,其中会根据用户配置进行消费线程的设置,从图中可看出,是否顺序消费对创建的线程池也是不一样的,ZMS 为什么会这么做呢?单 KafkaConsumer 实例 + 多 worker 线程消费线程模型,由于消费逻辑是利用多线程进行消费的,因此并不能保证其消息的消费顺序,如果我们需要在 Kafka 中实现顺序消费,那么需要保证同一类消息放入同一个线程当中,我用如下图表示:但需要注意的是,以上仅仅是保证正常情况下能够实现顺序消费,如果期间出现重平衡等异常情况,就会导致消费顺序被打乱,不过本身像 RocketMQ 一样是不能保证严格的顺序消费,对于能容忍消息短暂乱序的业务来说,这是一个不错的实现方式。com.zto.consumer.KafkaConsumerProxy#register以上,ZMS 每注册一个 KafkaConsumerProxy,都会使用新的线程去处消费 KafkaConsumer,前面也说过了 KafkaConsumer 是非线程安全的。com.zto.consumer.KafkaConsumerProxy#submitRecords以上是 ZMS 实现多线程消费逻辑的核心,ZMS 会对用消息分区和线程池列表缓存进行取模,从而使得相同分区的消息会被分配到相同线程池中执行,对于顺序消费来说至关重要,前面我也说了,当用户配置了顺序消费时,每个线程池只会分配一个线程,如果相同分区的消息分配到同一个线程池中执行,也就意味着相同分区的消息会串行执行,实现消息消费的顺序性。以上就是 ZMS Kafka 消费线程模型的简单分析。最后附上 ZMS 的 GitHub 地址:https://github.com/ZTO-Express/zms欢迎大家提出宝贵意见。
最近因为全链路压测项目需要对用户自定义线程池 Bean 进行适配工作,我们知道全链路压测的核心思想是对流量压测进行标记,因此我们需要给压测的流量请求进行打标,并在链路中进行传递,那么问题来了,如果项目中使用了多线程处理业务,就会造成父子线程间无法传递压测打标数据,不过可以利用阿里开源的 ttl 解决这个问题。全链路压测项目的宗旨就是不让用户感知这个项目的存在,因此我们不可能让用户去对其线程池进行改造的,我们需要主动去适配用户自定义的线程池。在适配过程的过程中无非就是将线程池替换成 ttl 去解决,可通过代理或者替换 Bean 的方式实现,这方面不是本文的内容,本文主要是深入 Spring 异步实现的原理,让大家对 Spring 异步编程不再陌生!运行原理分析过一遍源码分析,才能知道其中的一些细节原理,这也是不可避免的过程,虽然我也不想在文章中贴过多的源码,但如果不从源码中得出原因,很可能你会知其然不知其所以然。下面就尽量跟着源码走一遍它的运行机制是怎么样的,我把我自己的理解也会尽量详细地描述出来,在这里我会将其关联的源码贴出来分析,这些源码都有其相互关联性,可能你看到后面还会回来再看一遍。注册通知器过程开启 Spring 异步编程之需要一个注解即可:@EnableAsyncSpringboot 中有非常多 @Enable* 的注解,其目的是显式开启某一个功能特性,这也是一个非常典型的编程模型。@EnableAsync 注解注入了一个 AsyncConfigurationSelector 类,这个类目的就是为了注入 ProxyAsyncConfiguration 自动配置类,它的父类 AbstractAsyncConfiguration 做了件事情:org.springframework.scheduling.annotation.AbstractAsyncConfiguration#setConfigurers我们可以实现 AsyncConfigurer 接口的方式去自定义一个线程池 Bean,这个后面会会讲到,源码所示,这里目的是为了这个 bean,并将其定义的线程池对象和异常处理对象保存到 AsyncConfiguration 中,用于创建 AsyncAnnotationBeanPostProcessor 。这两个对象后面源码分析会再次遇上。而这个配置类就是为了注册一个名为 AsyncAnnotationBeanPostProcessor 的 bean,如其名,它是一个 BeanPostProcessor 处理器,它的类继承结构如下所示:从类继承结构可以看出,AsyncAnnotationBeanPostProcessor 实现了 BeanPostProcessor 和 BeanFactoryAware,因此 AsyncAnnotationBeanPostProcessor 会在 setBeanFactory 方法中做了 Spring 异步编程中最为重要的一步,创建一个针对 @Async 注解的通知器 AsyncAnnotationAdvisor(叫做切面貌似也可以),这个通知器主要用于拦截被 @Async 注解的方法。同时,bean 实例初始化过程会被 AsyncAnnotationBeanPostProcessor 拦截处理,处理过程会将符合条件的 bean 注册 AsyncAnnotationAdvisor :org.springframework.aop.framework.AbstractAdvisingBeanPostProcessor#postProcessAfterInitialization创建通知器过程接下来我们就分析 AsyncAnnotationAdvisor 是如何创建的。AsyncAnnotationAdvisor 实现了 PointcutAdvisor 接口,因此需要同时实现 getPointcut 和 getAdvice 方法,而这两个方法的实际内容有以上红框创建实现。到这里我们已经知道,Spring 的异步实现原理,是利用 Spring AOP 切面编程实现的,通过 BeanPostProcessor 拦截处理符合条件的 bean,并将切面织入,实现切面增强处理。Spring AOP 编程核心概念:Advice:通知,切面的一种实现,可以完成简单的织入功能。通知定义了增强代码切入到目标代码的时间点,是目标方法执行之前执行,还是执行之后执行等。切入点定义切入的位置,通知定义切入的时间;Pointcut:切点,切入点指切面具体织入的方法;Advisor:切面的另一种实现,能够将通知以更为复杂的方式织入到目标对象中,是将通知包装为更复杂切面的装配器。因此我们需要创建一个切面和切入点:buildAdvice:buildAdvice 方法可知,切面是一个 AnnotationAsyncExecutionInterceptor 类,该类实现了 MethodInterceptor 接口,其 invoke 方法即为拦截处理的核心源码,后面会进行详细分析。buildPointcut:从 AsyncAnnotationAdvisor 构造器中可以看出,buildPointcut 方法目的就是为了创建 @Async 注解的切入点。通知器拦截处理过程前面我们已经知道,拦截切面是一个 AnnotationAsyncExecutionInterceptor 类,我们直接定位到 invoke 方法一探究竟:org.springframework.aop.interceptor.AsyncExecutionInterceptor#invoke拦截处理的核心逻辑就是这么简单,也没啥好分析的,无非就是匹配方法指定的线程池,接着构建执行单元 Callable,最后调用 doSubmit 方法执行。如何匹配线程池?重点在于如何匹配线程池,这也是后面实战分析的重点内容,因此我们需要在这里详细分析匹配线程池的一些策略细节。org.springframework.aop.interceptor.AsyncExecutionAspectSupport#determineAsyncExecutorgetExecutorQualifier 方法目的是获取 @Async 注解上的 value 值,value 值即线程池 Bean 的名称,如果获取到的 targetExecutor 不是 Spring 类型的线程池,则使用 TaskExecutorAdapter 进行适配,这也是为什么我们直接创建 Executor 类型的线程池 Spring 也是支持的原因。从以上源码逻辑可看出如果我们使用 @Async 注解时 value 值为空,Spring 就会使用 defaultExecutor ,defaultExecutor 是什么时候赋值的呢?上面内容已经有提及,在 buildAdvice 方法创建 AnnotationAsyncExecutionInterceptor 时 调用了其 configure 方法,如下:org.springframework.aop.interceptor.AsyncExecutionAspectSupport#configure原来当 defaultExecutor 和 exceptionHandler 是当初从 ProxyAsyncConfiguration 中获取用户自定义的 AsyncConfigurer 实现类而来的,那么如果 defaultExecutor 不存在怎么办?从源码可看出,defaultExecutor 其实是一个 SingletonSupplier 类型,如果调用 get 方法不存在,则使用默认值,默认值为:() -> getDefaultExecutor(this.beanFactory);org.springframework.aop.interceptor.AsyncExecutionAspectSupport#getDefaultExecutor注意第一个红框的注释,此时 Spring 寻找默认的线程池 Bean 为指定 Spring 的 TaskExecutor 类型,并非 Executor 类型,如果 Bean 容器中没有找到 TaskExecutor 类型的 Bean,则继续寻找默认为以下名称的 Bean:public static final String DEFAULT_TASK_EXECUTOR_BEAN_NAME = "taskExecutor";那么如果都没有找到怎么办呢?在这个方法直接返回 null 了,AsyncExecutionInterceptor 类覆写了 这个方法:org.springframework.aop.interceptor.AsyncExecutionInterceptor#getDefaultExecutor如果没有找到,则直接创建一个 SimpleAsyncTaskExecutor 类作为 @Async 注解底层使用的线程池。从匹配线程池源码得知,如果你创建的线程池 Bean 非TaskExecutor 类型并且没有使用实现 AsyncConfigurer 接口方式创建线程池,就需要主动指定线程池 Bean 名称,否则 Spring 会使用默认策略。总结利用 BeanPostProcessor 机制在 Bean 初始化过程中创建一个 AsyncAnnotationAdvisor 切面,并且符合条件的 Bean 生成代理对象并将 AsyncAnnotationAdvisor 切面添加到代理中。可以看出 Spring 的很多功能都是围绕着 Spring IOC 和 AOP 实现的。Spring 默认线程池策略分析有时候为了方便,我们不自定义创建线程池 bean 时,Spring 默认会为我们提供什么样的线程池呢?我们先来看下结果:很奇怪,明明我们都没有在项目中自定义线程池 Bean,按照以上源码的分析结果来看,此时 Spring 选择的是 SimpleAsyncTaskExecutor 才对,莫非是 super#getDefaultExecutor 方法找到了线程池 Bean?从以上截图确实是找到了,而且类型还是 ThreadPoolTaskExecutor 类型的,那可以推断出 Spring 一定是在某个地方创建了一个 ThreadPoolTaskExecutor 类型的 Bean。果然,在 spring-boot-autoconfigure 2.1.3.RELEASE 中,会在 org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration 中自动创建一个默认的 ThreadPoolTaskExecutor bean,getDefaultExecutor 方法会在容器中找到这个bean,并将其作为默认的 @Async 注解的执行线程池。这里我为什么要标注版本呢?因为某些低版本的 spring-boot-autoconfigure,是没有 TaskExecutionAutoConfiguration 的,此时 Spring 就会选择 SimpleAsyncTaskExecutor。org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration从以上源码可以看出,默认的线程池的参数还可以手动在 properties 中配置,这意味着不需要主动创建线程池的情况下,也可以通过 properties 配置文件更改线程池相关参数。创建线程池 Bean 的几种方式1、直接创建一个 Bean 的方式,这貌似是最多人使用的方式,可以创建多个线程池 Bean,使用时指定线程池 Bean 名称:@Bean("myTaskExecutor_1") public Executor getThreadPoolTaskExecutor1() { final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // set ... return executor; } @Bean("myTaskExecutor_2") public Executor getThreadPoolTaskExecutor2() { final ThreadPoolExecutor executor = new ThreadPoolExecutor(); // set ... return executor; }2、实现 AsyncConfigurer 接口方式:@Component public class AsyncConfigurerTest implements AsyncConfigurer { private static final Logger LOGGER = LoggerFactory.getLogger(AsyncConfigurerTest.class); @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // set ... return executor; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> { LOGGER.info("Exception message:{}", ex.getMessage(), ex); LOGGER.info("Method name:{}", method.getName()); for (Object param : params) { LOGGER.info("Parameter value:{}", param); } }; } }这种方式可以方便定义异常处理的逻辑,不过从源码分析可以看出,项目中只能存在一个 AsyncConfigurer 的配置,意味着我们只能通过 AsyncConfigurer 配置一个自定义的线程池 Bean。3、利用 spring-boot-autoconfigure 在 properties 配置线程池参数:前面讲到了 Spring 默认线程池策略,这里利用 spring-boot-autoconfigure 默认创建一个 ThreadPoolTaskExecutor,通过 properties 自定义线程池相关参数。这个方式的缺点就是类型固定为 ThreadPoolTaskExecutor,且只能有一个线程池。注:以上所有原理分析与实战结果都是基于 Spring 5.1.5.RELEASE 版本。
前段时间接到用户要求,调整某个主题在 Kafka 集群消息大小为 4M。根据 Kafka 消息大小规则设定,生产端自行将 max.request.size 调整为 4M 大小,Kafka 集群为该主题设置主题级别参数 max.message.bytes 的大小为 4M。以上是针对 Kafka 2.2.x 版本的设置,需要注意的是,在某些旧版本当中,还需要调整相关关联参数,比如 replica.fetch.max.bytes 等。从上面例子可看出,Kafka 消息大小的设置还是挺复杂的一件事,而且还分版本,需要注意的参数巨多,而且每个都长得差不多,不但分版本,还需要注意生产端、broker、消费端的设置,而且还要区分 broker 级别还是 topic 级别的设置,而且还需要清楚知道每个配置的含义。本文通过相关参数的解析说明,再结合实战测试,帮助你快速搞明白这些参数的含义以及规则。brokerbroker 关于消息体大小相关的参数主要有 message.max.bytes、replica.fetch.min.bytes、replica.fetch.max.bytes、replica.fetch.response.max.bytes1、message.max.bytesKafka 允许的最大 record batch size,什么是 record batch size ?简单来说就是 Kafka 的消息集合批次,一个批次当中会包含多条消息,生产者中有个参数 batch.size,指的是生产者可以进行消息批次发送,提高吞吐量,以下是 message.max.bytes 参数作用的源码:kafka.log.Log#analyzeAndValidateRecords以上源码可以看出 message.max.bytes 并不是限制消息体大小的,而是限制一个批次的消息大小,所以我们需要注意生产端对于 batch.size 的参数设置需要小于 message.max.bytes。以下附带 Kafka 官方解释:The largest record batch size allowed by Kafka. If this is increased and there are consumers older than 0.10.2, the consumers' fetch size must also be increased so that the they can fetch record batches this large.In the latest message format version, records are always grouped into batches for efficiency. In previous message format versions, uncompressed records are not grouped into batches and this limit only applies to a single record in that case.This can be set per topic with the topic level max.message.bytesconfig.翻译如下:Kafka 允许的最大记录批量。如果增加此数量,并且有一些消费者的年龄大于 0.10.2,则消费者的获取大小也必须增加,以便他们可以获取如此大的记录批次。在最新的消息格式版本中,为了提高效率,始终将记录分组。在以前的消息格式版本中,未压缩的记录不会分组,并且在这种情况下,此限制仅适用于单个记录。可以使用主题级别 “max.message.bytes” 配置针对每个主题进行设置。2、replica.fetch.min.bytes、replica.fetch.max.bytes、replica.fetch.response.max.byteskafka 的分区如果是多副本,那么 follower 副本就会源源不断地从 leader 副本拉取消息进行复制,这里也会有相关参数对消息大小进行设置,其中 replica.fetch.max.bytes 是限制拉取分区中消息的大小,在 0.8.2 以前的版本中,如果 replica.fetch.max.bytes < message.max.bytes,就会造成 follower 副本复制不了消息。不过在后面的版本当中,已经对这个问题进行了修复。replica.fetch.max.bytes 参见 2.2.x 版本的官方解释:The number of bytes of messages to attempt to fetch for each partition. This is not an absolute maximum, if the first record batch in the first non-empty partition of the fetch is larger than this value, the record batch will still be returned to ensure that progress can be made. The maximum record batch size accepted by the broker is defined via message.max.bytes (broker config) or max.message.bytes (topic config).翻译如下:尝试为每个分区获取的消息的字节数。这不是绝对最大值,如果获取的第一个非空分区中的第一个记录批处理大于此值,那么仍将返回记录批处理以确保进度。 代理接受的最大记录批处理大小是通过 message.max.bytes(代理配置)或 max.message.bytes(主题配置)定义的。replica.fetch.min.bytes、replica.fetch.response.max.bytes 同理。topic1、max.message.bytes该参数跟 message.max.bytes 参数的作用是一样的,只不过 max.message.bytes 是作用于某个 topic,而 message.max.bytes 是作用于全局。producer1、max.request.size该参数挺有意思的,看了 Kafka 生产端发送相关源码后,发现消息在 append 到 RecordAccumulator 之前,会校验该消息是否大于 max.request.size,具体逻辑如下:org.apache.kafka.clients.producer.KafkaProducer#ensureValidRecordSize从以上源码得出结论,Kafka 会首先判断本次消息大小是否大于 maxRequestSize,如果本次消息大小 maxRequestSize,则直接抛出异常,不会继续执行追加消息到 batch。并且还会在 Sender 线程发送数据到 broker 之前,会使用 max.request.size 限制发送请求数据的大小:org.apache.kafka.clients.producer.internals.Sender#sendProducerData也就是说,max.request.size 参数具备两个特性:1)限制单条消息大小2)限制发送请求大小参见 2.2.x 版本的官方解释:The maximum size of a request in bytes. This setting will limit the number of record batches the producer will send in a single request to avoid sending huge requests. This is also effectively a cap on the maximum record batch size. Note that the server has its own cap on record batch size which may be different from this.翻译如下:请求的最大大小(以字节为单位)。此设置将限制生产者将在单个请求中发送的记录批数,以避免发送大量请求。这实际上也是最大记录批次大小的上限。请注意,服务器对记录批大小有自己的上限,该上限可能与此不同。2、batch.sizebatch.size 是 Kafka producer 非常重要的参数,它的值对 Producer 的吞吐量有着非常大的影响,因为我们知道,收集到一批消息再发送到 broker,比每条消息都请求一次 broker,性能会有显著的提高,但 batch.size 设置得非常大又会给机器内存带来极大的压力,因此需要在项目中合理地增减 batch.size 值,才能提高 producer 的吞吐量。org.apache.kafka.clients.producer.internals.RecordAccumulator#append以上,将消息追加到消息缓冲区时,会尝试追加到一个 ProducerBatch,如果 ProducerBatch 满了,就去缓存区申请 batch.size 大小的缓存创建一个新的 ProducerBatch 继续追加消息。需要注意的是,如果消息大小本身就比 batch.size 大,这种情况每个 ProducerBatch 只会包含一条消息。最终 RecordAccumulator 缓存区看起来是这样的:参见 2.2.x 版本的官方解释:The producer will attempt to batch records together into fewer requests whenever multiple records are being sent to the same partition. This helps performance on both the client and the server. This configuration controls the default batch size in bytes.No attempt will be made to batch records larger than this size.Requests sent to brokers will contain multiple batches, one for each partition with data available to be sent.A small batch size will make batching less common and may reduce throughput (a batch size of zero will disable batching entirely). A very large batch size may use memory a bit more wastefully as we will always allocate a buffer of the specified batch size in anticipation of additional records.翻译如下:每当将多个记录发送到同一分区时,生产者将尝试将记录一起批处理成更少的请求。这有助于提高客户端和服务器的性能。此配置控制默认的批处理大小(以字节为单位)。不会尝试批处理大于此大小的记录。发送给代理的请求将包含多个批次,每个分区一个,并包含可发送的数据。较小的批处理量将使批处理变得不那么普遍,并且可能会降低吞吐量(零的批处理量将完全禁用批处理)。非常大的批处理大小可能会浪费一些内存,因为我们总是在预期其他记录时分配指定批处理大小的缓冲区。那么针对 max.request.size 、batch.size 之间大小的调优就尤其重要,通常来说,max.request.size 大于 batch.size,这样每次发送消息通常会包含多个 ProducerBatch。consumer1、fetch.min.bytes、fetch.max.bytes、max.partition.fetch.bytes1)fetch.max.bytes参见 2.2.x 版本的官方解释:The maximum amount of data the server should return for a fetch request. Records are fetched in batches by the consumer, and if the first record batch in the first non-empty partition of the fetch is larger than this value, the record batch will still be returned to ensure that the consumer can make progress. As such, this is not a absolute maximum. The maximum record batch size accepted by the broker is defined via message.max.bytes (broker config) or max.message.bytes (topic config). Note that the consumer performs multiple fetches in parallel.翻译如下:服务器为获取请求应返回的最大数据量。使用者将批量获取记录,并且如果获取的第一个非空分区中的第一个记录批次大于此值,则仍将返回记录批次以确保使用者可以取得进展。因此,这不是绝对最大值。代理可接受的最大记录批处理大小是通过“ message.max.bytes”(代理配置)或“ max.message.bytes”(主题配置)定义的。请注意,使用者并行执行多个提取。fetch.min.bytes、max.partition.fetch.bytes 同理。实战测试针对以上相关参数配置的解读,还需要对 max.request.size、batch.size、message.max.bytes(或者 max.message.bytes)三个参数进一步验证。1、测试消息大于 max.request.size 是否会被拦截设置:max.request.size = 1000, record-size = 2000使用 kafka-producer-perf-test.sh 脚本测试:$ {kafka_path}/bin/kafka-producer-perf-test.sh --topic test-topic2 --num-records 500000000000 --record-size 20000 --throughput 1 --producer-props bootstrap.servers=localhost:9092,localhost:9093,localhost:9094 acks=-1 max.request.size=1000测试结果:可以得出结论,成功拦截了大于 max.request.size 的消息。2、测试 max.message.bytes 参数用于校验批次大小还是校验消息大小设置:record-size = 500 batch.size = 2000 linger.ms = 1000 max.message.bytes = 1000 // 在控制台调整主题级别配置即可使用 kafka-producer-perf-test.sh 脚本测试:$ {kafka_path}/bin/kafka-producer-perf-test.sh --topic test-topic1 --num-records 500000000000 --record-size 500 --throughput 5 --producer-props bootstrap.servers=localhost:9092,localhost:9093,localhost:9094 acks=-1 batch.size=2000 linger.ms=1000测试结果:当 max.message.bytes = 2500 时:可以得出结论,max.message.bytes 参数校验的是批次大小,而不是消息大小。3、测试消息大小比 batch.size 还大的情况下,是否还会发送消息,当 max.message.bytes 参数小于消息大小时,是否会报错record-size = 1000 batch.size = 500 linger.ms = 1000使用 kafka-producer-perf-test.sh 脚本测试:$ {kafka_path}/bin/kafka-producer-perf-test.sh --topic test-topic1 --num-records 500000000000 --record-size 1000 --throughput 5 --producer-props bootstrap.servers=localhost:9092,localhost:9093,localhost:9094 acks=-1 batch.size=500 linger.ms=1000测试结果:可以得出结论,即使消息大小比 batch.size 还大,依然会继续发送消息。当 max.message.bytes = 900 时:可以得出结论,即使 batch.size < max.message.bytes,但由于消息大小比 batch.size 大的情况下依然会发送消息,如果没有 max.request.size 参数控制消息大小的话,就有可能会报错。这也说明了文章开头为什么直接修改 max.request.size 和 max.message.bytes 即可,而不需要调整 batch.size 的原因。总结从测试结果来看, max.request.size、batch.size、message.max.bytes(或者 max.message.bytes)三个参数都有一定的联系,环环相扣,在实际的业务中还需要根据业务消息大小,给出适当的值,这对于 Kafka 集群的吞吐量起着至关重要的作用。本文基于 Kafka 2.2.x 版本
Docker Swarm 集群的内部会为容器的各个节点之间负责负载均衡的管理,现在我们来验证一下 Docker Swarm 的负载均衡特性。创建测试项目编写测试程序:func main() { resp, _ := http.Get("http://myexternalip.com/raw") defer resp.Body.Close() content, _ := ioutil.ReadAll(resp.Body) r := gin.Default() r.GET("/addr", func(c *gin.Context) { c.JSON(200, gin.H{ "addr": string(content), }) }) r.Run(":8081") }编写 Dockerfile:FROM golang:latest WORKDIR $GOPATH/src/go-gin-demo COPY . $GOPATH/src/go-gin-demo RUN go get github.com/gin-gonic/gin && go build . EXPOSE 8081 ENTRYPOINT ["./go-gin-demo"]打包镜像并上传到 docker hub:$ docker build -t chenghuizhang/go-gin-demo:v3 . $ docker push chenghuizhang/go-gin-demo:v3创建集群首先初始化一个管理节点:$ docker swarm init --advertise-addr 193.xxx.61.178这里需要说明一下,由于我的两台服务器都同于一个内网环境,所以这里需要指定外网 ip,得到以下命令:$ docker swarm join --token xxxxxxxxxxxxxxxx 193.xxx.61.178:2377另一台服务器加入,现在得到了拥有两个节点的 swarm 集群:docker swarm这里特别注意一下,由于是加入管理节点需要通过外网,所以docker swarm join加个地址参数:$ docker swarm join --token xxxxxxxxxxxxxxxx 193.xxx.61.178:2377 --advertise-addr 111.xxx.254.127部署测试创建集群网络驱动:$ docker network create -d overlay mynet部署 go-gin-demo 到其中一个节点,另外一个节点是否可通过 docker 的 overlay 跨主机网路驱动访问:$ docker service create -p 8081:8081 --network mynet --replicas 1 --name go-gin-demo chenghuizhang/go-gin-demo:v3查看服务:$ docker service ps go-gin-demo发现 go-gin-demo 部署到工作节点了,这时我们通过管理节点 ip 访问,结果如下:docker swarm说明即使管理节点没有部署该服务,仍然是可以通过 overlay 跨主机网络进行调用的。同时我们查看管理节点的 8081 是否有被监听:$ lsof -i:8081docker swarm发现 go-gin-demo 虽然没有部署到管理节点上,但其端口在其他节点上面依然被监听着,所以我们得出,整个 overlay 网络中,每个服务都可以通过任意一台集群内服务器访问。这里需要注意一下,服务器防火墙需要开通 docker 相关的端口,这里为了方便,就把服务器的防火墙关闭了:$ systemctl stop firewalld.service # centos 7 关闭防火墙部署 go-gin-demo 到两个节点上,访问其中一台服务器,验证 swarm 集群是否具备负载均衡:$ docker service scale go-gin-demo=2docker swarm这时我们随意访问一台服务器,多访问几次,会出现返回来的是另一台服务器的地址,说明 swarm 集群具备负载均衡的特性。
单机容器内的通信是通过 docker 自带的网桥连接互通的,如果是集群,那么做这些单机网络模型就行不通了,因为集群必然会将一个服务的多个任务需要分布到不同的机器上进行部署,因为如果把所有的任务都分配到一台机器部署了,这台服务器有故障,就会导致这个服务不能正常运行了,而且一个集群内,不同主机之间的容器是怎么进行通信的呢,这里我们就涉及到 docker 网络模型。容器的端口映射还记得 docker 单机部署时的 run -p port:port 吗?这样做的目的是将 docker 容器内的端口映射到宿主机的端口上,以便能够通过外网 ip 访问到 docker 容器,这时我们就想,如果我们把所有容器的接口都暴露在宿主机中,通过访问外网 ip 来达到容器间通信,这不是万事大吉了吗?但是在集群中如果这样暴露容器的端口,是有问题的,如果其中一个容器监听了宿主机 8080 端口,那么其他容器只能映射到其它端口了,因为端口并不能被共享,而且映射到宿主机的端口上,意味着容器也就暴露到外网了,如果要限制访问,那么就需要做一些安全配置。单机网络模型在介绍跨主机网络模型前,先来看看单机网络模型,在安装 docker 之后,docker 就会有 4 种网络模型,分别是:host 模式,使用 --net=host 指定。container 模式,使用 --net=container:NAME_or_ID 指定。none 模式,使用 --net=none 指定。bridge 模式,使用 --net=bridge 指定,默认设置。但这四种网络模式都仅限于单机,其中 bridge 网络模型是 docker 的默认单机网络模型,它会将一个主机上的 docker 容器连接到一个虚拟网桥上,这个虚拟桥名称为 docker0,如下图:brige单机中的容器之间就可以通过 docker0 互相通信了,但是如果容器被分布在不同主机上,在没有跨主机网络模型前,只能通过映射端口的形式来通信了。brige如上图,net1 和 net2 都代表一台主机中的 docker0 网络,在同主机下的容器通过 docker0 网络互相通信,但是在不同主机中却又是隔离的。跨主机网络模型docker 1.9 版本之后,加入了一个默认的 overlay 的网络模型,它是 docker swarm 内置的跨主机通信方案,这是一个基于 vxlan 协议的网络实现,其作用是虚拟出一个子网,让处于不同主机的容器能透明地使用这个子网。所以跨主机的容器通信就变成了在同一个子网下的容器通信,看上去就像是同一主机下的 bridge 网络通信。关于详细的 vxlan 协议原理,请移步:vxlan 协议原理简介在 swarm 管理节点发布的服务想要监听端口,只需要在 像 docker run 一样在后缀加 -p 8080:8080 就可以了,如下:$ docker service create --replicas 2 -p 8080:8080 --name hello \ chenghuizhang/helloword:0.0.2但是跟 docker run 的 -p 又有本质的区别,实际上面那条命令并没有将 8080 端口直接暴露出去,而是将 8080 端口托付给 docker 的 overlay 网络模型中了。docker ps如上图可知,hello 服务的两个实例都在同一台服务器,都是 8080 端口,且没有映射到宿主机的端口上。查看 docker 默认网络:$ docker network lsdocker network其中 ingress 为 docker 默认的 overlay 网络。查看 ingress 网络信息:$ docker network inspect ingress[ { "Name": "ingress", "Id": "x58u3pdo4z4qooriloi82l58k", "Created": "2018-08-15T12:54:22.080771222+08:00", "Scope": "swarm", "Driver": "overlay", "EnableIPv6": false, "IPAM": { "Driver": "default", "Options": null, "Config": [ { "Subnet": "10.255.0.0/16", "Gateway": "10.255.0.1" } ] }, "Internal": false, "Attachable": false, "Containers": { "aef3e18d73b1db723aab0f162600a805aa7c50b5f51ec31d82d80b7eb3438c08": { "Name": "hello.1.tzk61cn6d0jjat2w4mu5x8bbf", "EndpointID": "40e9352419b4eccea739be3a6ab7e6327ee28a2220ce185750f028d74e2e05c8", "MacAddress": "02:42:0a:ff:00:05", "IPv4Address": "10.255.0.5/16", "IPv6Address": "" }, "ef4a7f16567d3b51d0dff629b3b6252f50d06ec77a24b36dcd8d40b6ab9afc2d": { "Name": "hello.2.lcvumjyd19tymzankbjwita1w", "EndpointID": "d5b9a2e41654097337d6f22b139e1501b253e260d2ac3ac52e8eaf491a70340d", "MacAddress": "02:42:0a:ff:00:06", "IPv4Address": "10.255.0.6/16", "IPv6Address": "" }, "ingress-sbox": { "Name": "ingress-endpoint", "EndpointID": "b610f32940e6066751a6c7b8d87f11874077f779a00d28855d8d3329d15783e9", "MacAddress": "02:42:0a:ff:00:03", "IPv4Address": "10.255.0.3/16", "IPv6Address": "" } }, "Options": { "com.docker.network.driver.overlay.vxlanid_list": "4096" }, "Labels": {}, "Peers": [ { "Name": "VM_0_10_centos-8ef37c047944", "IP": "172.16.0.10" } ] } ]由于 orverlay 网络模型是基于 vxlan 协议的网络实现,所以根据上面的网络信息可知,它是要在三层网络中虚拟出二层网络,即跨网段建立虚拟子网,也就是把 docker 要发送的信息先发送到虚拟子网地址 10.255.0.1,再由虚拟子网包装为宿主机的真实网网址 172.16.0.10,这样做的好处就是不会公开暴露容器的端口,让这些事情交给 overlay 网络驱动去做就行了,而且在同一台服务器,不会引起端口冲突,最重要的一点是可以实现集群容器间的负载均衡。正如它的名字一样,在所有容器的上面一层,覆盖了一层网络,该网络可以使在集群中的容器像本地通信一样,所以 orverlay 网络模型也称之为覆盖网络。构建自定义 overlay 网络集群新建网络驱动:$ docker network create -d overlay mynet-d 指定 mynet 网络驱动为 overlay 类型。创建 etcd 服务:$ docker service create \ --name etcd \ --replicas 1 \ --network mynet \ -p 2379:2379创建 mysql 服务:$ docker service create \ --name mysql-galera \ --replicas 3 \ --network mynet \ -p 3306:3306创建 hello 应用服务:$ docker service create \ --name hello chenghuizhang/helloword:0.0.2 \ --replicas 2 \ --network mynet \ -p 8080:8080到这里,我们已经构建了一个名为 mynet 的网络集群了,集群网络模型如下:swarm 集群的内部会为容器的各个节点之间负责负载均衡的管理,无需我们去操心了,如上如图三台服务器,无论我们访问的哪台服务器,都可以访问到 docker 各个可用节点中,比如访问 172.16.1.11:8080,也可以通过 swarm 集群的负载均衡转发到 172.16.1.12:8080。
Docker Swarm 集群的一些概念节点swarm集群分为管理节点和工作节点,管理节点可以操作swarm命令控制swarm集群,工作节点是用于运行服务的节点,理论上管理节点也可以是工作节点,一样可以用于运行服务。一般来说一个swarm集群需要两个以上的管理节点。服务在分布式集群应用中,应用的不同部分拆分成“服务”,服务在swarm集群中可部署在多个节点上,形成集群,可使用swarm命令动态扩展服务在swarm集群中运行的实例数量,以满足需求。技术栈技术栈是一组相关的服务,它们共享依赖项并且可以一起进行编排和扩展,比如我们的vipay和cash项目的各个服务,可使用compose.yml文件编排成vipay技术栈以及cash技术栈,并使用 docker stack deploy分别进行部署。技术栈也是swarm集群中层次结构的最高级别。Docker Swarm 集群的命令栈docker swarm: 集群管理,子命令有 init, join, leave, updatedocker service: 服务管理,子命令有 create, inspect, update, remove, tasksdocker node:节点管理,子命令有accept, promote, demote, inspect, update, tasks, ls, rmdocker network: 网络管理,子命令有connect,create,disconnect,inspect,ls,prune,rmdocker stack: 服务上层管理,子命令有deploy,ls,ps,rm,servicesdocker volume:数据卷管理,子命令有ls,inpsect,rm,create以下是一些常用的命令操作:# 创建服务 docker service create \ --image nginx \ --replicas 2 \ nginx # 更新服务 docker service update \ --image nginx:alpine \ nginx # 删除服务 docker service rm nginx # 减少服务实例(这比直接删除服务要好) docker service scale nginx=0 # 增加服务实例 docker service scale nginx=5 # 查看所有服务 docker service ls # 查看服务的容器状态 docker service ps nginx # 查看服务的详细信息。 docker service inspect nginx创建 Docker Swarm 集群步骤安装docker设置docker版本镜像仓库,从而可以轻松完成安装和升级任务:$ sudo yum install -y yum-utils device-mapper-persistent-data lvm2添加Docker源,始终需要使用stable镜像仓库进行更新docker版本:$ sudo yum-config-manager \ --add-repo https://download.docker.com/linux/centos/docker-ce.repo执行安装:sudo yum makecache安装Docker:$ sudo yum install docker-ce如果出现以下报错:Downloading packages: warning: /var/cache/yum/x86_64/7/base/packages/wget-1.14-13.el7.x86_64.rpm: Header V3 RSA/SHA256 Signature, key ID f4a80eb5: NOKEY Retrieving key from http://mirror.centos.org/centos/RPM-GPG-KEY-CentOS-6 The GPG keys listed for the "CentOS-7 - Base - 163.com" repository are already installed but they are not correct for this package. Check that the correct key URLs are configured for this repository. Failing package is: wget-1.14-13.el7.x86_64 GPG Keys are configured as: http://mirror.centos.org/centos/RPM-GPG-KEY-CentOS-6解决方法:sudo rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7配置daemo.json$ vim /etc/docker/daemon.json{ "registry-mirror": [ "https://registry.docker-cn.com" ], "insecure-registries": [ "172.17.10.127:5000" ] }以上配置目的添加一个私有库以及镜像加速器。启动docker$ sudo systemctl start docker初始化一个swarm集群(后续添加节点该步骤省略)$ sudo docker swarm init节点加入集群查看使用主节点的token添加工作节点到集群的命令:$ sudo docker swarm join-token worker查看使用主节点的token添加管理节点到集群的命令:$ sudo docker swarm join-token manage集群中加入一个节点:$ sudo docker swarm join \ --token SWMTKN-1-69luztakii9ix7f5osezl0v6l2ibfzp1vqc0gbhcous63hm1fx-8p3vxanj97f2e0jflznihvl8f \ <HOSST>:<NAME>创建 Docker 原生私有库使用docker官方registry仓库镜像,直接在仓库服务器pull下镜像,docker run就可以创建一个私有仓库了docker默认推送到私有库只能用https协议,在原有的基础上需要配置一个拥有权限认证的私有仓库:# 创建仓库数据卷 $ sudo docker volume create registry $ sudo docker run -d -p 5000:5000 \ -v registry:/var/lib/registry \ --restart=always \ --name registry registry在/etc/docker/daemon.json中加入以下内容:{ "registry-mirror": [ "https://registry.docker-cn.com" ], "insecure-registries": [ "仓库内网ip:端口" ] }在每个节点添加兼容私有仓库非 https 协议配置:$ vim /etc/sysconfig/docker OPTIONS='--insecure-registry 192.168.1.111:5000'Docker Swarm 集群的可视化管理在swarm集群中添加portainer可视化管理工具,先下载compose编排文件:$ curl -L https://downloads.portainer.io/portainer-agent-stack.yml -o portainer-agent-stack.yml稍微修改一下文件,以适应我们的swarm集群,比如网络驱动,容器名称,端口等等。deploy运行:$ sudo docker stack deploy --compose-file=portainer-agent-stack.yml portainerDocker Swarm 集群的负载均衡单机模型:同一主机docker容器间通过docker内置的虚拟网桥docker0通信, 如果需要跨主机通信, 那么就通过端口映射的方式.跨主机模型:通过vxlan网络协议实现, 简单来说就是在所有容器的上面一层,覆盖了一层网络,该网络可以使在集群中的容器像本地通信一样,所以 orverlay 网络模型也称之为覆盖网络, 容器本身并没有把端口映射到主机, 而是将端口暴露的事情交给覆盖网络去处理了.docker的覆盖网络有个好处就是在集群下, 通过任意一个节点可以访问到对应的服务, 即使当前节点没有该服务实例, 这样也间接性地实现了节点间的负载均衡.创建跨主机网络驱动:$ sudo docker network create -d overlay mynetSwarm集群服务的更新与版本回滚更新执行命令: docker service update --images xxx:latest my_project回滚执行命令: docker service update --rollback my_project指定回滚版本号:docker service update --images xxx:latest my_project:<上一个版本>镜像版本的一些规范每次需要打包构建镜像名称:<仓库地址>/<服务名称>:<分支>-<时间戳>再打包一个镜像名称:<仓库地址>/<服务名称>:<分支>-latest这么做的好处是:有时间戳的镜像版本作为回滚的作用,可通过命令 docker service update images 命令回滚到任意一个版本无时间戳的镜像版本为当前运行的最新版本。每次镜像更新构建完后,默认运行该镜像。使用 docker swarm 集群的好处1.可动态调整服务的实例个数当我们需要增加一个服务部署的实例个数时,我们不需要重新在一台机器里面做一些重复劳动性的工作了,我们只需动动手指头,就可以动态扩。我直接可通过 docker swarm集群的管理界面工具上,找到相关服务,手动调整实例个数就ojbk了,当然你想逼格更高点,你直接去管理节点敲命令行也是ojbk的:$ sudo docker service scale myService = 数量我们以后就再也不用关心项目部署在哪台机了,它会自动随机分配部署到集群的任意一个节点,我们只需通过swarm集群,就可负载均衡地随机访问到任意一个实例。2.可动态扩容当我们集群内集群负载过高时,可以增加若干台机器,在每台加入机器装上docker,执行以下加入集群的命令,就可以加入集群,听从管理节点分配的工作。完全不需要在新增的机器上面做一些重复性劳动,你只需要安装docker,就这么任性。docker swarm join --token SWMTKN-1-69luztakii9ix7f5osezl0v6l2ibfzp1vqc0gbhcous63hm1fx-8p3vxanj97f2e0jflznihvl8f 172.17.10.127:23773.一次打包,到处运行这个也是docker官方的宣传口语,我们只需将所有的运行时依赖打包成一个镜像,就可以任性地到处运行了。测试运维小伙伴再也不需要重新将环境搭建一次了,人都会犯错的,你不能保证你搭建的环境跟我开发的环境是一致的,有时候就会出现我在sit环境部署的很好,一上uat就变火葬场的情况。用上docker,将会大大杜绝这种事情的发生。4.自动化发版jenkins监听到仓库对应分支的代码有改动,自动拉取代码,制作新镜像,执行远程命令:$ sudo docker update --images <imagesname> <servicename>将会自动更新该服务所有的实例,且不需要停机停服更新,完全可实现平滑升级,比如该服务有3个实例,那么可设置依次更新。
上两篇文章都在讨论顺序消息的一些知识,看到有个读者的留言如下:这个问题问得非常棒,由于在之前的文章中并没有提及到,因此我在这篇文章中单独讲解,本文将从消费顺序性这个问题出发,深度剖析 Kafka/RocketMQ 消费线程模型。Kafkakafka 的消费类 KafkaConsumer 是非线程安全的,因此用户无法在多线程中共享一个 KafkaConsumer 实例,且 KafkaConsumer 本身并没有实现多线程消费逻辑,如需多线程消费,还需要用户自行实现,在这里我会讲到 Kafka 两种多线程消费模型。1、每个线程维护一个 KafkaConsumer这样相当于一个进程内拥有多个消费者,也可以说消费组内成员是有多个线程内的 KafkaConsumer 组成的。但其实这个消费模型是存在很大问题的,从消费消费模型可看出每个 KafkaConsumer 会负责固定的分区,因此无法提升单个分区的消费能力,如果一个主题分区数量很多,只能通过增加 KafkaConsumer 实例提高消费能力,这样一来线程数量过多,导致项目 Socket 连接开销巨大,项目中一般不用该线程模型去消费。2、单 KafkaConsumer 实例 + 多 worker 线程针对第一个线程模型的缺点,我们可采取 KafkaConsumer 实例与消息消费逻辑解耦,把消息消费逻辑放入单独的线程中去处理,线程模型如下:从消费线程模型可看出,当 KafkaConsumer 实例与消息消费逻辑解耦后,我们不需要创建多个 KafkaConsumer 实例就可进行多线程消费,还可根据消费的负载情况动态调整 worker 线程,具有很强的独立扩展性,在公司内部使用的多线程消费模型就是用的单 KafkaConsumer 实例 + 多 worker 线程模型。但这个消费模型由于消费逻辑是利用多线程进行消费的,因此并不能保证其消息的消费顺序,在这里我们可以引入阻塞队列的模型,一个 woker 线程对应一个阻塞队列,线程不断轮训从阻塞队列中获取消息进行消费,对具有相同 key 的消息进行取模,并放入相同的队列中,实现顺序消费, 消费模型如下:但是以上两个消费线程模型,存在一个问题:在消费过程中,如果 Kafka 消费组发生重平衡,此时的分区被分配给其它消费组了,如果拉取回来的消息没有被消费,虽然 Kakfa 可以实现 ConsumerRebalanceListener 接口,在新一轮重平衡前主动提交消费偏移量,但这貌似解决不了未消费的消息被打乱顺序的可能性?因此在消费前,还需要主动进行判断此分区是否被分配给其它消费者处理,并且还需要锁定该分区在消费当中不能被分配到其它消费者中(但 kafka 目前做不到这一点)。参考 RocketMQ 的做法:在消费前主动调用 ProcessQueue#isDropped 方法判断队列是否已过期,并且对该队列进行加锁处理(向 broker 端请求该队列加锁)。RocketMQRocketMQ 不像 Kafka 那么“原生”,RocketMQ 早已为你准备好了你的需求,它本身的消费模型就是单 consumer 实例 + 多 worker 线程模型,有兴趣的小伙伴可以从以下方法观摩 RocketMQ 的消费逻辑:org.apache.rocketmq.client.impl.consumer.PullMessageService#runRocketMQ 会为每个队列分配一个 PullRequest,并将其放入 pullRequestQueue,PullMessageService 线程会不断轮询从 pullRequestQueue 中取出 PullRequest 去拉取消息,接着将拉取到的消息给到 ConsumeMessageService 处理,ConsumeMessageService 有两个子接口:// 并发消息消费逻辑实现类 org.apache.rocketmq.client.impl.consumer.ConsumeMessageConcurrentlyService; // 顺序消息消费逻辑实现类 org.apache.rocketmq.client.impl.consumer.ConsumeMessageOrderlyService;其中,ConsumeMessageConcurrentlyService 内部有一个线程池,用于并发消费,同样地,如果需要顺序消费,那么 RocketMQ 提供了 ConsumeMessageOrderlyService 类进行顺序消息消费处理。经过对 Kafka 消费线程模型的思考之后,从 ConsumeMessageOrderlyService 源码中能够看出 RocketMQ 能够实现局部消费顺序,我认为主要有以下两点:1)RocketMQ 会为每个消息队列建一个对象锁,这样只要线程池中有该消息队列在处理,则需等待处理完才能进行下一次消费,保证在当前 Consumer 内,同一队列的消息进行串行消费。2)向 Broker 端请求锁定当前顺序消费的队列,防止在消费过程中被分配给其它消费者处理从而打乱消费顺序。总结经过这篇文章的分析后,尝试回答文章开头的那个问题:1)多分区的情况下:如果想要保证 Kafka 在消费时要保证消费的顺序性,可以使用每个线程维护一个 KafkaConsumer 实例,并且是一条一条地去拉取消息并进行消费(防止重平衡时有可能打乱消费顺序);对于能容忍消息短暂乱序的业务(话说回来, Kafka 集群也不能保证严格的消息顺序),可以使用单 KafkaConsumer 实例 + 多 worker 线程 + 一条线程对应一个阻塞队列消费线程模型。1)单分区的情况下:由于单分区不存在重平衡问题,以上两个线程模型的都可以保证消费的顺序性。另外如果是 RocketMQ,使用 MessageListenerOrderly 监听消费可保证消息消费顺序。很多人也有这个疑问:既然 Kafka 和 RocketMQ 都不能保证严格的顺序消息,那么顺序消费还有意义吗?一般来说普通的的顺序消息能够满足大部分业务场景,如果业务能够容忍集群异常状态下消息短暂不一致的情况,则不需要严格的顺序消息。如果你对文章还有什么疑问和补充或者发现文中有错误的地方,欢迎留言,我们一起探讨。
上一篇文章「保证严格的消息顺序消费究竟有多难?」简单描述了对消息顺序消费的一些理解,上一篇文章中的第二个故障问题,感觉没描述清楚,现在我以 Kafka 为例子,继续分析一波。从上一篇文章中分析可知,想要保证消息顺序消费,只需要保证生产端消息发送处在同一分区即可,但现实情况往往会遇到很多意外情况,下面我就盘点一下 Kafka 集群中有哪些意外情况会打乱消息的顺序。1、分区变更的情况假设有集群中有两个分区的主题 A,生产端需要往分区 1 发送 3 条顺序消息,我们都知道生产端是根据消息 Key 取模计算决定消息发往哪个分区的,如果此时生产端发送第三条消息前,主题 A 增加了一个分区,生产端根据 Key 取模得出的分区号就不一样了,第三条消息路由到其它分区,结果就是这三条顺序消息就不在同一个分区了,此时就不能保证这三条消息的消费顺序了。2、分区不变更2.1、分区单副本假设此时集群有两个分区的主题 A,副本因子为 1,生产端需要往分区 1 发送 3 条顺序消息,前两条消息已成功发送到分区 1,此时分区 1 所在的 broker 挂了(由于副本因子只有 1,因此会导致分区 1 不可用),当生产端发送第三条消息时发现分区 1 不可用,就会导致发送失败,然后尝试进行重试发送,如果此时分区 1 还未恢复可用,这时生产端会将消息路由到其它分区,导致了这三条消息不在同一个分区。2.2、分区多副本针对分区单副本情况,我们自然会想到将分区设置为多副本不就可以避免这种情况发生吗?多副本情况下,发送端同步发送,acks = all,即保证消息都同步到全部副本后,才返回发送成功,保证了所有副本都处在 ISR 列表中,如果此时其中一个 broker 宕机了,也不会导致分区不可用的情况,看起来确实避免了分区单副本分区不可用导致消息路由到其它分区的情况发生。但我想说的是,还有一种极端的现象会发生,当某个 broker 宕机了,处在这个 broker 上的 leader 副本就不可用了,此时 controller 会进行该分区的 leader 选举,在选举过程中分区 leader不可用,生产端会短暂报 no leader 警告,这时生产端也会出现消息被路由到其它分区的可能。
我不记得有多少人问过以下这个问题了:我觉得这个问题问得很频繁,而且非常经典,在这里我就以 Kafka 为例子,说说我对 Kafka 顺序消息的一些理解吧,如有理解不对的地方麻烦留言指点一下。通常我们在说顺序消费指的是生产者按照顺序发送,消费者按照顺序进行消费,听起来简单,但做起来却非常困难。我们都知道无论是 Kafka 还是 RocketMQ,每个主题下面都有若干分区(RocketMQ 叫队列),如果消息被分配到不同的分区中,那么 Kafka 是不能保证消息的消费顺序的,因为每个分区都分配到一个消费者,此时无法保证消费者的消费先后,因此如果需要进行消息具有消费顺序性,可以在生产端指定这一类消息的 key,这类消息都用相同的 key 进行消息发送,kafka 就会根据 key 哈希取模选取其中一个分区进行存储,由于一个分区只能由一个消费者进行监听消费,因此这时候消息就具有消息消费的顺序性了。但以上情况只是在正常情况下可以保证顺序消息,但发生故障后,就没办法保证消息的顺序了,我总结以下两点:1、当生产端是异步发送时,此时有消息发送失败,比如你异步发送了 1,2,3 消息,2 消息发送异常重试发送,这时候顺序就乱了;2、当 Broker 宕机重启,由于分区会发生重平衡动作,此时生产端根据 key 哈希取模得到的分区发生变化,这时会发生短暂消息顺序不一致的现象。针对以上两点,生产端必须保证单线程同步发送,这还好解决,针对第二点,想要做到严格的消息顺序,就要保证当集群出现故障后集群立马不可用,或者主题做成单分区,但这么做大大牺牲了集群的高可用,单分区也会另集群性能大大降低。
最近都在看小马哥的 Spring 视频教程,通过这个视频去系统梳理一下 Spring 的相关知识点,就在一个晚上,躺床上看着视频快睡着的时候,突然想到当我们在使用 SpringMVC 时,Spring 容器是如何与 Servlet 容器进行交互的?虽然在我的博客上还有几年前写的一些 SpringMVC 相关源码分析,其中关于 Spring 容器如何与 Servlet 容器进行交互并没有交代清楚,于是趁着这个机会,再撸一次 SpringMVC 源码。Spring 容器的加载可否还记得,当年还没有 Springboot 的时候,在 Tomcat 的 web.xml 中进行面向 xml 编程的青葱岁月?其中有那么几段配置总是令我记忆犹新:首先是 Spring 容器配置:<context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-config.xml</param-value> </context-param>其次是 Servlet 容器监听器配置:<listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener>在 Tomcat 启动时,根据这两段配置,究竟做了什么动作,使得 Tomcat 与 Spring 完美地结合在一起了呢?首先我们来看下 ContextLoaderListener 监听器的源码:我们发现它继承了 ContextLoader,并且实现了 ServletContextListener 接口,下面说下这两个东西的作用:ContextLoader:正如其名,ContextLoader 可以在启动时载入 IOC 容器;ServletContextListener:ServletContextListener 接口有两个抽象方法,contextInitialized 和 contextDestroyed,该监听器会结合 Web 容器的生命周期被调,ContextLoaderListener 正是实现了该接口。因此,ContextLoaderListener 最主要的作用就是在 Tomcat 启动时,根据配置加载 Spring 容器。以上就是 ContextLoaderListener 实现 contextInitialized 方法的逻辑,也是加载并初始化 Spring 容器的开始。org.springframework.web.context.ContextLoader#initWebApplicationContext以上代码逻辑主要做了以下几个操作:调用 createWebApplicationContext 方法创建一个容器,会创建一个 contextClass 类型的容器,如果没有配置,则默认创建 WebApplicationContext 类型的容器;将容器强转为 ConfigurableWebApplicationContext 类型;调用 configureAndRefreshWebApplicationContext 方法初始化 Spring 容器;最后将 Spring 容器,以一个元素的形式保存到 Servlet 容器中,这也就意味着,得到 Servlet 容器,同时也可以得到 Spring 容器。还发现 Spring 容器保存到 Servlet 容器中的 key 为 ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE,我们顺藤摸瓜找到获取 Spring 容器的方法:org.springframework.web.context.support.WebApplicationContextUtils#getWebApplicationContext关于这个方法在哪里调用后面有说到。org.springframework.web.context.ContextLoader#configureAndRefreshWebApplicationContext以上是 Spring 容器初始化逻辑,其中,CONFIG_LOCATION_PARAM 即是我们在 xml 中配置的 contextConfigLocation 参数:同时还会将 Servlet 容器保存到 Spring 容器中,最后调用 refresh 方法进行初始化。在将 Spring 容器初始化最后以一个元素的形式保存到 Servlet 容器之后,那么 SpringMVC 在初始化时,是如何拿到 Spring 容器的呢?我们继续看 SpringMVC 初始化是怎么操作的。SpringMVC 容器的加载SpringMVC 本质上来讲,就是一个大号的 Servlet,其各种机制都是围绕着一个名叫 DispatcherServlet 的 Servlet 展开的,因此它必然实现了 Servlet 接口,那么在 Tomcat 启动时,它必然会通过 Servlet#init 方法进行初始化动作,我在其调用链路上发现以下方法:org.springframework.web.servlet.FrameworkServlet#initWebApplicationContextDispatcherServlet 的父类同样有一个方法,该方法是加载 SpringMVC 容器,即源码中的 webApplicationContext:我们发现,rootContext 就是 ContextLoaderListener 加载的 Spring 容器,在这里,它会以父容器的身份保存到 SpringMVC 容器中。当然,如果用 Springboot 环境,那么默认只会存在一个上下文环境,原因如下:1、在 Springboot 应用程序启动时,在 SpringBootServletInitializer#onStartup 方法中,会创建一个 rootAppContext 容器,如下:我们发现,rootContext 就是 ContextLoaderListener 加载的 Spring 容器,在这里,它会以父容器的身份保存到 SpringMVC 容器中。当然,如果用 Springboot 环境,那么默认只会存在一个上下文环境,原因如下:1、在 Springboot 应用程序启动时,在 SpringBootServletInitializer#onStartup 方法中,会创建一个 rootAppContext 容器,如下:DispatcherServlet 初始化时,经过 debug 可以看到,rootContext 和 webApplicationContext 是同一个实例对象:原因是通过 ContextLoaderListener 加载的上下文环境,通过 ApplicationContextAware 接口自动 set 进来保存到 DispatcherServlet 的 webApplicationContext 变量中了。在 FrameworkServlet#initWebApplicationContext 方法最后,最终会将 webApplicationContext 注入以一个元素的形式保存到 Servlet 容器中:DispatcherServlet 初始化最终,SpringMVC 初始化会调用该方法:org.springframework.web.servlet.DispatcherServlet#onRefreshDispatcherServlet 初始化时,从 Spring 容器中获取相关 Bean,初始化各种不同的组件,比如初始化 HandlerMapping:DispatcherServlet 初始化时,从 Spring 容器中获取相关 Bean,初始化各种不同的组件,比如初始化 HandlerMapping:
经过上次 Kafka 日志集群某节点重启失败导致某个主题分区不可用的事故之后,这篇文章专门对分区不可用进行故障重现,并给出我的一些骚操作来尽量减少数据的丢失。故障重现下面我用一个例子重现分区不可用且 leader 副本被损坏的例子:使用 unclean.leader.election.enable = false 参数启动 broker0;使用 unclean.leader.election.enable = false 参数启动 broker1;创建 topic-1,partition=1,replica-factor=2;将消息写入 topic-1;此时,两个 broker 上的副本都处于 ISR 中,broker0 的副本为 leader 副本;停止 broker1,此时 topic-1 的 leader 依然是 broker0 的副本,而 broker1 的副本从 ISR 中剔除;停止 broker0,并且删除 broker0 上的日志数据;重启 broker1,topic-1 尝试连接 leader 副本,但此时 broker0 已经停止运行,此时分区处于不可用状态,无法写入消息;恢复 broker0,broker0 上的副本恢复 leader 职位,此时 broker1 尝试加入 ISR,但此时由于 leader 的数据被清除,即偏移量为 0,此时 broker1 的副本需要截断日志,保持偏移量不大于 leader 副本,此时分区的数据全部丢失。我的建议在遇到分区不可用时,是否可以提供一个选项,让用户可以手动设置分区内任意一个副本作为 leader?因为集群一旦设置了 unclean.leader.election.enable = false,就无法选举 ISR 以外的副本作为 leader,在极端情况下仅剩 leader 副本还在 ISR 中,此时 leader 所在的 broker 宕机了,那如果此时 broker 数据发生损坏这么办?在这种情况下,能不能让用户自己选择 leader 副本呢?尽管这么做也是会有数据丢失,但相比整个分区的数据都丢失而言,情况还是会好很多的。我的骚操作首先你得有一个不可用的分区(并且该分区 leader 副本数据已损失),如果是测试,可以以上故障重现 1-8 步骤实现一个不可用的分区(需要增加一个 broker):此时 leader 副本在 broker0,但已经挂了,且分区不可用,此时 broker2 的副本由于掉出 ISR ,不可选为 leader,且 leader 副本已损坏清除,如果此时重启 broker0,follower 副本会进行日志截断,将会丢失该分区所有数据。经过一系列的测试与实验,我总结出了以下骚操作,可以强行把 broker2 的副本选为 leader,尽量减少数据丢失:1、使用 kafka-reassign-partitions.sh 脚本对该主题进行分区重分配,当然你也可以使用 kafka-manager 控制台对该主题进行分区重分配,重分配之后如下:此时 preferred leader 已经改成 broker2 所在的副本了,但此时的 leader 依然还是 broker0 的副本。需要注意的是,分区重分配之后的 preferred leader 一定要之前那个踢出 ISR 的副本,而不是分区重分配新生成的副本。因为新生成的副本偏移量为 0,如果自动重分配不满足,那么需要编写 json 文件,手动更改分配策略。2、进入 zk,查看分区状态并修改它的内容:修改 node 内容,强行将 leader 改成 2(与重分配之后的 preferred leader 一样),并且将 leader_epoch 加 1 处理,同时 ISR 列表改成 leader,改完如下:此时,kafka-manager 控制台会显示成这样:但此时依然不生效,记住这时需要重启 broker 0。3、重启 broker0,发现分区的 lastOffset 已经变成了 broker2 的副本的 lastOffset:成功挽回了 46502 条消息数据,尽管依然丢失了 76053 - 46502 = 29551 条消息数据,但相比全部丢失相对好吧!以上方法的原理其实很简单,就是强行把 Kafka 认定的 leader 副本改成自己想要设置的副本,然后 lastOffset 就会以我们手动设置的副本 lastOffset 为基准了。
背景在 2 月10 号下午大概 1 点半左右,收到用户方反馈,发现日志 kafka 集群 A 主题 的 34 分区选举不了 leader,导致某些消息发送到该分区时,会报如下 no leader 的错误信息:In the middle of a leadership election, there is currently no leader for this partition and hence it is unavailable for writes.接下来运维在 kafka-manager 查不到 broker0 节点了处于假死状态,但是进程依然还在,重启了好久没见反应,然后通过 kill -9 命令杀死节点进程后,接着重启失败了,导致了如下问题:由于 A 主题 34 分区的 leader 副本在 broker0,另外一个副本由于速度跟不上 leader,已被踢出 ISR,0.11 版本的 kafka 的 unclean.leader.election.enable 参数默认为 false,表示分区不可在 ISR 以外的副本选举 leader,导致了 A 主题发送消息持续报 34 分区 leader 不存在的错误,且该分区还未消费的消息不能继续消费了。Kafka 日志分析查看了 KafkaServer.log 日志,发现 Kafka 重启过程中,产生了大量如下日志:发现大量主题索引文件损坏并且重建索引文件的警告信息,定位到源码处:kafka.log.OffsetIndex#sanityCheck按我自己的理解描述下:Kafka 在启动的时候,会检查 kafka 是否为 cleanshutdown,判断依据为 ${log.dirs} 目录中是否存在 .kafka_cleanshutDown 的文件,如果非正常退出就没有这个文件,接着就需要 recover log 处理,在处理中会调用 sanityCheck() 方法用于检验每个 log sement 的 index 文件,确保索引文件的完整性:entries:由于 kafka 的索引文件是一个稀疏索引,并不会将每条消息的位置都保存到 .index 文件中,因此引入了 entry 模式,即每一批消息只记录一个位置,因此索引文件的 entries = mmap.position / entrySize;lastOffset:最后一块 entry 的位移,即 lastOffset = lastEntry.offset;baseOffset:指的是索引文件的基偏移量,即索引文件名称的那个数字。索引文件与日志文件对应关系图如下:判断索引文件是否损坏的依据是:_entries == 0 || _lastOffset > baseOffset = false // 损坏 _entries == 0 || _lastOffset > baseOffset = true // 正常这个判断逻辑我的理解是:entries 索引块等于零时,意味着索引没有内容,此时可以认为索引文件是没有损坏的;当 entries 索引块不等于 0,就需要判断索引文件最后偏移量是否大于索引文件的基偏移量,如果不大于,则说明索引文件被损坏了,需要用重新构建。那为什么会出现这种情况呢?我在相关 issue 中似乎找到了一些答案:https://issues.apache.org/jira/browse/KAFKA-1112https://issues.apache.org/jira/browse/KAFKA-1554总的来说,非正常退出在旧版本似乎会可能发生这个问题?有意思的来了,导致开机不了并不是这个问题导致的,因为这个问题已经在后续版本修复了,从日志可看出,它会将损坏的日志文件删除并重建,我们接下来继续看导致重启不了的错误信息:问题就出在这里,在删除并重建索引过程中,就可能出现如上问题,在 issues.apache.org 网站上有很多关于这个 bug 的描述,我这里贴两个出来:https://issues.apache.org/jira/browse/KAFKA-4972https://issues.apache.org/jira/browse/KAFKA-3955这些 bug 很隐晦,而且非常难复现,既然后续版本不存在该问题,当务之急还是升级 Kafka 版本,后续等我熟悉 scala 后,再继续研究下源码,细节一定是会在源码中呈现。解决思路分析矛盾点都是因为 broker0 重启失败导致的,那么我们要么把 broker0 启动成功,才能恢复 A 主题 34 分区。由于日志和索引文件的原因一直启动不起来,我们需要将损坏的日志和索引文件删除并重启即可。但如果出现 34 分区的日志索引文件也损坏的情况下,就会丢失该分区下未消费的数据,原因如下:此时 34 分区的 leader 还处在 broker0 中,由于 broker0 挂掉了且 34 分区 ISR 只有 leader,导致 34 分区不可用,在这种情况下,假设你将 broker0 中 leader 的数据清空,重启后 Kafka 依然会将 broker0 上的副本作为 leader,那么就需要以 leader 的偏移量为准,而这时 leader 的数据清空了,只能将 follower 的数据强行截断为 0,且不大于 leader 的偏移量。这似乎不太合理,这时候是不是可以提供一个操作的可能:在分区不可用时,用户可以手动设置分区内任意一个副本作为 leader?后面我会单独一篇文章对这个问题进行分析。后续集群的优化制定一个升级方案,将集群升级到 2.x 版本;每个节点的服务器将 systemd 的默认超时值为 600 秒,因为我发现运维在故障当天关闭 33 节点时长时间没反应,才会使用 kill -9 命令强制关闭。但据我了解关闭一个 Kafka 服务器时,Kafka 需要做很多相关工作,这个过程可能会存在相当一段时间,而 systemd 的默认超时值为 90 秒即可让进程停止,那相当于非正常退出了;将 broker 参数 unclean.leader.election.enable 设置为 true(确保分区可从非 ISR 中选举 leader);将 broker 参数 default.replication.factor 设置为 3(提高高可用,但会增大集群的存储压力,可后续讨论);将 broker 参数 min.insync.replicas 设置为 2(这么做可确保 ISR 同时有两个,但是这么做会造成性能损失,是否有必要?因为我们已经将 unclean.leader.election.enable 设置为 true 了);发送端发送 acks=1(确保发送时有一个副本是同步成功的,但这个是否有必要,因为可能会造成性能损失)。
上次的 Kafka 重启失败事件,对为什么重启失败的原因似乎并没有解释清楚,那么我就在这里按照我对 Kafka 的认识,从源码和日志文件结构去尝试寻找原因。从源码中定位到问题的根源首先把导致 Kafka 进程退出的异常栈贴出来:注:以下源码基于 kafka 0.11.0.2 版本。我们直接从 index 文件损坏警告日志的位置开始:kafka.log.Log#loadSegmentFiles注:以下源码基于 kafka 0.11.0.2 版本。我们直接从 index 文件损坏警告日志的位置开始:kafka.log.Log#loadSegmentFiles源码中相关变量说明:log:当前日志 Segment 文件的对象;batchs:一个 log segment 的消息压缩批次;batch:消息压缩批次;indexIntervalBytes:该参数决定了索引文件稀疏间隔打底有多大,由 broker 端参数 log.index.interval.bytes 决定,默认值为 4 KB,即表示当前分区 log 文件写入了 4 KB 数据后才会在索引文件中增加一个索引项(entry);validBytes:当前消息批次在 log 文件中的物理地址。知道相关参数的含义之后,那么这段代码的也就容易解读了:循环读取 log 文件中的消息批次,并读取消息批次中的 baseOffset 以及在 log 文件中物理地址,将其追加到索引文件中,追加的间隔为 indexIntervalBytes 大小。我们再来解读下消息批次中的 baseOffset:我们知道一批消息中,有最开头的消息和末尾消息,所以一个消息批次中,分别有 baseOffset 和 lastOffset,源码注释如下:其中最关键的描述是:它可以是也可以不是第一条记录的偏移量。kafka.log.OffsetIndex#append以上是追加索引块核心方法,在这里可以看到 Kafka 异常栈的详细信息,Kafka 进程也就是在这里被异常中断退出的(这里吐槽一下,为什么一个分区有损坏,要整个 broker 挂掉?宁错过,不放过?就不能标记该分区不能用,然后让 broker 正常启动以提供服务给其他分区吗?建议 Kafka 在日志恢复期间加强异常处理,不知道后续版本有没有优化,后面等我拿 2.x 版本源码分析一波),退出的条件是:_entries == 0 || offset > _lastOffset = false也就是说,假设索引文件中的索引条目为 0,说明索引文件内容为空,那么直接可以追加索引,而如果索引文件中有索引条目了,需要消息批次中的 baseOffset 大于索引文件最后一个条目中的位移,因为索引文件是递增的,因此不允许比最后一个条目的索引还小的消息位移。现在也就很好理解了,产生这个异常报错的根本原因,是因为后面的消息批次中,有位移比最后索引位移还要小(或者等于)。前面也说过了,消息批次中的 baseOffset 不一定是第一条记录的偏移量,那么问题是不是出在这里?我的理解是这里有可能会造成两个消息批次获取到的 baseOffset 有相交的值?对此我并没有继续研究下去了,但我确定的是,在 kafka 2.2.1 版本中,append() 方法中的 offset 已经改成 消息批次中的 lastOffset 了:这里我也需要吐槽一下,**如果出现这个 bug,意味着这个问题除非是将这些故障的日志文件和索引文件删除,否则该节点永远启动不了,这也太暴力了吧?**我花了非常多时间去专门看了很多相关 issue,目前还没看到有解决这个问题的方案?或者我需要继续寻找?我把相关 issue 贴出来:https://issues.apache.org/jira/browse/KAFKA-1211https://issues.apache.org/jira/browse/KAFKA-3919https://issues.apache.org/jira/browse/KAFKA-3955严重建议各位尽快把 Kafka 版本升级到 2.x 版本,旧版本太多问题了,后面我着重研究 2.x 版本的源码。下面我从日志文件结构中继续分析。从日志文件结构中看到问题的本质我们用 Kafka 提供的 DumpLogSegments 工具打开 log 和 index 文件:$ ~/kafka_2.11-0.11.0.2/bin/kafka-run-class.sh kafka.tools.DumpLogSegments --files /dfs5/kafka/data/secLog-2/00000000000110325000.log > secLog.log $ ~/kafka_2.11-0.11.0.2/bin/kafka-run-class.sh kafka.tools.DumpLogSegments --files /dfs5/kafka/data/secLog-2/00000000000110325000.index > secLog-index.log用 less -Nm 命令查看,log 和 index 对比:如上图所示,index最后记录的 offset = 110756715,positioin=182484660,与异常栈显示的一样,说明在进行追加下一个索引块的时候,发现下一个索引块的 offset 索引不大于最后一个索引块的 offset,因此不允许追加,报异常并退出进程,那么问题就出现在下一个消息批次的 baseOffset,根据 log.index.interval.bytes 默认值大小为 4 KB(4096),而追加的条件前面也说了,需要大于 log.index.interval.bytes,因此我们 DumpLogSegments 工具查询:从 dump 信息中可知,在 positioin=182484660 往后的几个消息批次中,它们的大小加起来大于 4096 的消息批次的 offset=110756804,postion=182488996,它的 baseOffset 很可能就是 110756715,与索引文件最后一个索引块的 Offset 相同,因此出现错误。接着我们继续用 DumpLogSegments 工具查看消息批次内容:我们先查看 offset = 110756715,positioin=182484660 的消息块详情:接着寻找 offset = 110756715,的消息批次块:终于找到你了,跟我预测的一样!postion=182488996,在将该消息批次追加到索引文件中,发生 offset 混乱了。总结如果还是没找到官方的处理方案,就只能删除这些错误日志文件和索引文件,然后重启节点?非常遗憾,我在查看了相关的 issue 之后,貌似还没看到官方的解决办法,所幸的是该集群是日志集群,数据丢失也没有太大问题。我也尝试发送邮件给 Kafka 维护者,期待大佬的回应:不过呢,0.11.0.2 版本属于很旧的版本了,因此,升级 Kafka 版本才是长久之计啊!我已经迫不及待地想撸 kafka 源码了!在这个过程中,我学到了很多,同时也意识到想要继续深入研究 Kafka,必须要学会 Scala,才能从源码中一探 Kafka 的各种细节。接下来我还要对关于 Kafka 分区不可用的一些思考,在下一篇章节会讲到,敬请期待!
本人重度依赖 GitHub,面向 GitHub 编程,GitHub 可以让我每天早上打开电脑,假装了解最新开源项目。最近你们有没有发现,GitHub 明显变慢了,如果没有 fanqiang,拉取代码的速度简直惨不忍睹,如果拉取的量少还可以勉强拉下来,但是遇到数据量大的时候,2 KiB/s 的速度你能忍?拉到中途超时就让你痛不欲生。最近我就遇到这个问题,seata 社区的 seata.github.io 仓库有阵子突然增加了好多数据,我发现我已经拉不下来了,这时可以利用 Gitee 作为中间代理,下面详细说说具体操作过程。在 GitHub 中,一共有两个仓库:seata:Github 的 Seata 主仓库为:https://github.com/seata/seata.github.io.gitobjcoding:我从 Seata 主仓库中 fork 过来一个仓库,地址为:https://github.com/objcoding/seata.github.io.git以下内容将用 seat、objcoding 表示这两个仓库。Gitee 创建仓库时,可以导入已有仓库时选择从 GitHub 仓库中导入,这时我们填写 Seata 主仓库地址,意味着 Gitee 仓库将可以从 Seata 主仓库中同步代码 :将 Gitee 仓库 clone 到本地(此时仓库名称默认 origin):git clone https://gitee.com/objcoding/seata.github.io.git这个速度快到我想哭,你能想象GitHub 2 KiB/s 的悲惨人生么。添加 objcoding 远程仓库:git remote add objcoding https://github.com/objcoding/seata.github.io.gitfetch objcoding 远程仓库内容到本地:速度很快,因为远程仓库中的绝大部分代码,已经从 gitee 拉取下来了。添加 seata 远程仓库:git remote add seata https://github.com/seata/seata.github.io.git同理,fetch seata 远程仓库内容到本地。这时候,我本地仓库就拥有了三个远程仓库了,分别是:origin:码云仓库,该仓库可以从 seata 仓库中同步代码;objcoding:从 seata 仓库中 fork 的仓库;seata:seata 主仓库。为什么这里还需要添加 seata 仓库呢?这是因为一般来说,seata 主仓库增加的代码数据量都很少,即使是 2Kib/s 的速度,也是可以拉取下来的,所以平时可以直接从 seata 主仓库中拉取最新代码就可以了,但是像 seata.github.io 仓库,突然某个大佬上传了几十兆数据,那么此时我就可以利用 Gitee 仓库去同步这些代码,具体操作如下:接下来 fetch gitee 对应的分支,就可以将这些数据拉取下来了。以上是整个同步过程分析。
收到某业务组的小伙伴发来的反馈,具体问题如下:项目中某 kafka 消息组消费特别慢,有时候在 kafka-manager 控制台看到有些消费者已被踢出消费组。从服务端日志看到如下信息:该消费组在短时间内重平衡了 600 多次。从 cat 查看得知,每条消息处理都会有 4 次数据库的交互,经过一番沟通之后,发现每条消息的处理耗时大概率保持在 200ms 以上。Kafka 发生重平衡的有以下几种情况:消费组成员发生变更,有新消费者加入或者离开,或者有消费者崩溃;消费组订阅的主题数量发生变更;消费组订阅的分区数发生变更。在第 2、3 点都没有发生的情况下,那么就是由消费组成员发生了变化导致 Kafka 发生重平衡。在查看 kafka 客户端日志,发现有很多如下日志:日志的描述得知,消费者被被剔除的原因是调用 poll() 方法消费耗时太久了,其中有提到 max.poll.interval.ms 和 max.poll.records 两个参数,而且还会导致提交max.poll.interval.ms 表示消费者处理消息逻辑的最大时间,对于某些业务来说,处理消息可能需要很长时间,比如需要 1 分钟,那么该参数就需要设置成大于 1分钟的值,否则就会被 Coordinator 剔除消息组然后重平衡, 默认值为 300000;max.poll.records 表示每次默认拉取消息条数,默认值为 500。我们来计算一下:200 * 500 = 100000 < max.poll.interval.ms =300000,前面我也讲了,当每条消息处理时间大概率会超过 200ms。结论:本次出现的问题是由于客户端的消息消费逻辑耗时太长,如果生产端出现消息发送增多,消费端每次都拉取了 500 条消息进行消费,这时就很容易导致消费时间过长,如果超过了 max.poll.interval.ms 所设置的时间,就会被消费组所在的 coordinator 剔除掉,从而导致重平衡,Kafka 重平衡过程中是不能消费的,会导致消费组处于类似 stop the world 的状态下,重平衡过程中也不能提交位移,这会导致消息重复消费从而使得消费组的消费速度下降,导致消息堆积。解决办法:根据业务逻辑调整 max.poll.records 与 max.poll.interval.ms 之间的平衡点,避免出现消费者被频繁踢出消费组导致重平衡。
Seata 的动态降级需要结合配置中心的动态配置订阅功能。动态配置订阅,即通过配置中心监听订阅,根据需要读取已更新的缓存值,ZK、Apollo、Nacos 等第三方配置中心都有现成的监听器可实现动态刷新配置;动态降级,即通过动态更新指定配置参数值,使得 Seata 能够在运行过程中动态控制全局事务失效(目前只有 AT 模式有这个功能)。那么 Seata 支持的多个配置中心是如何适配不同的动态配置订阅以及如何实现降级的呢?下面从源码的层面详细给大家讲解一番。动态配置订阅Seata 配置中心有一个监听器基准接口,它主要有一个抽象方法和 default 方法,如下:io.seata.config.ConfigurationChangeListener该监听器基准接口主要有两个实现类型:实现注册配置订阅事件监听器:用于实现各种功能的动态配置订阅,比如 GlobalTransactionalInterceptor 实现了 ConfigurationChangeListener,根据动态配置订阅实现的动态降级功能;实现配置中心动态订阅功能与适配:对于目前还没有动态订阅功能的 file 类型默认配置中心,可以实现该基准接口来实现动态配置订阅功能;对于阻塞订阅需要另起一个线程去执行,这时候可以实现该基准接口进行适配,还可以复用该基准接口的线程池;以及还有异步订阅,有订阅单个 key,有订阅多个 key 等等,我们都可以实现该基准接口以适配各个配置中心。这里就用默认的 file 配置中心,以它的实现类 FileListener 举例子,它的实现逻辑如下:如上,dataId:为订阅的配置属性;listener:配置订阅事件监听器,用于将外部传入的 listener 作为一个 wrapper,执行真正的变更逻辑,这里特别需要注意的是,该监听器与 FileListener 同样实现了 ConfigurationChangeListener 接口,只不过 FileListener 是用于给 file 提供动态配置订阅功能,而 listener 用于执行配置订阅事件;executor:用于处理配置变更逻辑的线程池,在 ConfigurationChangeListener#onProcessEvent 方法中用到。FileListener#onChangeEvent 方法的实现让 file 具备了动态配置订阅的功能,它的逻辑如下:无限循环获取订阅的配置属性当前的值,从缓存中获取旧的值,判断是否有变更,如果有变更就执行外部传入 listener 的逻辑。ConfigurationChangeEvent 用于保存配置变更的事件类,它的成员属性如下:这里的 getConfig 方法是如何感知 file 配置的变更呢?我们点进去,发现它最终的逻辑如下:发现它是创建一个 future 类,然后包装成一个 Runnable 放入线程池中异步执行,最后调用 get 方法阻塞获取值,那么我们继续往下看:allowDynamicRefresh:动态刷新配置开关;targetFileLastModified:file 最后更改的时间缓存。以上逻辑:获取 file 最后更新的时间值 tempLastModified,然后对比对比缓存值 targetFileLastModified,如果 tempLastModified > targetFileLastModified,说明期间配置有更改过,这时就重新加载 file 实例,替换掉旧的 fileConfig,使得后面的操作能够获取到最新的配置值。添加一个配置属性监听器的逻辑如下:configListenersMap 为 FileConfiguration 的配置监听器缓存,它的数据结构如下:ConcurrentMap<String/*dataId*/, Set<ConfigurationChangeListener>> configListenersMap从数据结构上可看出,每个配置属性可关联多个事件监听器。最终执行 onProcessEvent 方法,这个是监听器基准接口里面的 default 方法,它会调用 onChangeEvent 方法,即最终会调用 FileListener 中的实现。动态降级有了以上的动态配置订阅功能,我们只需要实现 ConfigurationChangeListener 监听器,就可以做各种功能,目前 Seata 只有动态降级有用到动态配置订阅的功能。在「Seata AT 模式启动源码分析」这篇文章中讲到,Spring 集成 Seata 的项目中,在 AT 模式启动时,会用 用GlobalTransactionalInterceptor 代替了被 GlobalTransactional 和 GlobalLock 注解的方法,GlobalTransactionalInterceptor 实现了 MethodInterceptor,最终会执行 invoker 方法,那么想要实现动态降级,就可以在这里做手脚。在 GlobalTransactionalInterceptor 中加入一个成员变量:private volatile boolean disable;在构造函数中进行初始化赋值:ConfigurationKeys.DISABLE_GLOBAL_TRANSACTION(service.disableGlobalTransaction)这个参数目前有两个功能:在启动时决定是否开启全局事务;在开启全局事务后,决定是否降级。实现 ConfigurationChangeListener:这里的逻辑简单,就是判断监听事件是否属于 ConfigurationKeys.DISABLE_GLOBAL_TRANSACTION 配置属性,如果是,直接更新 disable 值。接下来在 GlobalTransactionalInterceptor#invoke 中做点手脚如上,disable = true 时,不执行全局事务与全局锁。配置中心订阅降级监听器io.seata.spring.annotation.GlobalTransactionScanner#wrapIfNecessary在 Spring AOP 进行 wrap 逻辑过程中,当前配置中心将订阅降级事件监听器。
Seata 可以支持多个第三方配置中心,那么 Seata 是如何同时兼容那么多个配置中心的呢?下面我给大家详细介绍下 Seata 配置中心的实现原理。配置中心属性加载在 Seata 配置中心,有两个默认的配置文件:file.conf 是默认的配置属性,registry.conf 主要存储第三方注册中心与配置中心的信息,主要有两大块:registry { # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa # ... } config { # file、nacos 、apollo、zk、consul、etcd3 type = "file" nacos { serverAddr = "localhost" namespace = "" } file { name = "file.conf" } # ... }其中 registry 为注册中心的配置属性,这里先不讲,config 为配置中心的属性值,默认为 file 类型,即会加载本地的 file.conf 里面的属性,如果 type 为其它类型,那么会从第三方配置中心加载配置属性值。在 config 模块的 core 目录中,有个配置工厂类 ConfigurationFactory,它的结构如下:可以看到都是一些配置的静态常量:REGISTRY_CONF_PREFIX、REGISTRY_CONF_SUFFIX:配置文件名、默认配置文件类型;SYSTEM_PROPERTY_SEATA_CONFIG_NAME、ENV_SEATA_CONFIG_NAME、ENV_SYSTEM_KEY、ENV_PROPERTY_KEY:自定义文件名配置变量,也说明我们可以自定义配置中心的属性文件。ConfigurationFactory 里面有一处静态代码块,如下:io.seata.config.ConfigurationFactory根据自定义文件名配置变量找出配置文件名称与类型,如果没有配置,默认使用 registry.conf,FileConfiguration 是 Seata 默认的配置实现类,如果为默认值,则会更具 registry.conf 配置文件生成 FileConfiguration 默认配置对象,这里也可以利用 SPI 机制支持第三方扩展配置实现,具体实现是继承 ExtConfigurationProvider 接口,在META-INF/services/创建一个文件并填写实现类的全路径名,如下所示:第三方配置中心实现类加载在静态代码块逻辑加载完配置中心属性之后,Seata 是如何选择配置中心并获取配置中心的属性值的呢?我们刚刚也说了 FileConfiguration 是 Seata 的默认配置实现类,它继承了 AbstractConfiguration,它的基类为 Configuration,提供了获取参数值的方法:short getShort(String dataId, int defaultValue, long timeoutMills); int getInt(String dataId, int defaultValue, long timeoutMills); long getLong(String dataId, long defaultValue, long timeoutMills); // ....那么意味着只需要第三方配置中心实现该接口,就可以整合到 Seata 配置中心了,下面我拿 zk 来做例子:首先,第三方配置中心需要实现一个 Provider 类:实现的 provider 方法如其名,主要是输出具体的 Configuration 实现类。那么我们是如何获取根据配置去获取对应的第三方配置中心实现类呢?在 Seata 项目中,获取一个第三方配置中心实现类通常是这么做的:Configuration CONFIG = ConfigurationFactory.getInstance();在 getInstance() 方法中主要是使用了单例模式构造配置实现类,它的构造具体实现如下:io.seata.config.ConfigurationFactory#buildConfiguration:首先从 ConfigurationFactory 中的静态代码块根据 registry.conf 创建的 CURRENT_FILE_INSTANCE 中获取当前环境使用的配置中心,默认为为 File 类型,我们也可以在 registry.conf 配置其它第三方配置中心,这里也是利用了 SPI 机制去加载第三方配置中心的实现类,具体实现如下:如上,即是刚刚我所说的 ZookeeperConfigurationProvider 配置实现输出类,我们再来看看这行代码:EnhancedServiceLoader.load(ConfigurationProvider.class,Objects.requireNonNull(configType).name()).provide();EnhancedServiceLoader 是 Seata SPI 实现核心类,这行代码会加载 META-INF/services/和 META-INF/seata/目录中文件填写的类名,那么如果其中有多个配置中心实现类都被加载了怎么办呢?我们注意到 ZookeeperConfigurationProvider 类的上面有一个注解:@LoadLevel(name = "ZK", order = 1)在加载多个配置中心实现类时,会根据 order 进行排序:io.seata.common.loader.EnhancedServiceLoader#findAllExtensionClass:io.seata.common.loader.EnhancedServiceLoader#loadFile:这样,就不会产生冲突了。但是我们发现 Seata 还可以用这个方法进行选择,Seata 在调用 load 方法时,还传了一个参数:Objects.requireNonNull(configType).name()ConfigType 为配置中心类型,是个枚举类:public enum ConfigType { File, ZK, Nacos, Apollo, Consul, Etcd3, SpringCloudConfig, Custom; }我们注意到,LoadLevel 注解上还有一个 name 属性,在进行筛选实现类时,Seata 还做了这个操作:根据当前 configType 来判断是否等于 LoadLevel 的 name 属性,如果相等,那么就是当前配置的第三方配置中心实现类。第三方配置中心实现类ZookeeperConfiguration 继承了 AbstractConfiguration,它的构造方法如下:构造方法创建了一个 zkClient 对象,这里的 FILE_CONFIG 是什么呢?private static final Configuration FILE_CONFIG = ConfigurationFactory.CURRENT_FILE_INSTANCE;原来就是刚刚静态代码块中创建的 registry.conf 配置实现类,从该配置实现类拿到第三方配置中心的相关属性,构造第三方配置中心客户端,然后实现 Configuration 接口时:就可以利用客户端相关方法去第三方配置获取对应的参数值了。第三方配置中心同步脚本上周末才写好,已经提交 PR 上去了,还处于 review 中,预估会在 Seata 1.0 版本提供给大家使用,敬请期待。具体位置在 Seata 项目的 script 目录中:config.txt 为本地配置好的值,搭建好第三方配置中心之后,运行脚本会将 config.txt 的配置同步到第三方配置中心。
从上一篇文章「分布式事务中间件Seata的设计原理」讲了下 Seata AT 模式的一些设计原理,从中也知道了 AT 模式的三个角色(RM、TM、TC),接下来我会更新 Seata 源码分析系列文章。今天就来分析 Seata AT 模式在启动的时候都做了哪些操作。客户端启动逻辑TM 是负责整个全局事务的管理器,因此一个全局事务是由 TM 开启的,TM 有个全局管理类 GlobalTransaction,结构如下:io.seata.tm.api.GlobalTransactionpublic interface GlobalTransaction { void begin() throws TransactionException; void begin(int timeout) throws TransactionException; void begin(int timeout, String name) throws TransactionException; void commit() throws TransactionException; void rollback() throws TransactionException; GlobalStatus getStatus() throws TransactionException; // ... }可以通过 GlobalTransactionContext 创建一个 GlobalTransaction,然后用 GlobalTransaction 进行全局事务的开启、提交、回滚等操作,因此我们直接用 API 方式使用 Seata AT 模式://init seata; TMClient.init(applicationId, txServiceGroup); RMClient.init(applicationId, txServiceGroup); //trx GlobalTransaction tx = GlobalTransactionContext.getCurrentOrCreate(); try { tx.begin(60000, "testBiz"); // 事务处理 // ... tx.commit(); } catch (Exception exx) { tx.rollback(); throw exx; }如果每次使用全局事务都这样写,难免会造成代码冗余,我们的项目都是基于 Spring 容器,这时我们可以利用 Spring AOP 的特性,用模板模式把这些冗余代码封装模版里,参考 Mybatis-spring 也是做了这么一件事情,那么接下来我们来分析一下基于 Spring 的项目启动 Seata 并注册全局事务时都做了哪些工作。我们开启一个全局事务是在方法上加上 @GlobalTransactional注解,Seata 的 Spring 模块中,有个 GlobalTransactionScanner,它的继承关系如下:public class GlobalTransactionScanner extends AbstractAutoProxyCreator implements InitializingBean, ApplicationContextAware, DisposableBean { // ... }在基于 Spring 项目的启动过程中,对该类会有如下初始化流程:InitializingBean 的 afterPropertiesSet() 方法调用了 initClient() 方法:io.seata.spring.annotation.GlobalTransactionScanner#initClientTMClient.init(applicationId, txServiceGroup); RMClient.init(applicationId, txServiceGroup);对 TM 和 RM 做了初始化操作。TM 初始化io.seata.tm.TMClient#initpublic static void init(String applicationId, String transactionServiceGroup) { // 获取 TmRpcClient 实例 TmRpcClient tmRpcClient = TmRpcClient.getInstance(applicationId, transactionServiceGroup); // 初始化 TM Client tmRpcClient.init(); }调用 TmRpcClient.getInstance() 方法会获取一个 TM 客户端实例,在获取过程中,会创建 Netty 客户端配置文件对象,以及创建 messageExecutor 线程池,该线程池用于在处理各种与服务端的消息交互,在创建 TmRpcClient 实例时,创建 ClientBootstrap,用于管理 Netty 服务的启停,以及 ClientChannelManager,它是专门用于管理 Netty 客户端对象池,Seata 的 Netty 部分配合使用了对象吃,后面在分析网络模块会讲到。io.seata.core.rpc.netty.AbstractRpcRemotingClient#initpublic void init() { clientBootstrap.start(); // 定时尝试连接服务端 timerExecutor.scheduleAtFixedRate(new Runnable() { @Override public void run() { clientChannelManager.reconnect(getTransactionServiceGroup()); } }, SCHEDULE_INTERVAL_MILLS, SCHEDULE_INTERVAL_MILLS, TimeUnit.SECONDS); mergeSendExecutorService = new ThreadPoolExecutor(MAX_MERGE_SEND_THREAD, MAX_MERGE_SEND_THREAD, KEEP_ALIVE_TIME, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(), new NamedThreadFactory(getThreadPrefix(), MAX_MERGE_SEND_THREAD)); mergeSendExecutorService.submit(new MergedSendRunnable()); super.init(); }调用 TM 客户端 init() 方法,最终会启动 netty 客户端(此时还未真正启动,在对象池被调用时才会被真正启动);开启一个定时任务,定时重新发送 RegisterTMRequest(RM 客户端会发送 RegisterRMRequest)请求尝试连接服务端,具体逻辑是在 NettyClientChannelManager 中的 channels 中缓存了客户端 channel,如果此时 channels 不存在获取已过期,那么就会尝试连接服务端以重新获取 channel 并将其缓存到 channels 中;开启一条单独线程,用于处理异步请求发送,这里用得很巧妙,之后在分析网络模块在具体对其进行分析。io.seata.core.rpc.netty.AbstractRpcRemoting#initpublic void init() { timerExecutor.scheduleAtFixedRate(new Runnable() { @Override public void run() { for (Map.Entry<Integer, MessageFuture> entry : futures.entrySet()) { if (entry.getValue().isTimeout()) { futures.remove(entry.getKey()); entry.getValue().setResultMessage(null); if (LOGGER.isDebugEnabled()) { LOGGER.debug("timeout clear future: {}", entry.getValue().getRequestMessage().getBody()); } } } nowMills = System.currentTimeMillis(); } }, TIMEOUT_CHECK_INTERNAL, TIMEOUT_CHECK_INTERNAL, TimeUnit.MILLISECONDS); }在 AbstractRpcRemoting 的 init 方法中,又是开启了一个定时任务,该定时任务主要是用于定时清除 futures 已过期的 futrue,futures 是保存发送请求需要返回结果的 future 对象,该对象有个超时时间,过了超时时间就会自动抛异常,因此需要定时清除已过期的 future 对象。RM 初始化io.seata.rm.RMClient#initpublic static void init(String applicationId, String transactionServiceGroup) { RmRpcClient rmRpcClient = RmRpcClient.getInstance(applicationId, transactionServiceGroup); rmRpcClient.setResourceManager(DefaultResourceManager.get()); rmRpcClient.setClientMessageListener(new RmMessageListener(DefaultRMHandler.get())); rmRpcClient.init(); }RmRpcClient.getInstance 处理逻辑与 TM 大致相同;ResourceManager 是 RM 资源管理器,负责分支事务的注册、提交、上报、以及回滚操作,以及全局锁的查询操作,DefaultResourceManager 会持有当前所有的 RM 资源管理器,进行统一调用处理,而 get() 方法主要是加载当前的资源管理器,主要用了类似 SPI 的机制,进行灵活加载,如下图,Seata 会扫描 META-INF/services/ 目录下的配置类并进行动态加载。ClientMessageListener 是 RM 消息处理监听器,用于负责处理从 TC 发送过来的指令,并对分支进行分支提交、分支回滚,以及 undo log 文件删除操作;最后 init 方法跟 TM 逻辑也大体一致;DefaultRMHandler 封装了 RM 分支事务的一些具体操作逻辑。接下来再看看 wrapIfNecessary 方法究竟做了哪些操作。io.seata.spring.annotation.GlobalTransactionScanner#wrapIfNecessaryprotected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) { // 判断是否有开启全局事务 if (disableGlobalTransaction) { return bean; } try { synchronized (PROXYED_SET) { if (PROXYED_SET.contains(beanName)) { return bean; } interceptor = null; //check TCC proxy if (TCCBeanParserUtils.isTccAutoProxy(bean, beanName, applicationContext)) { //TCC interceptor, proxy bean of sofa:reference/dubbo:reference, and LocalTCC interceptor = new TccActionInterceptor(TCCBeanParserUtils.getRemotingDesc(beanName)); } else { Class<?> serviceInterface = SpringProxyUtils.findTargetClass(bean); Class<?>[] interfacesIfJdk = SpringProxyUtils.findInterfaces(bean); // 判断 bean 中是否有 GlobalTransactional 和 GlobalLock 注解 if (!existsAnnotation(new Class[]{serviceInterface}) && !existsAnnotation(interfacesIfJdk)) { return bean; } if (interceptor == null) { // 创建代理类 interceptor = new GlobalTransactionalInterceptor(failureHandlerHook); } } LOGGER.info("Bean[{}] with name [{}] would use interceptor [{}]", bean.getClass().getName(), beanName, interceptor.getClass().getName()); if (!AopUtils.isAopProxy(bean)) { bean = super.wrapIfNecessary(bean, beanName, cacheKey); } else { AdvisedSupport advised = SpringProxyUtils.getAdvisedSupport(bean); // 执行包装目标对象到代理对象 Advisor[] advisor = super.buildAdvisors(beanName, getAdvicesAndAdvisorsForBean(null, null, null)); for (Advisor avr : advisor) { advised.addAdvisor(0, avr); } } PROXYED_SET.add(beanName); return bean; } } catch (Exception exx) { throw new RuntimeException(exx); } }GlobalTransactionScanner 继承了 AbstractAutoProxyCreator,用于对 Spring AOP 支持,从代码中可看出,用GlobalTransactionalInterceptor 代替了被 GlobalTransactional 和 GlobalLock 注解的方法。GlobalTransactionalInterceptor 实现了 MethodInterceptor:io.seata.spring.annotation.GlobalTransactionalInterceptor#invokepublic Object invoke(final MethodInvocation methodInvocation) throws Throwable { Class<?> targetClass = methodInvocation.getThis() != null ? AopUtils.getTargetClass(methodInvocation.getThis()) : null; Method specificMethod = ClassUtils.getMostSpecificMethod(methodInvocation.getMethod(), targetClass); final Method method = BridgeMethodResolver.findBridgedMethod(specificMethod); final GlobalTransactional globalTransactionalAnnotation = getAnnotation(method, GlobalTransactional.class); final GlobalLock globalLockAnnotation = getAnnotation(method, GlobalLock.class); if (globalTransactionalAnnotation != null) { // 全局事务注解 return handleGlobalTransaction(methodInvocation, globalTransactionalAnnotation); } else if (globalLockAnnotation != null) { // 全局锁注解 return handleGlobalLock(methodInvocation); } else { return methodInvocation.proceed(); } }以上是代理方法执行的逻辑逻辑,其中 handleGlobalTransaction() 方法里面调用了 TransactionalTemplate 模版:io.seata.spring.annotation.GlobalTransactionalInterceptor#handleGlobalTransactionprivate Object handleGlobalTransaction(final MethodInvocation methodInvocation, final GlobalTransactional globalTrxAnno) throws Throwable { try { return transactionalTemplate.execute(new TransactionalExecutor() { @Override public Object execute() throws Throwable { return methodInvocation.proceed(); } @Override public TransactionInfo getTransactionInfo() { // ... } }); } catch (TransactionalExecutor.ExecutionException e) { // ... } }handleGlobalTransaction() 方法执行了就是 TransactionalTemplate 模版类的 execute 方法:io.seata.tm.api.TransactionalTemplate#executepublic Object execute(TransactionalExecutor business) throws Throwable { // 1. get or create a transaction GlobalTransaction tx = GlobalTransactionContext.getCurrentOrCreate(); // 1.1 get transactionInfo TransactionInfo txInfo = business.getTransactionInfo(); if (txInfo == null) { throw new ShouldNeverHappenException("transactionInfo does not exist"); } try { // 2. begin transaction beginTransaction(txInfo, tx); Object rs = null; try { // Do Your Business rs = business.execute(); } catch (Throwable ex) { // 3.the needed business exception to rollback. completeTransactionAfterThrowing(txInfo,tx,ex); throw ex; } // 4. everything is fine, commit. commitTransaction(tx); return rs; } finally { //5. clear triggerAfterCompletion(); cleanUp(); } }以上是不是有一种似曾相识的感觉?没错,以上就是我们使用 API 时经常写的冗余代码,现在 Spring 通过代理模式,把这些冗余代码都封装带模版里面了,它将那些冗余代码统统封装起来统一流程处理,并不需要你显示写出来了,有兴趣的也可以去看看 Mybatis-spring 的源码,也是写得非常精彩。服务端处理逻辑服务端收到客户端的连接,那当然是将其 channel 也缓存起来,前面也说到客户端会发送 RegisterRMRequest/RegisterTMRequest 请求给服务端,服务端收到后会调用 ServerMessageListener 监听器处理:io.seata.core.rpc.ServerMessageListenerpublic interface ServerMessageListener { // 处理各种事务,如分支注册、分支提交、分支上报、分支回滚等等 void onTrxMessage(RpcMessage request, ChannelHandlerContext ctx, ServerMessageSender sender); // 处理 RM 客户端的注册连接 void onRegRmMessage(RpcMessage request, ChannelHandlerContext ctx, ServerMessageSender sender, RegisterCheckAuthHandler checkAuthHandler); // 处理 TM 客户端的注册连接 void onRegTmMessage(RpcMessage request, ChannelHandlerContext ctx, ServerMessageSender sender, RegisterCheckAuthHandler checkAuthHandler); // 服务端与客户端保持心跳 void onCheckMessage(RpcMessage request, ChannelHandlerContext ctx, ServerMessageSender sender) }ChannelManager 是服务端 channel 的管理器,服务端每次和客户端通信,都需要从 ChannelManager 中获取客户端对应的 channel,它用于保存 TM 和 RM 客户端 channel 的缓存结构如下:/** * resourceId -> applicationId -> ip -> port -> RpcContext */ private static final ConcurrentMap<String, ConcurrentMap<String, ConcurrentMap<String, ConcurrentMap<Integer, RpcContext>>>> RM_CHANNELS = new ConcurrentHashMap<String, ConcurrentMap<String, ConcurrentMap<String, ConcurrentMap<Integer, RpcContext>>>>(); /** * ip+appname,port */ private static final ConcurrentMap<String, ConcurrentMap<Integer, RpcContext>> TM_CHANNELS = new ConcurrentHashMap<String, ConcurrentMap<Integer, RpcContext>>();以上的 Map 结构有点复杂:RM_CHANNELS:resourceId 指的是 RM client 的数据库地址;applicationId 指的是 RM client 的服务 Id,比如 springboot 的配置 spring.application.name=account-service 中的 account-service 即是 applicationId;ip 指的是 RM client 服务地址;port 指的是 RM client 服务地址;RpcContext 保存了本次注册请求的信息。TM_CHANNELS:ip+appname:这里的注释应该是写错了,应该是 appname+ip,即 TM_CHANNELS 的 Map 结构第一个 key 为 appname+ip;port:客户端的端口号。以下是 RM Client 注册逻辑:io.seata.core.rpc.ChannelManager#registerRMChannelpublic static void registerRMChannel(RegisterRMRequest resourceManagerRequest, Channel channel) throws IncompatibleVersionException { Version.checkVersion(resourceManagerRequest.getVersion()); // 将 ResourceIds 数据库连接连接信息放入一个set中 Set<String> dbkeySet = dbKeytoSet(resourceManagerRequest.getResourceIds()); RpcContext rpcContext; // 从缓存中判断是否有该channel信息 if (!IDENTIFIED_CHANNELS.containsKey(channel)) { // 根据请求注册信息,构建 rpcContext rpcContext = buildChannelHolder(NettyPoolKey.TransactionRole.RMROLE, resourceManagerRequest.getVersion(), resourceManagerRequest.getApplicationId(), resourceManagerRequest.getTransactionServiceGroup(), resourceManagerRequest.getResourceIds(), channel); // 将 rpcContext 放入缓存中 rpcContext.holdInIdentifiedChannels(IDENTIFIED_CHANNELS); } else { rpcContext = IDENTIFIED_CHANNELS.get(channel); rpcContext.addResources(dbkeySet); } if (null == dbkeySet || dbkeySet.isEmpty()) { return; } for (String resourceId : dbkeySet) { String clientIp; // 将请求信息存入 RM_CHANNELS 中,这里用了 java8 的 computeIfAbsent 方法操作 ConcurrentMap<Integer, RpcContext> portMap = RM_CHANNELS.computeIfAbsent(resourceId, resourceIdKey -> new ConcurrentHashMap<>()) .computeIfAbsent(resourceManagerRequest.getApplicationId(), applicationId -> new ConcurrentHashMap<>()) .computeIfAbsent(clientIp = getClientIpFromChannel(channel), clientIpKey -> new ConcurrentHashMap<>()); // 将当前 rpcContext 放入 portMap 中 rpcContext.holdInResourceManagerChannels(resourceId, portMap); updateChannelsResource(resourceId, clientIp, resourceManagerRequest.getApplicationId()); } }从以上代码逻辑能够看出,注册 RM client 主要是将注册请求信息,放入 RM_CHANNELS 缓存中,同时还会从 IDENTIFIED_CHANNELS 中判断本次请求的 channel 是否已验证过,IDENTIFIED_CHANNELS 的结构如下:private static final ConcurrentMap<Channel, RpcContext> IDENTIFIED_CHANNELS = new ConcurrentHashMap<>();IDENTIFIED_CHANNELS 包含了所有 TM 和 RM 已注册的 channel。以下是 TM 注册逻辑:io.seata.core.rpc.ChannelManager#registerTMChannelpublic static void registerTMChannel(RegisterTMRequest request, Channel channel) throws IncompatibleVersionException { Version.checkVersion(request.getVersion()); // 根据请求注册信息,构建 RpcContext RpcContext rpcContext = buildChannelHolder(NettyPoolKey.TransactionRole.TMROLE, request.getVersion(), request.getApplicationId(), request.getTransactionServiceGroup(), null, channel); // 将 RpcContext 放入 IDENTIFIED_CHANNELS 缓存中 rpcContext.holdInIdentifiedChannels(IDENTIFIED_CHANNELS); // account-service:127.0.0.1:63353 String clientIdentified = rpcContext.getApplicationId() + Constants.CLIENT_ID_SPLIT_CHAR + getClientIpFromChannel(channel); // 将请求信息存入 TM_CHANNELS 缓存中 TM_CHANNELS.putIfAbsent(clientIdentified, new ConcurrentHashMap<Integer, RpcContext>()); // 将上一步创建好的get出来,之后再将rpcContext放入这个map的value中 ConcurrentMap<Integer, RpcContext> clientIdentifiedMap = TM_CHANNELS.get(clientIdentified); rpcContext.holdInClientChannels(clientIdentifiedMap); }TM client 的注册大体类似,把本次注册的信息放入对应的缓存中保存,但比 RM client 的注册逻辑简单一些,主要是 RM client 会涉及分支事务资源的信息,需要注册的信息也会比 TM client 多。以上源码分析基于 0.9.0 版本。
之前有个 Kafka 集群的每个节点的挂载磁盘多达 20+ 个,平均每个磁盘约 1T,每个节点的分区日志被平均分配到这些磁盘中,但由于每个分区的数据不一致,而集群节点 log.retention.bytes 这个参数的默认值是 -1,也就是没有任何限制,因此 Kafka 的日志删除日志依赖 log.retention.hours 参数来删除,因此会出现日志未过期,磁盘写满的情况。针对该集群双十一会遇到某些挂载磁盘被写满的情况,需要手动对主题进行删除以清空磁盘的操作,现在分析删除主题对集群以及客户端会有什么影响,以及 Kafka 都做了哪些动作。图解删除过程1. 删除主题删除主题有多种方法,可通过 kafka-topic.sh 脚本并执行 --delete 命令,或者用暴力方式直接在 zk 删除对应主题节点,其实删除主题无非就是令 zk 节点删除,以触发 controller 对应监听器,然后再通过监听器通知到所有 broker,具体流程如下:删除主题执行后,controller 监听到 zk 主题节点被删除,通知到所有 broker 删除主题对应的副本,这里会分成两个步骤,第一个步骤先将下线主题对应的副本,最后才执行真正的删除操作,注意,这里也并为真正的将主题从磁盘中删除,此时仅仅只会将要删除的副本所在的目录重命名,以免之后创建主题时目录有冲突,每个 broker 都会有一个定时线程,定时清除已重命名为删除状态的日志文件,具体如下:2. 自动创建主题自动创建主题的前提是 broker 配置参数 auto.create.topic.enble=true,删除主题后,当 Producer 发送时会对发送进行重试,期间会发送 MetadataRquest 命令到 broker 请求获取最新的元数据,在获取元数据的同时,会判断是否需要自动创建主题,如果需要,则调用 zk 客户端创建主题节点,controller 监听到有新主题创建,就会触发 controller 相关状态机工作创建主题。刚刚也说过,kafka 重命名要删除的主题后,并不会立马就会删除,而是等待异步线程去删除,如下图所示,重命名后与重新创建的分区不冲突,可以证明删除是异步执行的了,且不影响生产发送,但是被重命名后的日志就不能消费了,即丢失了。如下图可看出,在一分钟后,重命名后的副本被删除。相关日志分析1、controller.log触发删除主题监听器:[2019-11-07 19:24:11,121] DEBUG [Controller id=0] Delete topics listener fired for topics test-topic to be deleted (kafka.controller.KafkaController)开始删除主题操作:[2019-11-07 19:24:11,121] INFO [Topic Deletion Manager 0] Handling deletion for topics test-topic (kafka.controller.TopicDeletionManager)开始停止主题,但此时并未删除:[2019-11-07 19:24:11,143] DEBUG The stop replica request (delete = false) sent to broker 2 is StopReplicaRequestInfo([Topic=test-topic,Partition=1,Replica=2],false),StopReplicaRequestInfo([Topic=test-topic,Partition=0,Replica=2],false),StopReplicaRequestInfo([Topic=test-topic,Partition=2,Replica=2],false) (kafka.controller.ControllerBrokerRequestBatch)开始执行真正的删除动作:[2019-11-07 19:24:11,145] DEBUG [Topic Deletion Manager 0] Deletion started for replicas [2019-11-07 19:24:11,147] DEBUG The stop replica request (delete = true) sent to broker 2 is StopReplicaRequestInfo([Topic=test-topic,Partition=1,Replica=2],true),StopReplicaRequestInfo([Topic=test-topic,Partition=0,Replica=2],true),StopReplicaRequestInfo([Topic=test-topic,Partition=2,Replica=2],true) (kafka.controller.ControllerBrokerRequestBatch)收到 broker 删除的回调:[2019-11-07 19:24:11,170] DEBUG [Controller id=0] Delete topic callback invoked on StopReplica response received from broker 2: request error = NONE, partition errors = Map(test-topic-2 -> NONE, test-topic-0 -> NONE, test-topic-1 -> NONE) (kafka.controller.KafkaController) [2019-11-07 19:24:11,170] DEBUG [Topic Deletion Manager 0] Deletion successfully completed for replicas已经成功全部删除:[2019-11-07 19:24:11,202] INFO [Topic Deletion Manager 0] Deletion of topic test-topic successfully completed (kafka.controller.TopicDeletionManager)如果此时有新的消息写入,会自动创建主题:[2019-11-07 19:24:11,203] INFO [Controller id=0] New topics: [Set()], deleted topics: [Set()], new partition replica assignment [Map()] (kafka.controller.KafkaController) [2019-11-07 19:24:11,267] INFO [Controller id=0] New topics: [Set(test-topic)], deleted topics: [Set()], new partition replica assignment [Map(test-topic-2 -> Vector(1, 2, 0), test-topic-1 -> Vector(0, 1, 2), test-topic-0 -> Vector(2, 0, 1))] (kafka.controller.KafkaController) [2019-11-07 19:24:11,267] INFO [Controller id=0] New partition creation callback for test-topic-2,test-topic-1,test-topic-0 (kafka.controller.KafkaController)2、server.logbroker 收到删除主题通通知(此时并没有删除):[2019-11-07 19:24:11,144] INFO [ReplicaFetcherManager on broker 2] Removed fetcher for partitions Set(test-topic-2, test-topic-0, test-topic-1) (kafka.server.ReplicaFetcherManager)停止分区 fetch 线程:[2019-11-07 19:24:11,145] INFO [ReplicaFetcher replicaId=2, leaderId=1, fetcherId=0] Shutting down (kafka.server.ReplicaFetcherThread) [2019-11-07 19:24:11,146] INFO [ReplicaFetcher replicaId=2, leaderId=1, fetcherId=0] Error sending fetch request (sessionId=293639440, epoch=1824) to node 1: java.io.IOException: Client was shutdown before response was read. (org.apache.kafka.clients.FetchSessionHandler) [2019-11-07 19:24:11,146] INFO [ReplicaFetcher replicaId=2, leaderId=1, fetcherId=0] Stopped (kafka.server.ReplicaFetcherThread) [2019-11-07 19:24:11,147] INFO [ReplicaFetcher replicaId=2, leaderId=1, fetcherId=0] Shutdown completed (kafka.server.ReplicaFetcherThread)接收到真正删除主题指令后,会重命名分区日志目录,此时还未删除,会等待异步线程执行:[2019-11-07 19:24:11,157] INFO Log for partition test-topic-2 is renamed to /tmp/kafka-logs/kafka_3/test-topic-2.93ed68ff29d64a01a3f15937859124f7-delete and is scheduled for deletion (kafka.log.LogManager)如果此时有新的消息写入,会自动创建主题:[2019-11-08 15:39:39,343] INFO Creating topic test-topic with configuration {} and initial partition assignment Map(2 -> ArrayBuffer(1, 0, 2), 1 -> ArrayBuffer(0, 2, 1), 0 -> ArrayBuffer(2, 1, 0)) (kafka.zk.AdminZkClient) [2019-11-08 15:39:39,369] INFO [KafkaApi-1] Auto creation of topic test-topic with 3 partitions and replication factor 3 is successful (kafka.server.KafkaApis) [2019-11-07 19:24:11,286] INFO Created log for partition test-topic-0 in /tmp/kafka-logs/kafka_3 with properties {...}异步线程删除重命名后的主题:[2019-11-07 19:25:11,161] INFO Deleted log /tmp/kafka-logs/kafka_3/test-topic-2.93ed68ff29d64a01a3f15937859124f7-delete/00000000000000000000.log. (kafka.log.LogSegment) [2019-11-07 19:25:11,163] INFO Deleted offset index /tmp/kafka-logs/kafka_3/test-topic-2.93ed68ff29d64a01a3f15937859124f7-delete/00000000000000000000.index. (kafka.log.LogSegment) [2019-11-07 19:25:11,164] INFO Deleted time index /tmp/kafka-logs/kafka_3/test-topic-2.93ed68ff29d64a01a3f15937859124f7-delete/00000000000000000000.timeindex. (kafka.log.LogSegment) [2019-11-07 19:25:11,165] INFO Deleted log for partition test-topic-2 in /tmp/kaf
这是很早以前在我的博客上写的关于 SpringCloud 的一些实战笔记,现在我把这些实战笔记集合起来贴到这里,可能会对一些刚刚接触 SpringCloud 微服务的小伙伴有帮助。SpringBoot 构建项目在我们使用传统的 spring 开发一个 web 应用程序通常会想到一些基本的需要:web.xml 文件(配置 springMVC 的 DispatcherServlet,各种过滤器等等);启用了 springMVC 的 spring 配置文件;mybatis 等数据库配置文件等。以上的这些仅仅只是基本的需求,无论是开发一个大型项目或者只是一个 hello word 程序,都需要配置几乎同等的配置文件,既然这些都是通用的东西,那有什么东西可以把这些给自动配置了呢?这时候 springboot 的自动配置功能就派上用场了,springboot 会为这些常用的配置进行自动配置。这些自动配置涉及很多方面,比如:java 持久化 api,各种 web 模板,springMVC 等等。1. 起步依赖平时我们使用 maven 创建一个 web 项目的时候,常常需要想项目需要哪些包,以及包的版本。但是在 springboot 创建 web 应用的时候,你只需你只需添加 springboot 的 Web 起步依赖(org.springframework.boot:spring-boot-starter-web)。它会根据依赖传递把其他所需依赖引入项目里面。而其它你需要的功能,你只需要引入相关的的起步依赖即可。2. 内嵌 Servlet 容器其实 springboot 并不是一个应用服务器,它之所以可以运行 web 应用程序,是因为其内部已经内嵌了一个 Servlet 容器(Tomcat、Jetty 或 Undertow),其运行原理是把 web 应用直接打包成为一个 jar/war,然后这个 jar/war 是可以直接启动的,不需要另外配置一个 Web Server。相关的 embed 类就是它的依赖包。3. Spring Initializr 构建 springboot 应用程序本文使用的是 intellij idea 中的 Spring Initializr 工具创建 springboot 应用程序。菜单栏中选择File=>New=>Project..,步骤大概是选择构建的工程类型,如:maven,Gradle;language 的选择;选择 Spring Boot 版本和起步依赖包等等。具体创建步骤这里就省略了。spring boot 项目结构如图所示,整个项目结构遵循了 maven 项目的布局,主要的应用程序代码位于 src/main/java 目录里,资源都在 src/main/resources 目录里,测试代码则在 src/test/java 目录里。不同的是,web 页面模板移到 templates 了,我的项目现在主要用 thymeleaf 模板作为 web 页面。在结构图你会发现一些与 springboot 密切项目的文件:WebGatewayApplication.java:应用程序的启动引导类(bootstrap class),也是主要的 Spring 配置类;application.properties:用于配置应用程序和 Spring Boot 的属性;ReadingListApplicationTests.java:一个基本的集成测试类。banner.txt:spring boot 应用程序启动时加载的文件。3.1 启动引导 Spring前面我们看到的 WebGatewayApplication.java 在 springboot 应用程序中主要有两个作用:配置和启动引导。而也是 Spring 的主要配置类。虽然 springboot 的自动配置免除了很多 Spring 配置,但你还需要进行少量配置来启用自动配置。程序清单:package com.crm; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication // 开启组件扫描和自动配置 public class WebGatewayApplication { public static void main(String[] args) { SpringApplication.run(WebGatewayApplication.class, args);// 启动引导应用程序 } }3.2 配置应用程序属性用 Spring Initializr 生成的 application.properties 文件只是一个空文件,它可以删除完全不影响应用程序的运行,但是,如果你想修改应用程序的属性,你就得在里面配置相关属性了,比如你在里面配置了 server.port=9010,嵌入式的 tomcat 服务器的监听端口就不是默认的 8080 了,变成了 9010。而且这个属性文件是自动被加载的。这是我的项目 application.properties 属性配置:###### MySQL配置 spring.datasource.name=test spring.datasource.url=jdbc:mysql://localhost:3306/crm?characterEncoding=UTF8 spring.datasource.username=zch spring.datasource.password=123456 spring.datasource.driver-class-name=com.mysql.jdbc.Driver spring.datasource.filters=stat spring.datasource.maxActive=20 spring.datasource.initialSize=1 spring.datasource.maxWait=60000 spring.datasource.minIdle=1 spring.datasource.timeBetweenEvictionRunsMillis=60000 spring.datasource.minEvictableIdleTimeMillis=300000 spring.datasource.validationQuery=select 'x' spring.datasource.testWhileIdle=true spring.datasource.testOnBorrow=false spring.datasource.testOnReturn=false spring.datasource.poolPreparedStatements=true spring.datasource.maxOpenPreparedStatements=20 ###### mybatis mybatis.typeAliasesPackage=com.joosure.integral.cloud.pojo.cloud mybatis.mapperLocations=classpath:mapper/*.xml ####### thymeleaf spring.thymeleaf.cache=false spring.thymeleaf.check-template-location=true spring.thymeleaf.content-type=text/html spring.thymeleaf.enabled=true spring.thymeleaf.encoding=UTF-8 spring.thymeleaf.excluded-view-names= spring.thymeleaf.mode=HTML5 spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html spring.thymeleaf.template-resolver-order=3.3 构建过程解释我的项目用的是 maven 作为构建工具,因此用 Spring Initializr 会生成 pom.xml 文件,这与创建普通的 maven 项目一样,代码清单如下:<version>0.0.1-SNAPSHOT</version> <packaging>jar</packaging> <name>crm</name> <description>crm-system</description> <parent> <!-- 从spring-boot-starterparent继承版本号 --> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>1.5.3.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <java.version>1.8</java.version> </properties> <dependencies><!-- 起步依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <!--web及模板引擎--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--数据库--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>1.3.0</version> </dependency> <!--测试--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build><!-- 运行spring boot插件 --> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>其中 Artifact ID 为 spring-boot-starter-xxx 的都是 spring boot 起步依赖;构建插件的主要功能是把项目打包成一个可执行的超级 JAR(uber-JAR),包括把应用程序的所有依赖打入 JAR 文件内,并为 JAR 添加一个描述文件,其中的内容能让你用 java -jar 来运行应用程序;Maven 构建说明中还将 spring-boot-starter-parent 作为上一级,这样一来就能利用 Maven 的依赖管理功能,继承很多常用库的依赖版本,在你声明依赖时就不用再去指定版本号了。服务注册与发现现在公司的积分联盟平台系统构建于公司内部的第 4 代架构中,而第 4 代就是 基于 SpringCloud 的微服务架构,趁着项目上手,花了几天研究了一下。SpringCloud 是一个庞大的分布式系统,它包含了众多模块,其中主要有:服务发现(Eureka),断路器(Hystrix),智能路由(Zuul),客户端负载均衡(Ribbon)等。也就是说微服务架构就是将一个完整的应用从数据存储开始垂直拆分成多个不同的服务,每个服务都能独立部署、独立维护、独立扩展,服务与服务间通过诸如 RESTful API 的方式互相调用。1. 创建服务注册中心在搭建 SpringCloud 分布式系统前我们需要创建一个注册服务中心,以便监控其余模块的状况。这里需要在 pom.xml 中引入:<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-eureka-server</artifactId> </dependency>并且在 SpringBoot 主程序中加入@EnableEurekaServer 注解:@EnableEurekaServer @SpringCloudApplication public class EurekaServerApplication { public static void main(String[] args) { SpringApplication.run(EurekaServerApplication.class, args); } }接下来在 SpringBoot 的属性配置文件 application.properties 中如下配置:server.port=9100 eureka.client.register-with-eureka=false eureka.client.fetch-registry=false eureka.client.serviceUrl.defaultZone=http://localhost:${server.port}/eureka/server.port 就是你指定注册服务中心的端口号,在启动服务后,可以通过访问http://localhost:9100服务发现页面,如下:2. 创建服务方我们可以发现其它系统在这里注册并显示在页面上了,想要注册到服务中心,需要在系统上做一些配置,步骤跟创建服务注册中心类似,这里 web-gateway 系统做例子:首先在 pom.xml 中加入:<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-eureka</artifactId> </dependency>在 SpringBoot 主程序中加入@EnableDiscoveryClient 注解,该注解能激活 Eureka 中的DiscoveryClient实现,才能实现 Controller 中对服务信息的输出:@EnableDiscoveryClient @SpringBootApplication public class WebGatewayApplication { public static void main(String[] args) { SpringApplication.run(WebGatewayApplication.class, args); } }在 SpringBoot 的属性配置文件 application.properties 中如下配置:spring.application.name=web-gateway server.port=9010 eureka.client.serviceUrl.defaultZone=http://localhost:9100/eureka/ eureka.instance.leaseRenewalIntervalInSeconds=5再次启动服务中心,打开链接:http://localhost:9100/,就可以看到刚刚创建的服务了。服务消费者在系统与系统之间,如何进行相互间的调用呢?也就是说怎么去调用服务提供的接口内容呢?这里就要说一下 Ribbon 了,Ribbon 是一个基于 http 和 tcp 客户端的负载均衡器。下面我来简单介绍如何在 SpringCloud 分布式系统下使用 Ribbon 来实现负载均衡。首先在 pom.xml 中引入一下依赖:<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-ribbon</artifactId> </dependency>然后在 spring boot 主程序中创建 RestTemplate 类,并为它加上@LoadBalanced 注解开启负载均衡的能力:@EnableDiscoveryClient @SpringBootApplication public class WebGatewayApplication { @Bean @LoadBalanced RestTemplate restTemplate() { return new RestTemplate(); } public static void main(String[] args) { SpringApplication.run(WebGatewayApplication.class, args); } }RestTemplate 类是 Spring 用于构建 Restful 服务而提供的一种 Rest 服务可客户端,RestTemplate 提供了多种便捷访问远程 Http 服务的方法。在 apllication.properties 配置文件中配置 eureka 服务,并注册到服务中心:spring.application.name=integral-server server.port=9600 eureka.client.serviceUrl.defaultZone=http://localhost:9100/eureka/在公司项目中正是通过 RestTemplate 来访问各个微服务提供的接口,比如在项目中要访问积分系统 integral-server,添加积分用户:JSONObject integralServerResult = restTemplate.postForObject("http://integral-server/shop/add", RequestHandler.getRestRawRequestEntity(integralShopJson), JSONObject.class);这样就可以调用 integral-server 系统的添加用户的接口实现在别的系统中添加用户了。我们也可以在 application.properties 配置文件中加入:###### Ribbon ribbon.ReadTimeout=60000这个是设置负载均衡的超时时间的。断路器微服务架构中,各个系统被拆分成一个个服务单元,链路调用可能包括很多个服务单元,而每个单元又会个 N 个服务单元提供服务,因此如果有一个服务单元出现故障,就可能导致其它依赖此服务的服务单元出现延迟,导致整个微服务系统出现雪崩效应。在 SpringCloud 模块中有一个叫 Netflix Hystrix 的断路器模块,就是专门解决这个问题而生的,Hystrix 是 Netflix 开源的微服务框架套件之一,该框架目标在于通过控制那些访问远程系统、服务和第三方库的节点,从而对延迟和故障提供更强大的容错能力。下面来说一下 Hystrix 在微服务系统中的具体用法:首先还是在 pom.xml 中加入以下依赖:<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-hystrix</artifactId> </dependency>在 spring boot 主程序中加入@EnableCircuitBreaker 注解开启断路器模式:@EnableEurekaClient @EnableCircuitBreaker @EnableDiscoveryClient @SpringBootApplication public class WebGatewayApplication { @Bean @LoadBalanced RestTemplate restTemplate() { return new RestTemplate(); } public static void main(String[] args) { SpringApplication.run(WebGatewayApplication.class, args); }如果在调用过程中返回类似这样的响应:Whitelabel Error Page This application has no explicit mapping for /error, so you are seeing this as a fallback. Sat May 13 00:10:22 CST 2017 There was an unexpected error (type=Internal Server Error, status=500). 400 null断路器也就开启了。我们也可以在 application.properties 配置文件中加入:## hystrix hystrix.commond.default.execution.isolation.thread.timeoutInMilliseconds=60000这个设置可以更改返回错误响应的超时时间。如果不想返回默认的错误响应信息,我们还可以通过自定义来更改错误响应信息,我们需要一个类中注入一个 RestTemplate 类:@Autowired RestTemplate restTemplate;这个类在上面已经通过 Spring 创建好了,这里直接注入在类中即可,接下来我们在类中写一个方法:@HystrixCommand(fallbackMethod = "addServiceFallback") public String addService() { return restTemplate.postForObject("http://integral-server/shop/add", RequestHandler.getRestRawRequestEntity(integralShopJson), JSONObject.class); } public String addServiceFallback() { return "error"; }当调用 integral-server 系统的添加接口超出延时的时间时,就会返回“error”。服务网关前面我们通过 Ribbon 实现服务的消费和负载均衡,但还有些不足的地方,举个例子,服务 A 和服务 B,他们都注册到服务注册中心,这里还有个对外提供的一个服务,这个服务通过负载均衡提供调用服务 A 和服务 B 的方法,那么问题来了,每个服务都变得有状态了,即每个服务都需要维护一套校验逻辑,这样会带来对外接口有污染。而且权限等不好集中管理,整个集群处于混乱之中。最好的方法就是把所有请求都集中在最前端的地方,这地方就是 zuul 服务网关。服务网关是微服务架构组件中处于最外一层,通过服务网关统一,可以将链路前端集中管理起来,除了具备服务路由、均衡负载功能之外,它还需要具备权限控制等功能。Spring Cloud Netflix 中的 Zuul 就担任了这样的一个角色,为微服务披上了一层保护层,也方便了权限校验集中管理,增加了接口的通用性。1. 配置服务路由要使用 zuul,就要引入它的依赖:<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-zuul</artifactId> </dependency>在 spring boot 主程序中加入@EnableZuulProxy 注解开启 zuul:@EnableEurekaClient @EnableZuulProxy @EnableDiscoveryClient @SpringBootApplication public class WebGatewayApplication { @Bean @LoadBalanced RestTemplate restTemplate() { return new RestTemplate(); } public static void main(String[] args) { SpringApplication.run(WebGatewayApplication.class, args); } }在 application.properties 配置文件中配置 zuul 路由 url:spring.application.name=web-gateway server.port=9010到这里,一个微服务 zuul 服务网关系统已经可以运行了,接下来就是如何配置访问其它微服务系统的 url,zuul 提供了两种配置方式,一种是通过 url 直接映射,另一种是利用注册到 eureka server 中的服务 id 作映射:url 直接映射:zuul.routes.api-integral.path=/api-integral-url/** zuul.routes.api-integral.url=http://localhost:8080/以上规则意思是 /api-integral-url/** 的访问都会被路由到 http://localhost:8080/上。但是这么做必须得知道所有的微服务的地址,才能完成配置,这时我们可以利用注册到 eureka server 中的服务 id 作映射:###### Zuul配置 zuul.routes.api-integral.path=/integral/** zuul.routes.api-integral.serviceId=integral-server zuul.routes.api-member.path=/member/** zuul.routes.api-member.serviceId=member-serverintegral-server 和 member-server 是这俩微服务系统注册到微服务中心的一个 serverId,我们通过配置,访问http://localhost:9010/integual/add?a=1&b=2,该请求就会访问 integral-server 系统中的 add 服务。2. 服务过滤在定义 zuul 网关服务过滤只需要创建一个继承 ZuulFilter 抽象类并重写四个方法即可,下面是 ZuulFilter 的一些解释:filterType:过滤类型,具体如下:pre:请求路由之前执行;routing:请求路由时执行;post:在 routing 和 error 过滤器之后执行;error:在请求发生错误的时候执行;filterOrder:定义过滤器的执行顺序shouldFilter:判断该过滤器是否要执行,run:过滤器的具体逻辑。标准实例程序:public class ErrFilter extends ZuulFilter { @Override public String filterType() { return "pre"; } @Override public int filterOrder() { return 0; } @Override public boolean shouldFilter() { return true; } @Override public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); HttpServletRequest request = ctx.getRequest(); Object accessToken = request.getParameter("accessToken"); if(accessToken == null) { log.warn("access token is empty"); ctx.setSendZuulResponse(false); ctx.setResponseStatusCode(401); return null; } return null; } }在自定过滤器之后,我们还需要在 SpringBoot 主程序中加入@EnableZuulProxy 注解来开启 zuul 路由的服务过滤:@EnableZuulProxy @EnableEurekaClient @RibbonClients @SpringCloudApplication public class ApiGatewayApplication { public static void main(String[] args) { SpringApplication.run(ApiGatewayApplication.class, args); } @Bean PosPreFilter posPreFilter(){ return new PosPreFilter(); }到这里,微服务系统的 zuul 路由功能基本搭建完成。Feign之前说过了微服务间,我是通过 Spring 的 RestTemplate 类来相互调用的,它可通过整合 Ribbon 实现负载均衡,但发现了这样写不够优雅,且不够模板化,因此本篇介绍一下 Feign。Feign 是一种声明式、模板化的 HTTP 客户端,在 Spring Cloud 中使用 Feign 其实就是创建一个接口类,它跟普通接口没啥两样,因此通过 Feign 调用 HTTP 请求,开发者完全感知不到这是远程方法。2. 整合 Feign添加 Feign 依赖:<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-feign</artifactId> </dependency>创建 一个 Feign 接口:@FeignClient(value = FeignConst.COUPON_PROVIDER, url = "${feign.coupon.url:}") public interface CouponClient { @GetMapping(value = "/coupon/list/page", headers = LocalsEncoder.CONTENT_TYPE_LOCALS_GET) RestResponse couponList(@ModelAttribute CouponCriteria criteria); }启动 Feign 类@EnableFeignClients(basePackages = {"com.objcoding"}) @SpringCloudApplication public class ProviderApplication { }2. 服务降级当网络不稳定时,一个接口响应非常慢,就会一直占用这个连接资源,如果长时间不做处理,会导致系统雪崩,幸好,Feign 已经继承了熔断器 Hystrix@FeignClient(value = FeignConst.COUPON_PROVIDER, url = "${feign.coupon.url:}", fallback = CouponClient.CouponClientFallBack.class) public interface CouponClient { @GetMapping(value = "/coupon/list/page", headers = LocalsEncoder.CONTENT_TYPE_LOCALS_GET) RestResponse couponList(@ModelAttribute CouponCriteria criteria); @Component class CouponClientFallBack implements CouponClient { @Override public RestResponse couponList(CouponCriteria criteria) { return RestResponse.failed("网络超时"); } } }3. 拦截器有时候微服务间的调用,需要传递权限信息,这些信息都包含在请求头了,这时我们可以通过 Feign 拦截器实现权限穿透:@Configuration public class WebRequestInterceptor { @Bean public RequestInterceptor headerInterceptor() { return template -> { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes == null) { return; } HttpServletRequest request = attributes.getRequest(); Enumeration<String> headerNames = request.getHeaderNames(); if (headerNames != null) { while (headerNames.hasMoreElements()) { String name = headerNames.nextElement(); String values = request.getHeader(name); template.header(name, values); } } }; } }
ISR(in-sync replica) 就是 Kafka 为某个分区维护的一组同步集合,即每个分区都有自己的一个 ISR 集合,处于 ISR 集合中的副本,意味着 follower 副本与 leader 副本保持同步状态,只有处于 ISR 集合中的副本才有资格被选举为 leader。一条 Kafka 消息,只有被 ISR 中的副本都接收到,才被视为“已同步”状态。这跟 zk 的同步机制不一样,zk 只需要超过半数节点写入,就可被视为已写入成功。follwer 副本与 leader 副本之间的数据同步流程如下:从上图可看出,leader 的 remote LEO 的值相对于 follower LEO 值,滞后一个 follower RPC 请求,remote LEO 决定 leader HW 值的大小,详情请看「图解:Kafka 水印备份机制」。这也就意味着,leader 副本永远领先 follower 副本,且各个 follower 副本之间的消息最新位移也不尽相同,Kafka 必须要定义一个落后 leader 副本位移的范围,使得处于这个范围之内的 follower 副本被认为与 leader 副本是处于同步状态的,即处于 ISR 集合中。(1)0.9.0.0 版本之前的设计0.9.0.0 版本之前判断副本之间是否同步,主要是靠参数 replica.lag.max.messages 决定的,即允许 follower 副本落后 leader 副本的消息数量,超过这个数量后,follower 会被踢出 ISR。replica.lag.max.messages 也很难在生产上给出一个合理值,如果给的小,会导致 follower 频繁被踢出 ISR,如果给的大,broker 发生宕机导致 leader 变更时,肯能会发生日志截断,导致消息严重丢失的问题。可能你会问,给个适中的值不就行了吗?关键在这里,怎样才是适中?如何界定?假设现在某个 Kafka 集群追求高吞吐量,那生产者的 batch.size 就会设置得很大,每次发送包含的消息量很多,使消息发送的吞吐量大大提高,如果此时 min.insync.replicas=1,从上图可看出,生产者发送消息保存到 leader 副本后就会响应成功,表示许诺用户保存到至少一个副本的要求已经达到,消息已经成功发送。那问题来了,由于 follower 副本同步 leader 副本的消息是不断地发送 fetch 请求,此时如果 leader 一下子接收到很多消息,就会导致 leader 副本与 follower 副本的消息数量相差很大,如果此时这个差数大于 replica.lag.max.messages 的值,follower 副本就会被踢出 ISR,因此,该集群需要把 replica.lag.max.messages 的值设置成很大才能够避免 follower 副本频繁被踢出 ISR。所以说,replica.lag.max.messages 的设计是有缺陷的,当生产者发送消息量很大时,该值也需要相应调大,但就会造成消息严重丢失的风险。有没有更好的解决方案?(2)0.9.0.0 版本之后的设计在 0.9.0.0 版本之后,Kafka 给出了一个更好的解决方案,去除了 replica.lag.max.messages,,用 replica.lag.time.max.ms 参数来代替,该参数的意思指的是允许 follower 副本不同步消息的最大时间值,即只要在 replica.lag.time.max.ms 时间内 follower 有同步消息,即认为该 follower 处于 ISR 中,这就很好地避免了在某个瞬间生产者一下子发送大量消息到 leader 副本导致该分区 ISR 频繁收缩与扩张的问题了。
向大家提个问题:RocketMQ 消息消费进度是如何提交的,并发消费的时候,一次从 一个队列拉 32 条消息,这 32 条消息会提交到线程池中处理,如果偏移量 m5 比 m4 先执行完成,消息消费后,提交的消费进度是哪个?是提交消息 m5 的偏移量?下面跟着我的节奏,撸一波源码。RocketMQ 每次拉取完消息都会将消息存储到 PullRequest 对象中的 ProcessQueue 中:org.apache.rocketmq.client.consumer.PullCallback#onSuccessboolean dispathToConsume = processQueue.putMessage(pullResult.getMsgFoundList());接着将消息放进消费线程中去执行:org.apache.rocketmq.client.consumer.PullCallback#onSuccessDefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(// pullResult.getMsgFoundList(), // processQueue, // pullRequest.getMessageQueue(), // dispathToConsume);ConsumeMessageService 类实现消息消费的逻辑,它有两个实现类:// 并发消息消费逻辑实现类 org.apache.rocketmq.client.impl.consumer.ConsumeMessageConcurrentlyService; // 顺序消息消费逻辑实现类 org.apache.rocketmq.client.impl.consumer.ConsumeMessageOrderlyService;这里我们只分析并发消费:org.apache.rocketmq.client.impl.consumer.ConsumeMessageConcurrentlyService#submitConsumeRequestConsumeRequest consumeRequest = new ConsumeRequest(msgThis, processQueue, messageQueue); try { this.consumeExecutor.submit(consumeRequest); } catch (RejectedExecutionException e) { // ... }将消息消费任务封装成 ConsumeRequest 对象,然后将其交给消费线程池中去执行。org.apache.rocketmq.client.impl.consumer.ConsumeMessageConcurrentlyService.ConsumeRequest#run:if (!processQueue.isDropped()) { ConsumeMessageConcurrentlyService.this.processConsumeResult(status, context, this); } else { log.warn("processQueue is dropped without process consume result. messageQueue={}, msgs={}", messageQueue, msgs); }ConsumeRequest 是一个实现了 Runnable 的类,因此消息消费的核心逻辑都写在了 run 方法中,如上代码是提交已消费位移的逻辑,当 ProcessQueue 没有被丢弃,则进行已消费位移的提交。org.apache.rocketmq.client.impl.consumer.ConsumeMessageConcurrentlyService#processConsumeResult// 移除已消费的消息,并返回已消费的 long offset = consumeRequest.getProcessQueue().removeMessage(consumeRequest.getMsgs()); if (offset >= 0 && !consumeRequest.getProcessQueue().isDropped()) { this.defaultMQPushConsumerImpl.getOffsetStore().updateOffset(consumeRequest.getMessageQueue(), offset, true); }移除已消费的位移,并返回最小位移量,如果最小位移量大于 0,并且 ProcessQueue 没有被丢弃,则更新本地缓存,org.apache.rocketmq.client.impl.consumer.ProcessQueue#removeMessagepublic long removeMessage(final List<MessageExt> msgs) { long result = -1; final long now = System.currentTimeMillis(); try { this.lockTreeMap.writeLock().lockInterruptibly(); this.lastConsumeTimestamp = now; try { if (!msgTreeMap.isEmpty()) { result = this.queueOffsetMax + 1; int removedCnt = 0; // 移除已消费的消息 for (MessageExt msg : msgs) { MessageExt prev = msgTreeMap.remove(msg.getQueueOffset()); if (prev != null) { removedCnt--; } } // 消息总量累加 msgCount.addAndGet(removedCnt); // 返回消息容器中最小元素 key if (!msgTreeMap.isEmpty()) { result = msgTreeMap.firstKey(); } } } finally { this.lockTreeMap.writeLock().unlock(); } } catch (Throwable t) { log.error("removeMessage exception", t); } return result; }以上方法就是解答文章开头问题的关键,由于该方法是各个消费线程并发执行,因此需要对其进行加锁操作,msgTreeMap 是 ProcessQueue 的消息容器,它的格式如下:private final TreeMap<Long, MessageExt> msgTreeMap = new TreeMap<>();它是一个 TreeMap 结构,key 为消息位移,value 为消息数据,消息容器中,消息可以按照位移进行排序,那也就意味着,当消息消费完,只需要在消息容器中移除即可,然后返回消息容器中最小元素(最小位移),如下:由于消息是按照位移进行排序,因此我们只需移除已消费的消息,并且确保不会将未消费的位移提交,就可避免了位移大的消息先消费导致消息丢失的问题了。org.apache.rocketmq.client.consumer.store.RemoteBrokerOffsetStore#updateOffset:public void updateOffset(MessageQueue mq, long offset, boolean increaseOnly) { if (mq != null) { AtomicLong offsetOld = this.offsetTable.get(mq); if (null == offsetOld) { offsetOld = this.offsetTable.putIfAbsent(mq, new AtomicLong(offset)); } if (null != offsetOld) { if (increaseOnly) { MixAll.compareAndIncreaseOnly(offsetOld, offset); } else { offsetOld.set(offset); } } } }offsetTable 为本地位移缓存容器,它的结构如下:private ConcurrentMap<MessageQueue, AtomicLong> offsetTable = new ConcurrentHashMap<>();它是一个 ConcurrentMap,一个线程安全容器,key 为 MessageQueue,value 为当前 MessageQueue 的消费位移,从源码看出,当前消费位移的更新,只能是递增更新。在更新完本地缓存之后,RocketMQ 是如何将其提交到 broker 的呢?org.apache.rocketmq.client.impl.factory.MQClientInstance#startScheduledTask:this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() { @Override public void run() { try { MQClientInstance.this.persistAllConsumerOffset(); } catch (Exception e) { log.error("ScheduledTask persistAllConsumerOffset exception", e); } } }, 1000 * 10, this.clientConfig.getPersistConsumerOffsetInterval(), TimeUnit.MILLISECONDS);以上,消费者在启动的时候,开启了一个定时任务,定时将本地缓存提交到broker。org.apache.rocketmq.client.consumer.store.RemoteBrokerOffsetStore#persistAll:// 参数mqs是当前分配的队列 public void persistAll(Set<MessageQueue> mqs) { if (null == mqs || mqs.isEmpty()) return; final HashSet<MessageQueue> unusedMQ = new HashSet<MessageQueue>(); if (!mqs.isEmpty()) { // 遍历位移缓存容器 for (Map.Entry<MessageQueue, AtomicLong> entry : this.offsetTable.entrySet()) { MessageQueue mq = entry.getKey(); AtomicLong offset = entry.getValue(); if (offset != null) { // 位移缓存容器包含在当前分配队列,则进行消费位移提交 if (mqs.contains(mq)) { try { // 提交消费位移 this.updateConsumeOffsetToBroker(mq, offset.get()); } catch (Exception e) { log.error("updateConsumeOffsetToBroker exception, " + mq.toString(), e); } } else { unusedMQ.add(mq); } } } } // 将未分配的队列从位移缓存中移除 if (!unusedMQ.isEmpty()) { for (MessageQueue mq : unusedMQ) { this.offsetTable.remove(mq); log.info("remove unused mq, {}, {}", mq, this.groupName); } } }最终会调用以上方法,RocketMQ 会从重平衡那里获取当前消费者已分配的队列,如果位移缓存容器包含在当前分配队列,则进行消费位移提交,否则将从位移缓存容器中移除。broker 端处理:org.apache.rocketmq.broker.offset.ConsumerOffsetManager#commitOffsetprivate void commitOffset(final String clientHost, final String key, final int queueId, final long offset) { ConcurrentMap<Integer, Long> map = this.offsetTable.get(key); if (null == map) { map = new ConcurrentHashMap<Integer, Long>(32); map.put(queueId, offset); this.offsetTable.put(key, map); } else { Long storeOffset = map.put(queueId, offset); if (storeOffset != null && offset < storeOffset) { log.warn("[NOTIFYME]update consumer offset less than store. clientHost={}, key={}, queueId={}, requestOffset={}, storeOffset={}", clientHost, key, queueId, offset, storeOffset); } } }以上,offsetTable 为 broker 端的消费位移缓存容器,它的结构如下:private ConcurrentMap<String/* topic@group */, ConcurrentMap<Integer, Long>> offsetTable = new ConcurrentHashMap<>(512);它同样是一个 ConcurrentMap,一个线程安全容器,key 为的形式为 “topic@group”,value 也是一个 ConcurrentMap 它的 key 为 queueId,value 为位移,它会以 json 的形式持久化到磁盘 ${ROCKETMQ_HOME}/store/config/consumerOffset.json 文件中,具体格式如下:{ "offsetTable": { "test-topic@test-group": { "0": 88526, "1": 88528, "2": 88532, "3": 88537 } } }
纯粹是为了记录搭建的过程。忘了就翻来看看。RocketMQ部署类型单个Master单机模式, 即只有一个Broker, 如果Broker宕机了, 会导致RocketMQ服务不可用, 不推荐使用.多Master模式组成一个集群, 集群每个节点都是Master节点, 配置简单, 性能也是最高, 某节点宕机重启不会影响RocketMQ服务, 缺点就是如果某个节点宕机了, 会导致该节点未被消费的消息在在节点恢复前不可订阅.多Master多Slave模式,异步复制每个Master配置一个Slave, 多对Master-Slave, Master与Slave消息采用异步复制方式, 主从消息一致会有毫秒级的延迟. 优点是弥补了多Master模式下节点宕机后在恢复前不可订阅的问题, 在Master宕机后, 消费者还可以从Slave节点进行消费, 缺点就是如果Master宕机, 磁盘损坏的情况下, 如果没有即使将消息复制到Slave, 会导致有少量消息丢失.多Master多Slave模式,同步双写每个Master配置一个Slave,有多对Master-Slave,HA采用同步双写方式,主备都写成功,向应用返回成功。数据与服务都无单点,Master宕机情况下,消息无延迟,服务可用性与数据可用性都非常高。缺点就是性能比异步复制模式略低,大约低10%左右,发送单个消息的RT会略高。集群的一些概念Name Server: 是一个几乎无状态节点, 可集群部署, 节点之间间无任何信息同步.Broker: Broker分为Master与Slave, 一个Master可以对应多个Slave, 但是一个Slave只能对应一个Master, Master与Slave的对应关系通过指定相同的BrokerName, 不同的BrokerId来定义, BrokerId为0表示Master, 非0表示Slave. Master也可以部署多个. 每个Broker与Name Server集群中的所有节点建立长连接, 定时注册Topic信息到所有Name Server.Producer: producer与Name Server集群中的其中一个节点(随机选择)建立长连接, 定期从Name Server取Topic路由信息, 并向提供Topic服务的Master建立长连接, 且定时向Master发送心跳. Producer完全无状态, 可集群部署.Comsumer: Consumer与Name Server集群中的其中一个节点(随机选择)建立长连接, 定期从Name Server 取Topic路由信息, 并向提供Topic服务的Master、Slave建立长连接, 且定时向Master、Slave发送心跳. Consumer既可以从Master订阅消息, 也可以从Slave订阅消息, 订阅规则由Broker配置决定.以上概念来源于RocketMQ开发手册搭建多 Master多Slave 异步复制模式下载:$ wget http://mirrors.tuna.tsinghua.edu.cn/apache/rocketmq/4.3.2/rocketmq-all-4.3.2-source-release.zip解压:$ unzip rocketmq-all-4.3.2-source-release.zip $ mv rocketmq-all-4.3.2 rocketmqMaven编译:$ mvn -Prelease-all -DskipTests clean install -U修改broker配置文件:Master1$ vim ${rocketmq_home}/conf/2m-2s-sync/broker-a.properties brokerClusterName=test_hk_hk3_hd_mq_rocket_1 brokerName=broker-a # 主服务器必须为0 brokerId=0 deleteWhen=04 fileReservedTime=48 brokerRole=ASYNC_MASTER flushDiskType=ASYNC_FLUSH #nameServer地址,分号分割 namesrvAddr=172.17.4.60:9876;172.17.4.61:9876Slave1$ vim ${rocketmq_home}/conf/2m-2s-sync/broker-a-s.properties brokerClusterName=test_hk_hk3_hd_mq_rocket_1 brokerName=broker-a # 从服务器必须大于0 brokerId=1 deleteWhen=04 fileReservedTime=48 brokerRole=SLAVE flushDiskType=ASYNC_FLUSH #nameServer地址,分号分割 namesrvAddr=172.17.4.60:9876;172.17.4.61:9876其余另一对Master-Slave与此类推.另外再列出具体的配置信息与注释:#所属集群名字 brokerClusterName=rocketmq-cluster #broker名字,注意此处不同的配置文件填写的不一样 brokerName=broker-a|broker-b #0 表示 Master,>0 表示 Slave brokerId=0 #nameServer地址,分号分割 namesrvAddr=rocketmq-nameserver1:9876;rocketmq-nameserver2:9876 #在发送消息时,自动创建服务器不存在的topic,默认创建的队列数 defaultTopicQueueNums=4 #是否允许 Broker 自动创建Topic,建议线下开启,线上关闭 autoCreateTopicEnable=true #是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭 autoCreateSubscriptionGroup=true #Broker 对外服务的监听端口,10911为默认值 listenPort=10911 #表示Master监听Slave请求的端口,默认为服务端口+1 haListenPort=10912 #删除文件时间点,默认凌晨 4点 deleteWhen=04 #文件保留时间,默认 48 小时 fileReservedTime=120 #commitLog每个文件的大小默认1G mapedFileSizeCommitLog=1073741824 #ConsumeQueue每个文件默认存30W条,根据业务情况调整 mapedFileSizeConsumeQueue=300000 #destroyMapedFileIntervalForcibly=120000 #redeleteHangedFileInterval=120000 #检测物理文件磁盘空间 diskMaxUsedSpaceRatio=88 #存储路径 storePathRootDir=/usr/local/rocketmq/store #commitLog 存储路径 storePathCommitLog=/usr/local/rocketmq/store/commitlog #消费队列存储路径存储路径 storePathConsumeQueue=/usr/local/rocketmq/store/consumequeue #消息索引存储路径 storePathIndex=/usr/local/rocketmq/store/index #checkpoint 文件存储路径 storeCheckpoint=/usr/local/rocketmq/store/checkpoint #abort 文件存储路径 abortFile=/usr/local/rocketmq/store/abort #限制的消息大小 maxMessageSize=65536 #flushCommitLogLeastPages=4 #flushConsumeQueueLeastPages=2 #flushCommitLogThoroughInterval=10000 #flushConsumeQueueThoroughInterval=60000 #Broker 的角色 #- ASYNC_MASTER 异步复制Master #- SYNC_MASTER 同步双写Master #- SLAVE brokerRole=ASYNC_MASTER #刷盘方式 #- ASYNC_FLUSH 异步刷盘 #- SYNC_FLUSH 同步刷盘 flushDiskType=ASYNC_FLUSH #checkTransactionMessageEnable=false #发消息线程池数量 #sendMessageThreadPoolNums=128 #拉消息线程池数量 #pullMessageThreadPoolNums=128启动: 进入rocketMQ解压目录下的bin文件夹 启动nameServer:$ nohup sh bin/mqnamesrv &启动broker:$ nohup sh mqbroker -c broker-a.properties >/dev/null 2>&1 & $ nohup sh mqbroker -c broker-a-s.properties >/dev/null 2>&1 & $ nohup sh mqbroker -c broker-b.properties >/dev/null 2>&1 & $ nohup sh mqbroker -c broker-b-s.properties >/dev/null 2>&1 &如果运行出现如下错误: "The broker[broker-a, 172.17.4.60:10911] boot success. serializeType=JSON and name server is 172.17.4.60:9876" "INFO: os::commit_memory(0x00000006c0000000, 2147483648, 0) failed; error='Cannot allocate memory' (errno=12)"那么修改一下runserver.sh和runbroker.sh的合适的jvm参数就可以了搭建控制台 本地下载源码:$ git clone https://github.com/apache/rocketmq-externals配置好配置文件后, 打包:$ mvn clean package将打好的jar包上传到服务器服务器运行:$ nohup java -jar rocketmq-console-ng-1.0.0.jar 2>&1 & $ tail -f nohup.out
纯粹是为了记录搭建的过程。忘了就翻来看看。下载编译下载:$ wget http://download.redis.io/releases/redis-5.0.3.tar.gz解压:$ tar -zxvf redis-5.0.3.tar.gz编译:$ make install PREFIX=/opt/redis/redis-5.0.3拷贝配置文件:$ cp redis.conf /opt/redis/redis-5.0.3/bin $ cp redis-sentinel.conf /opt/redis/redis-5.0.3/bin配置配置 redis.conf# 这里需要配置内网地址,不要配置localhost, 不然只能单机自己玩 bind 内网地址 # 进程后台运行, 这个必须的 daemonize yes # 如果是从服务器, 那么这里需要配置主服务器的地址和端口 slaveof 主地址 主端口配置 sentinel.conf# 哨兵监听的端口 port 26379 # 进程后台运行, 这个必须的 daemonize yes # 监视一个名为mymaster的主服务器,这个主服务器的 IP 地址为 172.17.4.57,端口号为 6379 # 后面那个2指的是将这个主服务器判断为失效至少需要1个Sentinel同意(只要同意Sentinel的数量不达标,自动故障迁移就不会执行) sentinel monitor mymaster 172.17.4.57 6379 2启动启动 redis$ ./redis-server redis.conf启动哨兵监控$ ./redis-sentinel sentinel.conf查看主从状态$ ./redis-cli -h xxx.xxx.xx.x -p 6379 > info replication; # 主服务器: # Replication role:master connected_slaves:2 slave0:ip=172.17.4.58,port=6379,state=online,offset=20654866,lag=0 slave1:ip=172.17.4.59,port=6379,state=online,offset=20654852,lag=1 master_replid:cd484ba407267626276822a76c387aafc77c78c0 master_replid2:0000000000000000000000000000000000000000 master_repl_offset:20655090 second_repl_offset:-1 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:19606515 repl_backlog_histlen:1048576 # 从服务器 # Replication role:slave master_host:172.17.4.57 master_port:6379 master_link_status:up master_last_io_seconds_ago:1 master_sync_in_progress:0 slave_repl_offset:20675712 slave_priority:100 slave_read_only:1 connected_slaves:0 master_replid:cd484ba407267626276822a76c387aafc77c78c0 master_replid2:0000000000000000000000000000000000000000 master_repl_offset:20675712 second_repl_offset:-1 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:19627137 repl_backlog_histlen:1048576查看哨兵状态$ ./redis-cli -h xxx.xxx.xx.x -p 26379 info sentinel # Sentinel sentinel_masters:1 sentinel_tilt:0 sentinel_running_scripts:0 sentinel_scripts_queue_length:0 sentinel_simulate_failure_flags:0 master0:name=mymaster,status=ok,address=172.17.4.57:6379,slaves=2,sentinels=3
最近在 RocketMQ 钉钉官方群中看到有人反馈说 broker 主从部署,在发布消息的时候会报 SLAVE_NOT_AVAILABLE 异常,报这个异常的前提 master 的模式一定为 SYNC_MASTER(同步复制),从 异常码可以直接判断的一种原因就是因为 slave 挂掉了,导致 slave 不可用,但是他说 slave 一切正常。于是我决定撸一波源码。既然是主从同步的问题,那么我们直接定位到处理同步复制的方法:org.apache.rocketmq.store.CommitLog#handleHApublic void handleHA(AppendMessageResult result, PutMessageResult putMessageResult, MessageExt messageExt) { if (BrokerRole.SYNC_MASTER == this.defaultMessageStore.getMessageStoreConfig().getBrokerRole()) { HAService service = this.defaultMessageStore.getHaService(); if (messageExt.isWaitStoreMsgOK()) { // Determine whether to wait if (service.isSlaveOK(result.getWroteOffset() + result.getWroteBytes())) { GroupCommitRequest request = new GroupCommitRequest(result.getWroteOffset() + result.getWroteBytes()); service.putRequest(request); service.getWaitNotifyObject().wakeupAll(); boolean flushOK = request.waitForFlush(this.defaultMessageStore.getMessageStoreConfig().getSyncFlushTimeout()); if (!flushOK) { log.error("do sync transfer other node, wait return, but failed, topic: " + messageExt.getTopic() + " tags: " + messageExt.getTags() + " client address: " + messageExt.getBornHostNameString()); putMessageResult.setPutMessageStatus(PutMessageStatus.FLUSH_SLAVE_TIMEOUT); } } // Slave problem else { // Tell the producer, slave not available putMessageResult.setPutMessageStatus(PutMessageStatus.SLAVE_NOT_AVAILABLE); } } } }消息写入时需要判断 master 是否为 SYNC_MASTER 模式,从源码可以看出来,isSlaveOK() 方法决定是否报 SLAVE_NOT_AVAILABLE 异常码的关键逻辑,所以关键就是要看这个方法:org.apache.rocketmq.store.ha.HAService#isSlaveOKpublic boolean isSlaveOK(final long masterPutWhere) { boolean result = this.connectionCount.get() > 0; result = result && ((masterPutWhere - this.push2SlaveMaxOffset.get()) < this.defaultMessageStore .getMessageStoreConfig().getHaSlaveFallbehindMax()); return result; }从源码的逻辑看,masterPutWhere = result.getWroteOffset() + result.getWroteBytes(),其中 wroteOffset 表示从那个位移开始写入,wroteBytes 表示写入的消息量,因此 masterPutWhere 表示 master 最大的消息拉取位移,push2SlaveMaxOffset 表示的是此时 slave 拉取最大的位移,haSlaveFallbehindMax 表示 slave 主从同步同步复制时最多可落后 master 的位移,masterPutWhere - this.push2SlaveMaxOffset.get() 即可表示此时 slave 落后 master 的位移量,如果大于 haSlaveFallbehindMax,则报 SLAVE_NOT_AVAILABLE 给客户端,不过不用担心,只要 slave 没有挂掉,slave 的同步位移肯定能够追上来。push2SlaveMaxOffset 参数值 是 slave 与 master 保持一个心跳频率,定时上报给 master,master 再根据这个值判断 slave 落后 master 多少位移量。下面重点分析 slave 如何上报 push2SlaveMaxOffset 给master。master 收到 slave 的位移量之后,是从以下方法进行更新的:org.apache.rocketmq.store.ha.HAService#notifyTransferSomepublic void notifyTransferSome(final long offset) { for (long value = this.push2SlaveMaxOffset.get(); offset > value; ) { boolean ok = this.push2SlaveMaxOffset.compareAndSet(value, offset); if (ok) { this.groupTransferService.notifyTransferSome(); break; } else { value = this.push2SlaveMaxOffset.get(); } } }从调用栈来看,该方法在服务端处理读请求类中调用了,我们接着往下看:org.apache.rocketmq.store.ha.HAConnection.ReadSocketService#processReadEventif (readSize > 0) { readSizeZeroTimes = 0; this.lastReadTimestamp = HAConnection.this.haService.getDefaultMessageStore().getSystemClock().now(); if ((this.byteBufferRead.position() - this.processPostion) >= 8) { int pos = this.byteBufferRead.position() - (this.byteBufferRead.position() % 8); long readOffset = this.byteBufferRead.getLong(pos - 8); this.processPostion = pos; HAConnection.this.slaveAckOffset = readOffset; if (HAConnection.this.slaveRequestOffset < 0) { HAConnection.this.slaveRequestOffset = readOffset; log.info("slave[" + HAConnection.this.clientAddr + "] request offset " + readOffset); } HAConnection.this.haService.notifyTransferSome(HAConnection.this.slaveAckOffset); }如上源码逻辑,如果读取到的字节大于 0,并且大于等于 8,则说明了收到了 slave 端反馈过来的位移量,于是将其取出并更新到 push2SlaveMaxOffset 中。接着我们来看 slave 是如何上报位移的。org.apache.rocketmq.store.ha.HAService.HAClient#runif (this.isTimeToReportOffset()) { boolean result = this.reportSlaveMaxOffset(this.currentReportedOffset); if (!result) { this.closeMaster(); } }以上逻辑在 slave 端处理拉取同步消息线程 run 方法中,首先判断是否到了需要上报位移的时间间隔了,到了直接调用上报位移方法。org.apache.rocketmq.store.ha.HAService.HAClient#isTimeToReportOffsetprivate boolean isTimeToReportOffset() { long interval = HAService.this.defaultMessageStore.getSystemClock().now() - this.lastWriteTimestamp; boolean needHeart = interval > HAService.this.defaultMessageStore.getMessageStoreConfig() .getHaSendHeartbeatInterval(); return needHeart; }首先求出距离上次同步消息的时时间间隔的大小,再与 haSendHeartbeatInterval 作对比,若大于 haSendHeartbeatInterval 则发送心跳包上报位移。org.apache.rocketmq.store.ha.HAService.HAClient#reportSlaveMaxOffsetprivate boolean reportSlaveMaxOffset(final long maxOffset) { this.reportOffset.position(0); this.reportOffset.limit(8); this.reportOffset.putLong(maxOffset); this.reportOffset.position(0); this.reportOffset.limit(8); for (int i = 0; i < 3 && this.reportOffset.hasRemaining(); i++) { try { this.socketChannel.write(this.reportOffset); } catch (IOException e) { log.error(this.getServiceName() + "reportSlaveMaxOffset this.socketChannel.write exception", e); return false; } } return !this.reportOffset.hasRemaining(); }该方法向主服务器上报已拉取的位移,具体做法是将 ByteBuffer 读取位置 position 值为 0,其实调用 flip() 方法也可以,然后调用 putLong() 方法将 maxOffset 写入 ByteBuffer,将 limit 设置为 8,跟写入 ByteBuffer 中的 maxOffset(long 型)大小一样,最后采取 for 循环将 maxOffset 写入网络通道中,并调用 hasRemaining() 方法,该方法的逻辑为判断 position 是否小于 limit,即判断 ByteBuffer 中的字节流是否全部写入到通道中。最后总结,如果 slave 正常运行,报这个错是正常的,你可以适当调整 haSendHeartbeatInterval 参数(1000 * 5)的大小,它决定 slave 上报同步位移的心跳频率,以及 haSlaveFallbehindMax 参数值(默认 1024 * 1024 * 256),它决定允许 slave 最多落后 master 的位移。
2022年05月