SOLILOQUIZE 红黑树 2019-10-28 09:13:54 numerical /2019/10/28/红黑树

算法导论(Introduction to Algorithm)第13章详细介绍了红黑树的定义和具体的操作,这里记录下结合实际的红黑数实现(Java TreeMap)对这一数据结构的理解。

定义

红黑树(Red-Black Tree)是一类排序二叉树,和普通的二叉查找树不同,对红黑树中的每个节点来说,除了基础的左右节点信息、值信息之外,增加了额外的颜色信息。在此基础之上,红黑树增加了一些额外的条件约束,以此来保证树的平衡性。具体的条件是,

  1. 节点或者是黑色或者是红色
  2. 根节点是黑色
  3. 空的叶节点是黑色
  4. 如果一个节点是红色,则它的左右子节点都是黑色
  5. 每一个节点到其后继的每个叶节点之间包含相同数量的黑色节点

这几个条件约束保证了红黑树的高度不超过2lg(n-1),n为节点数。

在进行树的结构变动时,均需要注意是否违背了上述定义。当出现与定义不一致的情况,就需要进行调整。调整的手段有,

  • 改变节点颜色
  • 节点旋转(左旋、右旋)

具体结合节点增删时候的代码来进行分析在什么场景需要使用什么操作。

增加节点

流程

红黑树首先要满足排序二叉树的要求,而后再满足其自身定义的约束。节点增加的步骤可以分为两大块,

  • 节点插入,普通二叉查找树逻辑
  • 节点调整,调整节点颜色或旋转节点

确定颜色

新节点应该设置什么颜色?

  • 红黑树为空。根据条件2,节点设置成黑色。
  • 红黑树非空。此时需要根据定义进行分析判定。
    • 新节点为黑色,违背条件5,新节点所在路径黑色节点树比其余路径增多了
    • 新节点为红色,当父节点为红色时,违背条件4,存在父子节点都为红色情况

当红黑树非空时,将节点设为黑色,会发现整个红黑树节点都需要进行变色或旋转,否则难以满足条件。若设置成红色,则即便存在违背条件4的情况,也可以在局部进行变化,然后层层迭代处理。因此,可以得到初步结论,

  • 红黑树非空。节点设置成红色。

变色旋转

接下来就考虑一下新节点为红色时存在的情况。

  • 如果父节点为黑色,则符合定义,不用任何调整。
  • 如果父节点为红色,违背条件4,需要对父节点做变色处理。

当父节点从红色变为黑色之后会出现的情况是,父节点到其后继子节点路径上的黑色节点数量增加了1,为了满足条件5,需要通过变化将父节点到后继子节点路径上的黑色节点数量减少1,以此来保持平衡。

如何进行处理?假设红黑树中存在如下子结构,

场景一,节点1与其父节点为红色,父友邻节点为红色,则进行如下变化,

场景二,节点1与其父节点为红色,父友邻节点为黑色,则进行如下变化,

可以看出上图最左与最右的结构,左右路径上的黑色节点数保持不变,同时经过变化也不存在两个相邻的红色节点。

场景一只有变色操作,场景二最后有一步旋转操作。为什么选择这样的操作?

首先新增节点父节点的颜色调整是不可避免的,这就带来了局部路径上黑色节点数增多问题。之后不论是变色还是旋转都是为了将路径中的黑色节点数再减少回去,根据局部结构的不同,选择合适的操作。新增节点为左节点或右节点处理的手段是对称的,这里也不再完全枚举了。

实际的代码,可以看下Java TreeMap中的fixAfterInsertion方法,

移除节点

流程

同样的红黑树的节点移除操作,也分为两大块,

  • 节点移除,普通二叉查找树逻辑
  • 节点调整,调整节点颜色或旋转节点

移除逻辑

二叉树移除节点过程也简单说明一下,分几种情况,

  • 对于叶节点来说,需要将当前节点移除,此外无结构上调整。
  • 对于含有一个子节点的节点来说,需要将当前节点移除,同时让父节点指向子节点。
  • 对于内部节点来说,不能单纯移除,需要找当前节点的后继节点。将后继节点的值复制当当前节点,移除后继节点。

实际代码可以参考Java TreeMap中的deleteEntry方法。

节点调整

在找到真正需要从结构上进行移除的节点之后,需要开始考虑如何满足红黑树的定义。根据最终待移除的实际节点进行区分,

  • 待删除节点为红,移除后不会破坏红黑树条件,无需调整
  • 待删除节点为黑,移除后会影响路径上的黑色节点数,需要进行变化处理

考虑几种情况,

  • 待删除节点为叶节点
  • 待删除节点为中间节点

不论哪种情况实际要解决的就是在路径上黑色节点数减少之后需要再度达成一致。

被移除节点的子树通过调整可以完成的,则在局部进行处理,例如被移除节点的子节点是红色,则直接变为黑色即可。

被移除节点的子树内部不能直接调整完成的,则将问题转移到被移除节点的父节点。如果经过旋转变色之后能够满足条件则处理结束,否则将左右子树路径上的黑色节点调整一致之后,再将问题上抛一层,直至条件满足。

实际的处理过程可以参考Java TreeMap fixAfterDeletion方法。

总结

对红黑树的理解更多还是偏概念上的,之前没有深入了解郭这一数据结构,刚好借着阅读Java代码的机会来看一下这个应用相对广泛的平衡树结构。如果未来什么时候被人问起红黑树的问题,至少不会一点也答不出来了,但要落笔即写对,也并不容易。

Java动态代理 2019-10-16 09:37:55 numerical /2019/10/16/Java动态代理

简单回顾一下JDK内部动态代理机制。

基本用法

Java原生提供了动态代理机制,通过内部机制可以实现一定层面的AOP处理逻辑。Java动态代理只能处理接口方法,不能直接在类级别上进行操作。直接看一个最简单的例子,

public interface Foobar {
    String hello();
}

public class ProxyHandler implements InvocationHandler {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) {
        return "proxy";
    }
}

Foobar foobar = (Foobar) Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class[] {Foobar.class}, new ProxyHandler());
System.out.println(foobar.hello());

动态代理类需要实现InvocationHandler,在invoke方法中可以获取到外部调用的方法,在invoke方法中可以再根据具体的待调用方法与参数进行拦截处理。

内部实现

深入到Proxy.newProxyInstance内部去观察一下内部的代码实现,可以找到几个关键函数调用,

  • Proxy.getProxyClass0
  • ProxyClassFactory.apply
  • ProxyGenerator.generateProxyClass

动态代理类的创建在ProxyClassFactory.apply中,流程代码不多,容易看到最终生成的类名是"com.sun.proxy.$Proxy{num}"形式。实际生成的Class中包含了怎样的内容,可以在generateProxyClass中去看,不过这个就很细了,要结合Class文件格式定义去看。

另外通过设置"sun.misc.ProxyGenerator.saveGeneratedFiles"为true,可以将Proxy的Class文件保存到文件系统,后续再使用javap去观察具体的字节码指令。

方案对比

实际项目中可以考虑的方案除了原生动态代理之外,另外的选择有,

  • cglib
  • javaassit

两者都可以进行字节码修改,另外javaassit也提供了动态代理。直接基于字节码进行处理,在能力上会更丰富,能够处理非接口方法。在此之外的差别就在于性能了。

关于几种方案的性能,随着Java版本和这些包版本的变化迭代可能会有差异。最靠谱的还是根据各自使用的环境版本自行测试验证。

RPC负载均衡策略学习 2019-10-01 09:59:15 numerical /2019/10/01/RPC负载均衡策略学习

负载均衡策略对于RPC的请求吞吐来说影响重大,因此尝试了解了一下一些开源PRC框架中的负载均衡策略。

概况

简单看了三个项目的实现:dubbosofa-rpcbrpc-java。首先还是直接清单展示对比一下三个项目中的负载均衡策略,随后再来分析下每种策略的实现。

项目策略
dubboRandomLoadBalance
RoundRobinLoadBalance
ConsistentHashLoadBalance
LeastActiveLoadBalance
sofa-rpcRandomLoadBalancer
RoundRobinLoadBalancer
WeightRoundRobinLoadBalancer
ConsistentHashLoadBalancer
WeightConsistentHashLoadBalancer
LocalPreferenceLoadBalancer
brpc-javaRandomStrategy
RoundRobinStrategy
WeightStrategy
FairStrategy

项目

dubbo

RandomLoadBalance

随机策略基本上是每一个RPC框架都不会少的。RandomLoadBalance考虑了节点权重,在节点权重相同时从N个待选节点中随机均匀挑选一个目标节点,权重不同时则按权重进行随机挑选。RandomLoadBalance实现考虑了节点预热。预热期间节点会有一个预热权重,以此对请求流量进行调度,避免预热节点处理过多请求。

随机策略的优点在于实现简单,易于理解。

随机策略的缺点在于没有考虑其它信息因素,当某个节点出现异常变慢时,被选中的概率还是不变,这种情况下会导致整体吞吐大量下降。如果考虑权重因素,让外层逻辑去进行一些动态计算来调整不同节点的权重,那可以规避一些明显的问题点,不过那种情况下关键点就在于权重的计算调整,而非随机这一点。

RoundRobinLoadBalance

轮询策略也是一种经典策略。按方法分别轮询,不同方法之间互不影响。RoundRobinLoadBalance实现中考虑了权重,其计算逻辑中,每个节点维护自身计数,每次选择时,节点计数增加自身权重值。全部节点处理完成之后,选择计数最大的节点作为目标节点,同时其计数减去全局权重和。

轮询策略也容易理解实现。不考虑权重因素的话,轮询也没能考虑其它指标,也不能很好处理部分节点变慢等场景。权重的计算处理,则也同样是另外的问题。

ConsistentHashLoadBalance

一致性哈希策略。将相同请求的相同参数路由到同一个节点中。

直觉上这个策略不是特别通用,考虑极端场景,如果请求不够离散,可能部分节点都不一定能分配到请求。

不过这种策略也有适用的场景。相同参数请求路由到同一个节点,目标节点适合做一些本地化处理,如内存缓存之类。相对来说命中率能够更好,除此之外,没有其它特别合适的场景了。

LeastActiveLoadBalance

最小活跃连接策略。每一次分发请求时,选择当前活跃连接数最小的节点进行随机加权分配。

最小活跃连接相比其它策略多了一个信息维度,其隐含的假设是如果节点变慢活跃连接数就会多,活跃连接数少节点处理能力强。在选定最小活跃的基础上,再进行同等条件下的随机处理。

最小活跃策略的好处是在部分节点变慢的场景下,可以有更好的性能吞吐表现。慢节点不易分配更多的请求。

最小活跃策略的缺点在于其不兼容节点预热处理。新增节点的连接数少,请求会优先发给它们,在节点预热完成之前,会一波波的出现这种情况。

如果要让最小活跃策略兼容预热,大体有两种修改思路,

  • 加权最小活跃连接,将最小活跃连接转化为权值,这样就转化成了加权随机策略。这个思路的问题点在原权值转化如何处理。
  • 分组处理。将节点分为预热与正常节点两组,组之间按照权重选择,组内部按照最小活跃连接选择。

sofa-rpc

RandomLoadBalancer

sofa-rpc的随机策略与dubbo类似,都考虑了权重因素,实现相差无几。

RoundRobinLoadBalancer/WeightRoundRobinLoadBalancer

RoundRobinLoadBalancer是单纯的轮询实现,很简单的计数取模实现。

WeightRoundRobinLoadBalancer考虑了权重因素,没有考虑预热逻辑。注释里面不推荐使用。

ConsistentHashLoadBalancer/WeightConsistentHashLoadBalancer

与dubbo的ConsistentHashLoadBalance思路一致,WeightConsistentHashLoadBalancer额外考虑了节点权重,对于权重高的节点会分配更多的请求比例。本质上还是让相同请求路由到相同节点,便于节点内部做一些本地缓存类似的事情。

LocalPreferenceLoadBalancer

本机优先策略。优先选择客户端相同机器上的服务端节点,未命中则进行随机处理。

这个策略的目的看着是为了在节点混部情况下,更好的识别本机服务。本机相对其它的差别在于更快,如果能够识别出节点的处理能力,那么完全可以替代这种策略。

brpc-java

RandomStrategy

单纯的随机策略,没有考虑权重。

RoundRobinStrategy

单纯的轮询策略,没有考虑权重。

WeightStrategy

相当于带权重随机,权重值计算为错误次数少的节点权重值高。

FairStrategy

brpc-java前面几种策略实现都很简单,主要的策略放在了FairStrategy上。FairStrategy中的Fair体现在权重的计算。权重计算来源是每个连接维持的调用延迟时间。每一个节点维护一组延迟数据,权重计算根据平均延迟来确定。权重计算完成之后,FairStrategy内部采用了树形结构来维护节点权重关系,在选择节点时通过树形查找来加速。

FairStrategy策略上来看最终效果是让各节点的平均延迟均衡。实际整体上的效果表现是否比上面其它策略更优,要拿到同一套框架内去测试比较。逻辑上来看,它的权值计算是周期性的,相比较最小活跃连接策略,在节点出现问题时的响应及时性上应该不及。

总结

现实中可能基于最小活跃连接去改造会更为方便,拿到更好结果。不过上述三个框架中的策略基本都只是客户端维度上的处理,基于最简单的统计维度。实际项目中的情况可能更为复杂,例如服务端业务逻辑进行了限流快速熔断等处理,这种适合节点处理请求快延迟小并不代表节点更优。这种场景下,单纯的客户端维度统计可能并不足够,或许是要更细的去识别请求结果内容来进行判定。

关于容灾处理的一些思考 2019-09-07 12:58:43 numerical /2019/09/07/关于容灾处理的一些思考

对于一个大流量互联网应用来说,系统的稳定性至关重要。可惜,稳定性目标并不那么轻易能够达成。现实中,种种意想不到的问题会出现。但是,本着专业的严谨,还是需要尽可能去规避解决各种问题,提前准备故障真实发生之后的处理手段。

故障类型

对于一个服务来说,可能遇到的故障类型有哪些呢?常见的有,

  • 自身代码问题造成故障,例如Full GC、死循环
  • 流量突增带来的故障,例如突发流量超出了系统容量水位
  • 依赖的上游服务故障,例如Nginx故障、网关故障
  • 依赖的下游服务故障,例如下游RPC服务、HTTP服务、外部服务故障
  • 依赖的中间件服务故障,例如Memcache/Redis/MySQL/Kafka等故障
  • 依赖的物理资源故障,例如虚拟机故障、网络故障

确定边界

服务端的稳定性由多方参与保障,运维、测试、开发、DBA等等角色。首先要明确的是对于问题的处理边界。从服务提供方的角度来说,哪些是需要处理也能够处理的问题呢?实际上,除了底层运行的物理资源故障之外,其它的都是应该纳入考虑范畴的。

治理思路

在明确了边界之后,怎么来具体实施?

  • 问题梳理。梳理,简单两个字,实际要投入很多精力与时间。从服务设计的架构流程角度出发,逐一排查可能的问题点。
  • 寻找方案。很多问题点的解决思路类似,所以这一环相对没有那么费劲。只要找到集中典型场景的策略,后续只是逐渐拓展覆盖面。
  • 实现方案。编码测试。这一步最为单纯,也最好看到进展。
  • 验证方案。方案有效性的验证也是很复杂的,需要去模拟各种问题。基础设施不完善的话,需要很多人力投入。

问题梳理

对于物理资源问题,服务自身没有什么处理能力。需要服务上游去处理或是外部工具处理。

对于代码问题,通过前置测试去排查,线上的处理策略也比较明确。有发布,则回滚。没发布,则隔离。不能隔离,则交由服务上游去处理。

对于流量问题,在流量压力超出系统允许水位时会带来问题,需要进行控制,或交由上游处理。

对于依赖问题,可以认为所有的依赖都可能出现问题。要梳理出系统的各种依赖点,

  • 下游RPC服务
  • 下游HTTP服务
  • 缓存服务
  • 数据库服务
  • 消息队列服务
  • ...

遇到的问题类型,

  • 访问异常
  • 访问超时

寻找方案

物理资源问题

  • 下线/替换故障节点。手动或自动,需要一定的基础设施支持。

代码问题

  • 测试前置,单元测试、集成测试。单元测试的覆盖率可以做一定要求,没有运行过的代码分支总是存在风险的。
  • 静态扫描。代码的静态分析,可以帮助养成一些好的编码习惯,排除问题隐患。
  • Code Review。关键代码的Code Review,可以考虑成发布流程的一环。
  • 发布卡点。在一些前置事项没有完成之前,禁止发布到线上。
  • 灰度发布。发布到线上之后,需要先灰度再全量。
  • 线上巡检。线上服务巡检要常态化,这样能快速发现问题。
  • 异常告警。通过异常告警来快速发现问题。
  • 快速回滚。很多时候,回滚真能解决问题。

流量问题

  • 扩容。从应用服务到存储服务都需要评估是否可以通过快速扩容来解决问题,如此最为便捷。
  • 降级。通过手动或自动手段,对服务降级,减少性能损耗。
  • 静态化。一些服务可以通过静态化手段来缓解性能问题。
  • 异步化。通过消息队列等手段,削峰处理。
  • QPS限流。如果能够提前确定各接口的容量水位,那么可以通过基本的限流手段来进行保障。
  • 自适应限流。可以考虑关于服务限流的一些思考中提到的自适应限流策略。

依赖问题

  • 熔断。对于出现问题的服务,需要能够识别下游故障情况,这种时候就需要降级熔断策略,当发现下游问题时,进行熔断,隔离故障影响。
  • 降级。降级到替代服务。

实现方案

具体要根据选择的策略进行实现,有必要去讨论的是,哪些可以通过现有的开源框架工具进行解决,哪些需要通过自主研发进行解决。

结果验证

  • 线下演练。在方案实现之后,首先需要在线下环境进行演练,发现问题,修改问题,最终获取结论。
  • 线上演练。方案的实际有效性需要在线上进行验证,否则无法形成闭环。

总结

稳定性是一个很复杂的问题,真实的稳定性治理是一个耗时耗力的过程。在那些基础设施完善的公司,可能已经能够常态化应对了。但更多的地方,想来也都是比较简单原始的。一步一步的解决治理过程中发现的问题,也是挺有意思的。且做且珍惜。虽然实际做下来发现,很多地方坑真的很深。

ZooKeeper 集群部署与启动 2019-05-28 23:07:47 numerical /2019/05/28/ZooKeeper-集群部署与启动

集群部署

以ZooKeeper 3.4.6为例,来看下多节点ZooKeeper集群如何进行配置部署。官方文档上也有具体的描述。

配置说明

首先,需要提供配置文件,在配置文件中指定具体配置项值。以官方文档描述为例,

tickTime=2000
dataDir=/var/lib/zookeeper/
clientPort=2181
initLimit=5
syncLimit=2
server.1=zoo1:2888:3888
server.2=zoo2:2888:3888
server.3=zoo3:2888:3888

对于Observer节点,需要增加配置,

peerType=observer

针对此种的各项配置,各参数含义为,

参数说明
tickTimeZK节点通过tick来进行心跳、超时判定等时间相关逻辑
dataDirZK SNAPSHOT存放路径
clientPortZK客户端访问链接的端口
initLimitFollower初始连接进行同步的时间,按tick计算
syncLimitFollower允许进行同步的时间,按tick计算
server.x=[hostname]:nnnnn[:nnnnn]每个节点的连接配置,两个端口一个用于节点与Leader连接,一个用于Leader选举

更多的配置项,可以在这里找到。

启动实例

准备好配置文件之后,可以通过命令行进行启动,

java -cp zookeeper.jar:lib/slf4j-api-1.6.1.jar:lib/slf4j-log4j12-1.6.1.jar:lib/log4j-1.2.15.jar:conf \ org.apache.zookeeper.server.quorum.QuorumPeerMain zoo.cfg 

更完整的启动命令可以根据zkServer.sh去进行定制修改。

启动流程

从启动命令可以看到,ZooKeeper实例启动入口为QuorumPeerMain。

QuorumPeerMain执行过程中,首先解析配置文件构建QuorumPeerConfig实例,而后创建QuorumPeer实例。QuorumPeer为ZooKeeper实例核心逻辑实现所在。QuorumPeer首先会从数据目录恢复数据到内存中,而后开始监听配置的端口。

ZooKeeper实例状态由如下枚举例类型定义,

public enum ServerState {
    LOOKING, FOLLOWING, LEADING, OBSERVING;
}

刚启动时状态为LOOKING。在LOOKING状态下会根据选举策略触发选主逻辑。ZooKeeper默认的选举策略是FastLeaderElection,其它策略不推荐使用。在选举完成之后,根据决定的角色切换到对应状态,会分别切换到LEADING、FOLLOWING、OBSERVING状态。随后会创建具体的逻辑处理类,对应不同状态,会分别创建,

节点类型客户端连接处理类
LeaderLeaderZooKeeperServer
FollowerFollowerZooKeeperServer
ObserverObserverZooKeeperServer

这些实现类的继承关系为,

在上述对象创建之后,不同角色分别调用各自的入口函数,

  • Leader.lead()
  • Follower.followLeader()
  • Observer.observeLeader()

上述函数正常运行之后,ZooKeeper实例启动阶段逻辑就算完成了。

ZooKeeper 节点角色说明 2019-05-28 23:04:31 numerical /2019/05/28/ZooKeeper-节点角色说明

在ZooKeeper集群中,不同节点有不同角色。

ZooKeeper定义了如下角色类型,

  • Leader
  • Learner
    • Follower
    • Observer

Leader

Leader为集群主节点,同一时刻只存在一个主节点。Leader通过ZooKeeper集群协议选举出。

Leader负责所有ZooKeeper数据变更的提交。在ZooKeeper集群收到写操作时,写操作会被转发到Leader节点。Leader节点会通知所有的Follower,当有半数节点允许写入时,变更操作被提交。

Follower

Follower是集群中的从节点。Follower会处理Leader发送过来消息,会参与集群Leader选举。自身允许客户端连接读写数据。

Follower的存在提升了系统的可用性,但Follower的数量需要注意,Follower数量过多会带来写操作的耗时增长。

Observer

Observer与Follower的区别在于Observer不参与选举,只从Leader接受确定了的变更操作。Observer主要用于提供客户端连接,分担集群的读压力。

Observer节点的增加不会影响ZooKeeper集群的写性能,在集群存在大量读操作时,可以适当部署以分摊压力。

ZooKeeper IntelliJ IDEA中配置开发环境 2019-05-22 00:54:39 numerical /2019/05/22/ZooKeeper-IntelliJ-IDEA中配置开发环境

最近需要在ZooKeeper某个旧版上进行些改造,旧版构建工具是Ant、Ivy,和当前流行的略有差异,因此这里进行记录。

检出分支

从Github获取ZooKeeper源码之后,需要切换到目标版本分支。ZooKeeper发布都有打tag,因此可以从tag中获取,

git tag -l

找到对应目标tag之后,

git checkout -b branch_name tag_name

打开工程目录

在IntelliJ IDEA中打开ZooKeeper目录。默认情况下IDEA无法识别工程,

设置Project SDK

在菜单栏中选择“File - Project Structure - Project”,设置Project SDK。JDK版本和语言级别都设置为1.8。

同时设置Project compile output输出目录。

设置Sources Root

ZooKeeper代码主体为Java,将“src/java/main”目录设置为Sources Root。设置之后,该目录下的Java代码被IDEA识别了,但会发现缺少依赖包。

获取依赖包

命令行下运行,

ant

运行之后会自动生成部分代码。将自动生成的代码目录“src/java/generated”也设置为Sources Root。

同时依赖包夜被下载到了本地,在菜单栏中选择“File - Project Structure - Libraries”,增加Java Libraries。添加之代码中就可以识别出依赖包中的类了。

触发编译

完成上述步骤之后可以在IDEA中触发Build操作,如无意外则会顺利执行。

发布打包

在IDEA中完成修改之后,最后的打包操作还是可以通过命令行的ant来触发。生成的包会位于build目录下。

Netty EventLoop机制 2019-05-01 14:23:21 numerical /2019/05/01/Netty-EventLoop机制

Netty服务端启动之后,每有一个客户端连接进入,就会创建一个新的Channnel对象以表示两端之间的连接。后续两者之间的数据传输读写就都发生在Channel之上。不同Channel之间的处理,肯定是并发的。同一个Channel之上的处理,则有一定顺序。但何时会有数据对Netty服务端来说是未知的,

Netty是一个异步化处理框架,Channel创建之后,从Channel中读写数据都可能异步化,这种能力通过Channel上绑定的EventLoop来提供。EventLoop即事件循环,在网络I/O处理上,数据的到来时机无法控制,服务端获取到数据之后需要进行相应处理,这种处理对应到Netty中则是触发ChannelHandler中的方法或事件,而这些方法调用或是事件相应的实际运行都在EventLoop当中。

Netty是如何保障这一点的,就需要来具体看一下EventLoop的相关实现了。

EventLoop在代码层面是什么

EventLoop首先是Netty中的接口定义,其继承关系如图,

从接口定义上可以发现EventLoop是一个Executor,且是一个保证执行顺序的OrderedEventExecutor。向EventLoop中submit的任务最终会按顺序执行。保证按顺序执行,那么EventLoop

Netty异步处理框架 异步的实现EventLoop EventLoop&EventLoopGroup ServerBootstrap在构建的时候需要传递

DefaultEventLoop实现,SingleThreadEventLoop

类继承关系

一个Channel只会有一个EventLoop,一个EventLoop可能绑定到多个Channel上 一个EventLoop背后只会有一个线程

为什么异步,异步的来源于IO事件本身的异步性, 收到io时间后交由eventloop进行处理。

channelhandler是作为逻辑实现的重点,如何保证与eventloop之间的关联 通过channel获取到eventloop,判定当前线程是否与eventloop线程相同,相同则执行,否则则放入队列

Channel 与EventLoop 绑定

Executor 与 EventExecutor 的关系 ThreadExecutorMap通过ThreadLocal维系了两者之间的关联,在Runnable任务中获取eventExector时能获取到原先的EventExecutor

MultithreadEventExecutorGroup

channel 与 EventLoopGroup 之间关系

EventLoopGroup创建多个EventLoop,每个EventLoop与Channel关系

Netty Channel接口与创建流程 2019-05-01 07:46:47 numerical /2019/05/01/Netty-Channel接口与创建流程

Netty中的Channel可以简单理解成连接通道。当有新连接创建时,也意味着有新Channel对象被创建。不同Channel实现与底层的传输处理方式相关,Netty提供了多种Channel实现,典型的有,

  • NioSocketChannel
  • NioServerSocketChannel
  • OioSocketChannel
  • OioServerSocketChannel
  • EpollSocketChannel
  • EpollServerSocketChannel

在Netty封装之后,不论底层用什么机制去处理,是用Java IO/NIO库实现还是和平台Native实现相关,都不再特别需要去关注。对于上层使用方来说,看到的就都只有Channel。

Channel接口

状态信息,

  • isOpen(),是否开启
  • isRegistered(),是否注册到EventLoop
  • isActive(),是否活跃
  • isWritable(),是否可写

Channel自身的生命周期内,状态变化路径大体是,

Registered -> Active -> Inactive -> Unregistered

配置信息,

  • config(),Channel上可以配置底层传输的相关参数

地址信息,

  • localAddress(),本地监听的地址
  • remoteAddress(),远程访问的来源地址

关联的EventLoop,

  • eventLoop(),每一个Channel只会与一个EventLoop相绑定

关联的ChannelPipeline,

  • pipeline(),ChannelPipeline中的ChannelHandler是实际各种功能逻辑的实现者

Channel接口继承了AttributeMap接口,可以向Channel中进行Attribute的读写。Channel的I/O操作都是异步的,具体功能逻辑由一个或多个ChannelHandler组合而成,ChannelHandler可以获取到关联的Channel对象,因此不同ChannelHandler之间可以通过Channel上的Attribute进行一定意义上的数据传递。从这个视角来看,Channel也可以认为是多个ChannelHandler之间公共数据的一个暂存地。

在使用Netty去进行开发时,对于Channel,实际可能只是在应用启动之初进行选择,选择具体的Channel实现,而后就基本无需再特别关注了。

Channel创建过程

对于服务端来说,以Nio连接处理为例,

  • 在ServerBootstrap中传入制定Channel类型,NioServerSocketChannel.class
  • 在ServerBootstrap.bind调用过程中,会在基类AbstractBootstrap的initAndRegister方法中进行创建,具体是通过ReflectiveChannelFactory.newChannel根据Channel的类型进行反射创建。
  • 在服务端启动之后,客户端连接进入时,则在NioServerSocketChannel.doReadMessages方法创建NioSocketChannel。

Channel创建完毕之后,在I/O处理过程中,会按照流程触发Register、Active操作,通过Channel相关接口可以获取到对应状态的变化结果。

一般来说,Channel的创建流程是不太需要去关心的。通过Channel接口在ChannelPipeline处理链路中去读取或改变Channel相关状态即可。

Netty 典型服务端代码结构 2019-05-01 07:07:56 numerical /2019/05/01/Netty-典型服务端代码结构

最近重新对Netty产生了深入了解学习的兴趣。一方面因为很多关联项目内都应用了Netty,加深对Netty的了解无疑会增加对那些项目的掌握度。另一方面,也想尝试应用Netty来实现一个简单的异步化网关。简单翻阅了Zuul2、Reactive-Netty等项目的代码实现,在网络这一层的处理看上去也没多么复杂,代码也就那样。不过刚好可以结合着这些项目代码来学习实现相关功能,加深对Netty的认知。

首先来看下Netty应用的整体结构。不仅从Zuul2等项目中,在Netty自身提供的例子中也很容易就可以发现。Netty应用的结构是相当类似的,

  • 选择EventLoopGroup实现
  • 通过ChannelInitializer添加ChannelHandler到ChannelPipeline
  • 通过Bootstrap串联起各模块
  • 自主控制的部分主要在于各种参数,以及ChannelHandler的选择与自定义

用简单的例子来分析,例如,

try {
    ServerBootstrap bootstrap = new ServerBootstrap();
    bootstrap.group(new NioEventLoopGroup(), new NioEventLoopGroup())
        .channel(NioServerSocketChannel.class)
        .localAddress(new InetSocketAddress(port))
        .childHandler(new ChannelInitializer<SocketChannel>() {
            @Override
            public void initChannel(SocketChannel ch) throws Exception {
                ch.pipeline().addLast(new DiscardServerHandler());
            }
        });
    ChannelFuture future = bootstrap.bind().sync();
    future.channel().closeFuture().sync();
} finally {
    group.shutdownGracefully().sync();
}

上述代码已经包含了Netty应用的各要件。真实场景下的代码,首先是将各种参数选择配置化,可以通过配置文件或配置下发的形式进行控制。ChannelInitializer的实现肯定会独立,现实场景的ChannelHandler往往由多个构成,构成逻辑也会比较复杂。Netty内置了不少Handler实现,业务自身在内置实现之外,往往也要实现不少ChannelHandler。有的可能是用于进行数据流转解析,有的是用于实现具体的业务逻辑。

Netty中的Channel、ChannelPipeline、ChannelHandler、EventLoop、EventLoopGroup、Bootstrap等概念定义与目的究竟如何,各自的内部实现与处理流程如何就是后续要一个一个去进行分析的。虽然现在还没有一个很清楚的概念,但若是这些都理清的话,那么单纯使用Netty就不会是什么困难的问题。

使用Druid进行简单SQL分析 2019-04-30 13:10:03 numerical /2019/04/30/使用Druid进行简单SQL分析

Druid SQL模块除了用于进行SQL重写之外,最近发现了一个新的应用场景,即用来做一些简单SQL的分析预警。对于简单SQL,根据语句中部分传入参数值,进行预警通知,例如,

select * from foobar order by id limit 10000 offset 0;

语句中limit参数可能传入一个很大的数值,如果能进行分析,那么就可以做到预警。

获取到上述limit对应的值并不困难,在之前提及SQL重写问题时就处理过limit offset的问题。针对这个问题,解决思路也是类似的,即在遍历SQL解析之后的语法树时获取目标值。

处理limit offset值之外,能够预想到的一些可能分析有,

  • 某具体字段对应的参数值
  • IN子句元素数量

当然,SQL语句本身可能包含复杂的运算或是函数调用,对于这类场景暂时就是无能为力的。但把能做的事情做了,多少也还是有用的。

为了实现上述目标,需要自定义Visitor进行数据获取,获取到数据之后采取怎样的行动策略就很方便了。

public class AnalyseVisitor extends MySqlOutputVisitor {

    private List<Pair<String, String>> cmpInfo = new ArrayList<>();
    private List<Pair<String, String>> inInfo = new ArrayList<>();
    private String limit;
    private String offset;

    public AnalyseVisitor(Appendable appender) {
        super(appender);
    }

    public boolean visit(SQLBinaryOpExpr expr) {
        if (isIdentifier(expr.getLeft()) && isValue(expr.getRight())) {
            cmpInfo.add(Pair.of(expr.getLeft().toString(), expr.getRight().toString()));
        }
        return super.visit(expr);
    }

    public boolean visit(SQLInListExpr expr) {
        if (isIdentifier(expr.getExpr())) {
            inInfo.add(Pair.of(expr.getExpr().toString(), String.valueOf(expr.getTargetList().size())));
        }
        return super.visit(expr);
    }

    public boolean visit(SQLLimit expr) {
        limit = expr.getRowCount().toString();
        if (expr.getOffset() != null) {
            offset = expr.getOffset().toString();
        }
        return super.visit(expr);
    }

    private boolean isIdentifier(SQLExpr expr) {
        return expr.getClass() == SQLIdentifierExpr.class;
    }

    private boolean isValue(SQLExpr expr) {
        Class<?> clazz = expr.getClass();
        return clazz == SQLVariantRefExpr.class
                || clazz == SQLIntegerExpr.class
                || clazz == SQLNumberExpr.class
                || clazz == SQLCharExpr.class
                || clazz == SQLBooleanExpr.class;
    }
}

重载visitor函数,针对不同参数分别进行处理。上述示例选了SQLBinaryOpExpr、SQLInListExpr、SQLLimit。如果有更复杂的场景,那么也是类似根据情况去进行定义。

之后再使用上述Vistor,就可以获取到想要的数据了,例如,

String sql = "select * from foobar where id = 1 and value in (1, 2, 3) order by id limit 1000 offset 0";
AnalyseVisitor visitor = new AnalyseVisitor(new StringBuilder());
SQLUtils.parseSingleMysqlStatement(sql).accept(visitor);
// [(id,1)]
System.out.println((visitor).getCmpInfo());
// [(value,3)]
System.out.println((visitor).getInInfo());
// 1000
System.out.println((visitor).getLimit());
// 0
System.out.println((visitor).getOffset());

可以看出,至少在这个SQL例子中是获取到了目标数据的。再复杂一些的SQL,Visitor可能也需要再进一步去完善,覆盖遗漏的一些条件处理。不过整体方向上应该差不多。如果能在运行时进行这样的处理,不论是预警还是变换或是统计等等行动就都有可能去实施了。

关于服务限流的一些思考 2019-04-27 08:40:46 numerical /2019/04/27/关于服务限流的一些思考

流量控制在单体应用的时候也同样存在,在微服务化之后需要进行流控的场景进一步增加了。

典型的微服务架构如下,网关接收请求,路由RPC到后端应用,应用之间直接通过RPC互相调用,限流可以应用的地方就是在各入口处,例如网关接收到的HTTP请求,服务提供的RPC调用。

Flow Control

策略

限流策略有不少,典型的有,

  • 单机QPS限流。统计单进程内的QPS,在进程内进行运算,无需引入外部依赖。
  • 全局QPS限流。统计相同接口在集群多台机器上的全局QPS统计,需要引入外部依赖,如Redis,进行QPS计数统计。
  • 全局高频限流。计算模型同全局QPS,但关注接口关联的参数,例如一个用户单位时间内访问一定次数,或是特定资源参数单位时间内访问一定次数。

问题

单机QPS限流

易失效或过敏感

随着应用集群规模的增长,单机QPS适用性会逐渐降低。机器规模增长,则单机分配到的请求必然降低。单机QPS阈值设置大了可能没效果,设小了又过于敏感。如果调度策略略微不均或是部分节点故障被剔除之后,均可能造成一些设置了低阈值的单机限流被触发。

全局QPS限流

外部依赖降低了稳定性

全局QPS在触发上不会那么敏感,但是为了计算全局数据,则必然需要引入外部依赖。常见的就是引入类似Redis一样的KV存储去维护滑动窗口内的QPS统计。外部依赖的增加无疑降低了服务的可用性。在依赖服务异常时可能会造成限流功能不可用或是触发更为严重的问题。限流场景下的计算天然就是大流量的,对依赖服务的压力显而易见,因此这并不是一个可以忽略或是低概率发生的问题。

统计精度与性能压力的权衡

如果每来一个请求都很精确的进行全局计算,那么外部依赖的压力会很高。如果进行一定的预取以及本地计算,则容易造成统计不准确。这一点在集群规模很大的情况下易出现。

限流阈值的维护性问题

限流的目标是什么

个人理解是在系统能够提供的最大允许范围内尽可能提供服务,拒绝掉系统承载能力之外的请求以保证服务稳定性。

按照这个目标,那么限流阈值配置成多少就不是一个易于回答的问题。应用的承载能力在代码变更或是部署调整时都可能产生较大的变化。这类变化往往不能在事前被维护人员意识到,或者是单纯遗忘了存在着的限流配置,更常见的是初始设置成什么样的阈值也难以去度量。

如何获得合理的QPS阈值

一种思路是通过压测去对系统进行真实的度量,最后将结果关联到降级配置。这种方式的问题在于压测模型与线上真是运行环境不一定相同,但接口的压测不能说明问题,混合接口压测又难以真实反应实际流量场景。

另一种思路是通过梳理各应用监控数据,从当前系统高峰期的QPS统计,通过预定义的计算公式来计算预期的限流QPS值。通常是根据高峰期的水位情况进行一定的放大。这种方式的问题在于系统性能拐点未知,单纯的预测不一定准确。

是否可以基于系统运行反馈自动设置QPS阈值

单纯的自动设置,是很容易的。例如可以采集每一个API在每日高峰期全集群的QPS数值,乘以一定系数进行设置。但是这个值的合理性很难保证,系数可能保守可能乐观,同样会导致限流策略的无效。

如果先按照上述方式获得了一个限流值,随后根据系统运行情况去调整会如何呢?假定初始QPS阈值设高了,在限流未触发时,应用集群负载出现了问题。此时通过监控巡检去发现这一问题,反向调低QPS阈值。反过来当初始阈值低了,那么在限流触发情况下,应用负载还是正常,那莪这个时候去自动调大阈值。

看上去可以,但去实施时会发现难以动手。应用可能对外提供多个接口,可能只部分接口有限流配置。即便观测到了负载与预期不符,但与预期的差异可能是别的原因造成的,也可能是未配置限流接口带来的。此时调整已有接口的阈值就都是无用的。

思考

思考上面的问题,准确的阈值是无法通过非实时方式获取到的,如果想实时获取,那么必然要基于应用的运行状况反馈。单机器的反馈是最准确的。因此更合理的限流方式可能需要回归到单机上。

应用可能提供多个接口,任何一个接口都可能是问题所在导致负载问题。从应用的运行数据反馈中也难以准确的获取究竟是哪一个接口造成的影响。可能是单个异常接口,也可能是每个接口都贡献了一份。也就是说,更有效的限流方式是应用级的,因此不是基于QPS去进行限流,而是基于应用的负载状况去进行限流。

微信的这篇论文Overload Control for Scaling WeChat Microservices描述的就是应用级别的负载保护策略。论文中应用通过RPC队列平均等待时间这一指标去判定应用是否处在过载状态,如果过载则按请求优先级逐步限流,如果正常则逐步恢复。如果微信的系统真实采用了论文中的方案,那么自然这个策略就肯定有效。单纯进行分析,也不难发现上述策略的合理性。

实际应用中可能是多种限流策略的组合。对于特定请求进行高频限流保护,对于可以稳定梳理维护的接口通过全局QPS保护,之后再通过上述应用级限流策略进行整体兜底。多方组合,才有助于提升系统稳定性。

关于开发规范的一些想法 2019-04-20 08:05:16 numerical /2019/04/17/关于开发规范的一些想法

最近协助整理组内的开发规范,整理思考了一段时间之后梳理出了一个初版,自己也来总结下这期间的一些想法。

规范到底需不需要

有规范或是没规范,这个应该不难得出结论。有比没有好,有了规范即使没有执行也不过就是和没有规范一样,所以还是要有。

规范的好处在于明确一个方向,“取乎其上,得乎其中”,也是在面临一些问题判断时的抉择依据。

规范的目标

提升专业性,提升质量,避免可能重复出现的低级问题,提升全局效率。

规范要做到什么粒度上

之前对所谓规范不置可否的原因在于很多规范是不易落地或是过于麻烦的。如果想让规范能够去实施,那么按照个人的理解,规范必须简明。不要在细节以及容易产生个人偏好的问题上去纠结。抓大放小,只关心核心问题。

规范结果而不是过程

如果在项目之初就有一份成熟的规范,那么可能可以更有针对性的去把控。但实际往往都是在一定时间后,根据现实遭遇的一些问题而整理出规范,在这种情境下,规范关键点的输出结果会比规范如何去做的流程会更容易被接受或实施。

规范的来源

一是参考能了解到的优秀团队的做法,二是调研周边团队实际落地了的做法,三是从已有未明确化的工作流程中梳理。

规范要达成共识

对于一些强势管理风格的团队,可能自上而下强制推行。但如果不采取那么生硬的方式,那么规范必须要在团队内达成共识。

首先是目标的共识,其次是方式的共识,再次是推进手段的共识。

否则定义的规范再多也只是空文,甚至带来比较不好的负面抵触情绪。

不符合规范的要去调整,而非容忍

对于一些遗留项目的处理也是规范能否落地的关键,例外不是不能有,但一定需要是必要的。否则例外多了,例外就成了常态,规范反倒成了例外。

规范的推进与监督

推进需要落实到具体的任务中,设置改进的时间点并进行追踪。改进的工作要计入工作计划中。

能与工具自动化结合的尽量结合,不能的也定期抽查检验。

规范的迭代

规范需要是活的,需要根据项目的实际运行情况来进行调整与进化。

Druid SQL重写遇到的一个问题 2019-04-14 02:50:48 numerical /2019/04/14/Druid-SQL重写遇到的一个问题

Druid在SQL格式化输出时,使用MySQL格式输出,会将limit n offset m修改为limit m, n

select * from foobar where id > 1 limit 10 offset 0;

会变成,

select * from foobar where id > 1 limit 0, 10;

在limit offset中传入具体值时,这种改写不会带来问题,但是如果传入的是占位通配符,则对应参数的顺序就进行了调整,

select * from foobar where id > 1 limit ? offset ?;

会变成,

select * from foobar where id > 1 limit ?, ?;

这个时候通过PreparedStatement进行参数设置的时候,参数就会传反。

为了应对这种情况,需要重写格式化输出部分,将limit子句的格式化输出方式进行调整,

public class CustomSqlVisitor extends MySqlOutputVisitor {

    public CustomSqlVisitor(Appendable appender) {
        super(appender);
    }

    public boolean visit(SQLLimit x) {
        this.print0(this.ucase ? "LIMIT " : "limit ");
        SQLExpr rowCount = x.getRowCount();
        this.printExpr(rowCount);
        SQLExpr offset = x.getOffset();
        if (offset != null) {
            this.print0(this.ucase ? " OFFSET " : " offset ");
            this.printExpr(offset);
        }
        return false;
    }
}

最后再实现类似SQLUtils.toSQLString方法,将其中的Visitor实现替换成上面自定义的CustomSqlVisitor,如此limit重写问题就能得到解决,

public static String toSQLString(SQLObject sqlObject, String dbType, SQLUtils.FormatOption option) {
    StringBuilder out = new StringBuilder();
    SQLASTOutputVisitor visitor = new CustomSqlVisitor(out);
    if (option == null) {
        option = DEFAULT_FORMAT_OPTION;
    }

    visitor.setUppCase(option.isUppCase());
    visitor.setPrettyFormat(option.isPrettyFormat());
    visitor.setParameterized(option.isParameterized());
    visitor.setFeatures(option.features);
    sqlObject.accept(visitor);
    return out.toString();
}
Druid SQL的一些使用场景 2019-03-15 13:46:46 numerical /2019/03/15/Druid-SQL的一些使用场景

最近一些场景下需要对SQL进行处理,搜寻了一通发现Druid中提供的SQL处理能力可以满足需求。

Druid本身的功能特性并不止于SQL处理,但如果项目中在数据库层已经有提供类似功能的框架,一般就不太会去变化,不过Druid的SQL处理部分没有那么重的依赖,可以单纯使用SQL这部分相关功能。

SQL处理的一些典型场景有,

SQL监控统计

例如统一不同SQL语句的平均耗时、最大耗时等等统计类的需求。

在进行统计之前需要对SQL进行归一化处理,将一些变量统一进行替换,如此才能对SQL进行归类。

Druid提供了一个快捷的方法,

public class ParameterizedOutputVisitorUtils {
    public static String parameterize(String sql, String dbType);
}

// 测试执行
ParameterizedOutputVisitorUtils.parameterize("select * from foobar where id > 1", JdbcConstants.MYSQL);

// 输出
SELECT *
FROM foobar
WHERE id > ?

SQL动态拦截

拦截具体SQL

这个场景与上面类似,同样也许要将SQL归一化后处理。而后可以与预设的拦截规则进行匹配判断,将制定的SQL语句进行拦截。

拦截表操作

在某些情况下,可能需要紧急禁止对表的操作访问,也可以通过Druid进行处理。这种情况下需要获取SQL语句中的表名,

public static List<String> getRelatedTable(String sql) {
    SQLStatement statement = SQLUtils.parseStatements(sql, JdbcConstants.MYSQL).get(0);
    if (statement instanceof SQLInsertStatement) {
        return Arrays.asList(((SQLInsertStatement) statement).getTableName().getSimpleName());
    } else if (statement instanceof SQLUpdateStatement) {
        return Arrays.asList(((SQLUpdateStatement) statement).getTableName().getSimpleName());
    } else if (statement instanceof SQLDeleteStatement) {
        return Arrays.asList(((SQLDeleteStatement) statement).getTableName().getSimpleName());
    } else {
        MySqlSchemaStatVisitor visitor = new MySqlSchemaStatVisitor();
        statement.accept(visitor);
        Map<TableStat.Name, TableStat> info = visitor.getTables();
        return info.keySet().stream().map(TableStat.Name::getName).collect(Collectors.toList());
    }
}

表映射修改(影子表)

在处理数据表隔离时可能会使用影子表方案,根据环境不同将SQL路由到不同的表中,对此Druid提供了快捷操作接口,

public class SQLUtils {
    public static String refactor(String sql, String dbType, Map<String, String> tableMapping);
}

例如,

Map<String, String> mapping = new HashMap<>();
mapping.put("foobar", "foobar_shadow");
System.out.println(SQLUtils.refactor("select * from foobar where id > 1";, JdbcConstants.MYSQL, mapping));

// 输出

SELECT *
FROM foobar_shadow
WHERE id > 1

条件变更

在一些情况下对SQL的查询条件可能需要统一进行增加条件处理,对此也有直接可用的接口,

public class SQLUtils {
    public static String addCondition(String sql, String condition, String dbType);
}

System.out.println(SQLUtils.addCondition("select * from foobar", "id > 1", JdbcConstants.MYSQL));

// 输出

SELECT *
FROM foobar
WHERE id > 1
Windows 10应用安装与开发环境配置 2019-02-26 13:54:13 numerical /2019/02/26/Windows-10应用安装与开发环境配置

笔记本切换到小米Pro之后,重新开始使用Windows。因此这里来重新梳理下Windows上必备的一些软件安装以及开发环境配置。

常用软件

浏览器

Windows 10自带的Microsoft Edge、Internet Explorer在当前的网络环境下并不好用,因此还是需要另外安装浏览器。选择Firefox而不是Chome仅仅是个人偏好。

Chrome也是需要安装的,但不设置为默认浏览器,仅仅用于Firefox不兼容情况下的替代方案。

输入法

Bing输入法已经不再维护更新了,其特性可能也整合进了系统自带的微软拼音输入法,但微软拼音用着不习惯。搜狗拼音如果不是附带那么多杂七杂八的内容,也是可以考虑的。

通讯工具

除了公司内部IM之外,只安装了微信。目前也可以触及所有需要联系的人了。

阅读工具

PC主要用于PDF阅读,书以及论文等,所以Adobe Reader还是不能少的。

下载工具

延续Mac下的使用习惯,继续使用Transmission。

效率工具

Visual Studio Code基本能够满足所有简单文本编辑的需求了,插件也很丰富。日常Markdown文本编写等等都可以用它搞定

偶尔文档编写需要脑图,因此需要XMind。

影音娱乐

习惯了网易云音乐,PC上歌曲下载后易于整理,虽然部分下载变成了不方便的私有格式。

Windows上的播放器选择余地很多,同样延续Mac下的使用习惯,继续使用MPV。

开发工具

当前主要使用Java,兼用Python,因此只安装了IDEA。IDEA的插件也很丰富,其它一些功能开发的需求大都能通过插件得到满足。

cmder是Windows上的一款替代命令行工具,可以与Linux子系统进行集成,默认启动的窗口可以直接进入到Linux子系统中。

开发环境

Linux Sub System

原本切换回Windows带来的最大影响在于Windows下没有好用的命令行工具,无法便利地使用Linux/Unix Shell命令。不过Windows 10自带了Linux Sub System,开启这项功能后将能够在Windows下无缝使用Linux,因此启动这一功能是第一步。

笔记本预装的是中文版的系统,因此启动该功能的操作路径大体是,

  • 控制面板 -> 程序 -> 启用或关闭Windows功能 -> 适用于Windows的Linux子系统

开启之后需要从Windows Store下载安装适配的Linux发行版,选择了Ubuntu LTS版本。

环境配置

程序语言以及工具库的安装这里就不再列上了,反正需要什么安装什么。但因为使用了Linux子系统,一些地方还是需要注意。

对于语言来说,例如JDK、Python基本为了满足日常的需求,在Windows、Linux下都需要分别安装。但是一些可以夸平台的库就不需要存在双份,例如,

Maven用于Java的包管理,如果Windows、Linux下同时存在,缓存的本地仓库数据就会双份,同时Windows下使用IDE去编译与Linux下命令行去操作会产生差异,因此这类工具需要放在可以被共享访问的目录,两个系统使用同一份。

另外一个要点在于换行符。Windows、Linux的换行符不同,导致同一份文件在两个系统内看到的改动状态不同,因此需要设置常用的编辑工具,将换行符进行统一,统一到Linux换行符下。对于IDEA来说在命令菜单中操作,对于Visual Studio Code来说需要设置files.eol参数。

面向错误编程 2018-12-29 22:50:44 numerical /2018/12/29/面向错误编程

真正对外提供服务的代码需要关心的除了核心业务逻辑之外,就是如何处理错误了。即便是单一服务也存在着不少外部依赖,微服务之后种种依赖项就更多了。只保证核心业务逻辑实现正常是不够的,更需要细致去考虑如何应对各种错误。自己并没有想到有什么一劳永逸的办法,但认识到这一问题具备这样的意识总是没错的。

逃不开的“墨菲定律”,凡是可能出现问题的都会出现问题。如果编码的时候就隐约感觉到某部分可能出问题,那大概率一定会发生,但更多的问题是编码时候没有意识到的。如果可以无脑的按照某种思路步骤一步步去考虑应对,相对来说系统的可用性可能可以得到改善。

主动处理外部依赖异常

首先要解决外部依赖问题,不管是数据库、缓存还是下游RPC,代码调用的这些服务都可能异常或者超时。这些依赖调用地方都要做好异常处理,有网络请求的都需要设置超时时间,避免被拖垮。

外部依赖的破坏性测试

不论是手动触发真正的依赖故障,还是依托故障注入的手段。对于外部依赖处理需要通过破坏性测试去进行验证。如果已经有方便的框架流程可以自动跑当然好,没有的话也需要手动去进行验证。只有实际测试过才能够验证上面异常处理的正确性。

预埋依赖隔离开关

很多时候除了对依赖异常进行处理,还可以考虑预留开关在其出现问题的时候进行手动隔离。依赖隔离肯定是有损的,但有损肯定也比服务不可用强得多。

尽可能多的使用配置下发机制

有时候触发问题是因为一些配置项参数设置不合理,比如不合理的超时时间、不合理的重试次数等,能下发的配置最好都进行下发。这样有问题的时候可能可以通过配置变更救回。

关键路径的单元测试覆盖

单元测试不要去追求全局覆盖率,但是关键路径还是要去完善的。否则功能本身的正确性无从验证,后续代码变更就更提心吊胆了。核心功能要覆盖测试,一些兜底处理策略也要覆盖测试,否则预留的后手可能根本不能生效。

关键路径包含哪些呢?放在可用性层面的话,那就是一些开关控制逻辑,资源初始化与清理逻辑,配置变更下发响应逻辑。

性能测试

性能测试往往也是能够发现问题的。比如线程池参数设置不合理,高并发访问很容易导致线程池资源耗尽。性能测试利于发现此类问题。性能测试最好能自动化、常态化。

灰度上线验证

代码上线最好也是一步步来,时间不紧急的话,小规模灰度看效果,再逐步放开。有的时候过于乐观自信容易导致问题,还是稳妥一点比较好。

总结

写bug虽然免不了,但要把bug的影响范围控制住。在微服务场景中,除了业务功能之外,还要注意保护服务自身、避免拖累其它服务。如上这些手段策略一定程度上可以帮助实现这一目标,当然最为重要的还是编码本身了。如何写出正确的代码,那又是另外的问题了。

微服务的一些理解 2018-12-15 08:54:06 numerical /2018/12/15/微服务的一些理解

微服务这一概念之前只是简单了解没有直接体会,近距离观察一段时间之后有了一些看法。

优点

易于多团队协作

将单一服务切分到微服务之后,容易与实际中的团队组织结构相对应。以服务为粒度,可以交由不同团队进行维护,彼此之间通过RPC等通信协议进行协作。在业务内容与人员都达到一定规模的场景下,这样的切分实际能够划定不同团队之间的工作边界,使得团队之间的协作不那么耦合。

风险隔离,提升稳定性

单一系统遇到问题的时候很容易造成整体服务不可用,拆分微服务之后,很多问题只会在局部服务中出现,问题的影响范围可以得到控制,提升了系统整体的稳定性与可靠性。

便于定向优化

服务拆分之后,局部的性能问题可以有更多的手段与策略进行优化。优化的成本与风险都会相较单一服务低。堆机器这种粗暴方案也能更有针对性。

降低技术升级的心智负担

如上所说单独的服务即使遇到问题,影响的范围也相对可控,因此更利于进行一些技术升级改造。当然,实际上的优化升级往往都是很漫长的过程,但那更多是愿不愿意改的问题,而不是敢不敢改的问题。

降低成本

当系统的复杂性达到一定量级之后,微服务化不仅仅从资源层面可以让硬件资源针对部分服务进行适配,提升资源利用率。另一方面也可以降低系统维护复杂度,微服务自身的复杂度与系统整体的复杂度相比减轻了很多,对于维护的人员来说,可以筛减掉很多无需关心的内容。

缺点

问题排查变得复杂

原先可以在一处解决的问题,变成了一系列RPC调用。原本可能就是代码逻辑问题,现在则可能是RPC下游的服务可用性问题,RPC调用的网络问题。在问题排查定位上,需要关注的因素会更多。

服务运行环境变得复杂

单一服务很容易进行部署,微服务之后服务之间的依赖关系使得微服务的部署变得复杂。不同环境、不同版本所需部署的微服务都有各自的需求。

沟通协调增加

微服务之间的依赖关系,使得在某些服务升级变更的时候需要沟通上下游,增加了沟通协调成本。

响应时间增加

不同服务之间的RPC调用肯定比本地的函数调用慢,网络调用开销的增加是不可避免的。如果服务链路过长,那么受到的影响就更明显了。

关键点

服务切分合理性

微服务的关键问题是如何进行划分,如何控制服务的粒度。服务切分得不好会带来后续一系列问题。如何切分这件事情和业务场景相关,也和实际人员的经验能力相关。

服务依赖可视化

微服务之间的依赖关系需要以简明直接的方式进行展现,这样有利于在服务切分不合理的时候第一时间发现。

调用链路追踪

微服务之间的调用链路一定需要进行追踪,否则问题排查是无从进行的。

监控预警

当微服务数量达到一定量级,局部出现问题总是无可避免的,这种情况下完善的监控预警就显得尤为重要了。否则在遇到问题时容易抓瞎。

代码框架与公共服务统一

微服务化之后也会继续出现新的业务逻辑与场景,这就意味着会继续微服务化。那么代码层面的一致性与底层框架公共服务的可用性、易用性就变得很重要了。

程序语言的统一

理论上微服务之后服务之间只要保证协议即可,使用什么语言都可以。但实际上需要进行控制,多语言的成本太高,完全没有必要。

RocketMQ os.sh参数说明 2018-09-02 22:15:11 numerical /2018/09/02/RocketMQ-ossh参数说明

RocketMQ在消息读写上的处理是其性能的关键,重度依赖底层操作系统的特性。os.sh中提供了一组默认的操作系统参数配置,这里一条条来分析下。

命令

sudo sysctl -w vm.extra_free_kbytes=2000000

这个参数应该也是用来控制空闲内存大小的。但是很多发行版没有这个参数,一般根据部署环境看是否支持,不支持的话不进行设置应该也没关系。

sudo sysctl -w vm.min_free_kbytes=1000000

设置系统需要保留的最小内存大小。当系统内存小于该数值时,则不再进行内存分配。这个命令默认被注释掉,根据文档看,如果设置了不恰当的值,比如比实际内存大,则系统可能直接就会崩溃掉。实际数值应该根据RocketMQ部署机器的内存进行计算,经验数值大概是机器内存的5% - 10%。

这个数值设置的过高,则内存浪费。若设置的过低,那么在内存消耗将近时,RocketMQ的Page Cache写入操作可能会很慢,导致服务不可用。

sudo sysctl -w vm.overcommit_memory=1

控制是否允许内存overcommit。设为1,则是允许。当应用申请内存时,系统都会认为存在足够的内存,准许申请。

sudo sysctl -w vm.drop_caches=1

设置为1会释放page cache。这个操作是一次性的,在执行该命令时释放page cache,没有持续性作用。

sudo sysctl -w vm.zone_reclaim_mode=0

该配置用于控制zone内存使用完之后的处理策略,设为0则禁止zone reclaim。对于大量使用缓存的应用来说,一般都需要禁止掉。

sudo sysctl -w vm.max_map_count=655360

Rocket使用MMAP映射文件到内存,因此需要设置映射文件的数量,避免MMAP操作失败。

sudo sysctl -w vm.dirty_background_ratio=50

设置内存中的脏数据占比,当超过该值时,内核后台线程将数据脏数据刷入磁盘。

sudo sysctl -w vm.dirty_ratio=50

设置内存的脏数据占比,当超过该至时,应用进程会主动将数据刷入磁盘。

sudo sysctl -w vm.dirty_writeback_centisecs=360000

内核线程会定期启动将内存中的旧数据刷入磁盘,通过此参数可以控制启动间隔。

sudo sysctl -w vm.page-cluster=3

设置一次读操作会加载几个数据页。

sudo sysctl -w vm.swappiness=1

vm.swappiness用于控制使用系统swap空间比例,一般设置为0是禁止使用swap,设为1估计是某些系统不支持设置为0.

RocketMQ的数据存储基于MMAP,大量使用内存,因此需要禁止swap,否则性能会急剧下降。

echo 'ulimit -n 655350' >> /etc/profile

设置可以打开的文件符号数。RocketMQ的存储都是基于文件的,因此稍稍设大默认值。

echo '* hard nofile 655350' >> /etc/security/limits.conf

设置一个进程能够打开的文件数。

echo 'deadline' > /sys/block/${DISK}/queue/scheduler

RocketMQ使用MMAP映射文件到内存,在存在新消息的时候都是追加顺序写,投递消息的时候则是根据Offset从CommitLog进行随机读取,Deadline调度方法会在调度时间内合并随机读为书序读,因此对RocketMQ性能有帮助。

总结

RocketMQ的存储模型以来在MMAP纸上,因此os.sh大部分参数控制都与内存管理相关。这些参数对Broker的性能有明显影响。默认提供的数值可以作为一个参考,实际还是可以根据这些参数的定义与实际机器配置来测试得到更优的配置。

参考

RocketMQ 延时消息实现 2018-09-02 13:35:47 numerical /2018/09/02/RocketMQ-延时消息实现

RocketMQ提供了延时消息实现,不过这个延时是一定级别的延迟,默认在MessageStoreConfig.messageDelayLevel定义,

1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h

这里来看下RocketMQ实现这一功能的相关实现。

消息存储

延时消息的存储过程与一般消息存在差异,在CommitLog.putMessage中对其做了特殊处理。

设置了Delay Level的消息,在存盘之前Broker会修改Topic为延时Topic,SCHEDULE_TOPIC_XXXX,同时备份原Topic信息到消息属性当中。而后则按正常消息存储流程进行处理。不同的Delay Level会对应到不同的MessageQueue中。

消息投递

因为修改了Topic,所以数据存盘之后Consumer并不能消费到消息。在消费之前需要将消息重新投递回初始的Topic,再经由正常消费逻辑进行处理。

重新投递这一逻辑在ScheduleMessageService中实现。从代码来看,延时的处理策略不复杂。

ScheduleMessageService中通过一个Timer来延时触发投递消息检查。每一个Delay Level对应一个DeliverDelayedMessageTimerTask。当对应Task被触发时会去检查当前Delay Level对应的MessageQueue中待处理的消息时间是否到达,如果没有满足条件则再次定时检查,如果延时时间满足,则恢复Message原Topic,并通过DefaultMessageStore.putMessage重新进入正常消息处理流程。

定时消息

RocketMQ没有提供定时消息功能,如何在MQ中实现支持任意延迟的消息对这个问题进行了分析讨论,个人觉得描述的比较清楚。

如果后续考虑在RocketMQ上实现定时消息功能的话,可以按照类似的思路进行实施。