计算机系统中的时间

前言

对于计算机系统中的时间,如果你曾经思考过下面的问题,但是没有结论,那么通过本文将给你详细的解答:
1. 闰秒是怎么产生的,在2012年6月30日UTC插入一个闰秒后,大量linux服务器宕机的原因是什么?
2. 计算机系统是怎么保证自己的时间是准确的?
3. 计算机系统我们经常使用微妙甚至纳秒,它怎么来提供这么高精度的时间?
4. 计算机系统是没有时间概念的机器,那么它是怎么来计算与管理时间的?

背景

时间是一个非常抽象的问题,吸引着许多伟大的神学家、哲学家和物理学家花毕生精力去解释时间的本质是什么,然而依然没有定论。幸运的是我们仅仅需要讨论计算机系统中的时间相关的问题,可以不用关心宇宙、黑洞、相对论和量子力学等等繁复的课题,仅仅局限在计算机这一个很小的范畴中,这看似非常简单的主题,然而现实却并不会如此简单。

计算机系统的时钟

在计算机系统中主要有两种时钟:一种是墙上时钟,一种是单调时钟。它们都可以衡量时间,但却有着本质的区别。下面我们一一来分析。

墙上时钟

墙上时钟又称为钟表时间,顾名思义,和我们平时使用的钟表的时间一样,表示形式为日期与时间。在linux系统中墙上时钟的表示形式为UTC时间,记录的是自公元1970年1月1日0时0分0秒以来的秒数和毫秒数(不含闰秒),linux系统需要处理闰秒的逻辑就是由于linux系统使用UTC时间,但是系统中记录的UTC时间是不含闰秒导致的,后面会闰秒相关的部分会有详细的介绍。

时间同步

根据定义可以发现,墙上时钟的标准是在计算机外部定义的,所以需要确保墙上时钟的准确性就变成一个问题。
计算机内部的计时器为石英钟,但是它不够精确,存在过快或者过慢的问题,这主要取决于机器的温度。所以依靠计算机自身来维持墙上时钟的准确性是不可能的。
目前普遍采取的一种方式为计算机与NTP时间服务器进行定期通过网络同步。当然这个方式受限于网络环境的影响,一般来说至少会有35毫秒的偏差,最大的时候可能会超过1秒。
对于一些对时间精度要求很高的系统,通过NTP进行同步是远远不够的,而通过GPS接收机接受标准的墙上时钟,然后在机房内部通过精确时间协议(PTP)进行同步。PTP是一种高精度时间同步协议,可以到达亚微秒级精度,有资料说可达到30纳秒左右的偏差精度,但需要网络的节点(交换机)支持PTP协议,才能实现纳秒量级的同步。
对于时间同步,Google的做法更酷,通过GPS接收机接受标准的墙上时钟,然后通过机房内部部署原子钟(精度可以达到每2000万年才误差1秒)来防止GPS接收机的故障。通过这些时间协调装置会连接到特定数量的主服务器,然后再由主服务器向整个谷歌网络中运行的其他计算机传输时间读数(TrueTime API)。

闰秒出现的原因

目前存在两种时间计量系统:基于地球自转的世界时(UT1),它以地球自转运动来计量时间,但由于地球自转速率正在变慢,所以世界时的秒长会有微小的变化,每天达到千分之几秒。原子时是取微观世界的铯原子两个超精细能级间跃迁辐射频率来度量时间,精确度非常高,每天快慢不超过千万分之一秒。
从上面可以看出,原子时是度量时间均匀的尺度,但是与地球空间位置无关;世界时度量时间的均匀性不好,但是它定义地球自转一周为一天,绕太阳公转一周为一年,这对人们的日常生产生活非常重要。
为了统一原子时与时间时直接的差距,就产生了协调世界时(UTC)。从1972年1月1日0时起,协调世界时秒长采用原子时秒长,时刻与世界时时刻之差保持在正负0.9秒之内,必要时用阶跃1整秒的方式来调整。这个1整秒的调整,就称为闰秒(增加1秒为正闰秒,较少1秒为负闰秒)。UTC从1972年1月正式成为国际标准时间,它是原子时和世界时这两种时间尺度的结合。

闰秒的处理

由于linux系统记录的是自公元1970年1月1日0时0分0秒以来的秒数和毫秒数,但是不含闰秒,这表示在linux系统中每分钟有60秒,每天有86400秒是系统定义死的。所以linux系统需要额外的逻辑来处理闰秒。

跳跃式调整

当UTC时间插入一个正闰秒后,linux系统需要跳过1秒,因为闰秒的这一秒钟在linux系统中不能被表示;当UTC时间插入一个负闰秒后,linux系统需要插入1秒,因为闰秒的这一秒钟在linux系统中不存在。目前linux系统就是采用该方式来处理闰秒的。在2012年6月30日UTC时间插入一个正闰秒的时候,由于linux系统的某些版本的闰秒处理逻辑触发了一个死锁的bug,造成了大规模的linux服务器内核死锁而宕机。

NTP服务的slew模式

NTP服务的slew模式并不使用跳跃式修改时间,而是渐进式的调整。比如当UTC时间需要插入一个正闰秒,NTP服务会每秒调整一定ms来缓慢修正时间。这样linux系统从NTP服务同步时间的时候就不会感知闰秒的存在了,内核也就不需要启动闰秒相关的逻辑了。

单调时钟

单调时钟它总是保证时间是向前的,不会出现墙上时钟的回拨问题。它非常适合用来测量持续时间段,比如在一个时间点读取单调时钟的值,完成某项工作后再次获得单调时钟的值,时钟值之差为两次检测之间的时间间隔。
但是单调时钟的绝对值没有任何意义,它可能是计算机自启动以后经历的纳秒数等等。因此比较不同节点上的单调时钟的值是没有意义的。

时间的管理

时间的概念对于计算机来说有些模糊,计算机必须在硬件的帮助下才能计算和管理时间。前面说的石英钟就是用来做计算机的系统定时器的,系统定时器以某中固定的频率自行触发时钟中断。由于时钟中断的频率是编程预定的,所以内核知道连续两次时钟中断的间隔时间。这个间隔时间就称为节拍,它等于千节拍分之一秒。通过时钟中断,内核周期性地更新系统的墙上时钟和单调时钟,从而计算和管理好时间。

时间的精度

目前系统定时器的中断频率为1000HZ,那么计算机能处理的时间精度为1ms。然而很多时候需要更加精确的时间,比如1微妙,计算机是怎么来解决这个问题的呢?
在每一次计算机启动的时候,计算机都会计算一次BogoMIPS的值,这个值的意义是处理器在给定的时间内执行指令数,通过BogoMIPS值,计算机就可以得到很小很小的精度了。比如1秒计算机执行了N条指令,那么计算机的精度就可以达到N分之一秒。很明显N是一个非常非常大的数目,因而计算机可以得到非常非常精确的时间。

总结

在本文中,我们讨论了计算机系统时间同步的方式,同时分析了闰秒产生的原因,以及linux系统应对的办法,然后概览性的讲了linux系统是怎么进行时间的计算与管理的,最后分析了linux系统可以提高高精度时间的方法。

数据库隔离级别剖析

前言

在线应用业务中,数据库是一个非常重要的组成部分,特别是现在的微服务架构,我们为了水平扩展能力,倾向于将状态都存储在数据库中,这样要求数据库高性能并且正确地处理请求。这几乎是一个不可能达到的要求,使得数据库的设计者们定义了隔离级别这一个概念,在高性能与正确性之间提供了一个缓冲地带,明确地告诉使用者,我们提供正确性差一点但是性能好一点的模式和正确性好一点单身性能差一点的模式,使用者按照你们的业务场景来选择使用吧。

本质

本质来说,隔离级别是定义数据库并发控制的。在我们应用程序的开发中,我们通常利用锁来进行并发控制,确保临界区的资源不会多个线程同时进行读写,这对应与数据库的隔离级别为可串行化(最高的隔离级别)。现在发现离级别是和我们日常开发很近的一个概念了吧,那么现在肯定会有一个问题,为什么应用程序可能提供可串行化的隔离级别,而数据库不能提供呢?其根本的一个原因是应用程序都是内存操作,数据库基本都需要持久化到磁盘,内存操作和磁盘操作的耗时是好几个数量级的差别,锁的临界区变长,会导致竞争变的激烈,程序的性能会大大降低。

隔离级别

数据库的隔离级别,目前主流的定位为以下四个等级:读未提交,读已提交,可重复读,可串行化。但是由于各个数据库的具体实现各不相同,所以我们先不讨论隔离级别的定义,直接从各个隔离级别会带来的异常情况来分析隔离级别的定义。

异常

从读未提交到可串行化,数据库可能出现的异常为:
脏写
事务a覆盖了其他事务尚未提交的写入。

脏读
事务a读到了其他事务尚未提交的写入。

读倾斜
事务a在执行过程中,对某一个值在不同的时间点读到了不同的值。即为不可重复读。

更新丢失
两个事务同时执行读-修改-写入操作序列,出现了其中一个覆盖了另一个的写入,但是没有包含对方最新的值的情况,导致了被覆盖部分修改数据发生了丢失。

幻读
事务先查询了某些符合条件的数据,同时另一个事务执行写入,改变了先前的查询结果。

写倾斜
事务先查询数据库,根据返回的结果而作出某些决定,然后修改数据库。在事务提交的时候,支持决定的条件不再成立。写倾斜是幻读的一种情况,由于读-写事务冲突导致的幻读。写倾斜也可以看做一种更广义的更新丢失问题。即如果两个事务读取同一组对象,然后更新其中的一部分:不同的事务更新不同的对象,可能发生写倾斜;不同的事务跟新同一个对象,则可能发生脏写或者更新丢失。

异常避免

对应四个隔离级别,我们分别来看看他们有什么异常情况,以及怎么通过应用层的优化来避免该异常的发生。

对于脏写,几乎所有的数据库都可以防止,我们用的mysql和tidb更是没有问题,所以不讨论脏写的情况;

对于脏读,提供读已提交 隔离级别以及以上隔离级别的都可以防止问题的出现,如果业务中不能接受脏读,那么隔离级别最少在读已提交 或者以上;

对于读倾斜,可重复读 隔离级别以及以上隔离级别的都可以防止问题的出现,如果业务中不能接受脏读,那么隔离级别最少可重复读 或者以上;

对于 更新丢失,幻读,写倾斜,如果只通过数据库隔离级别来处理的话,那么只能才有 可串行化 的隔离级别才能防止问题的出现,然后生产环境中,我们是不可能开启 可串行化 隔离级别的,那么是数据库直接不支持,要么是数据库支持,但是性能太差。因而在时机开发中,我们只能中可重复读的隔离级别的基础上,通过一些其他的手段来防止问题的发生。

怎么避免更新丢失

如果数据库提供原子写操作,那么一定要避免在应用层代码中完成“读-修改-写”的操作,直接通过数据库的原子操作来执行,这样就可以避免更新丢失的问题。数据库的原子操作例如关系数据库中的 udpate table set value=value+1 where key=*,mongodb也提供类似的操作。 数据库的原子操作一般通过独占锁来实现,相当于少可串行化的隔离级别,所以不会有问题。不过在使用ORM框架的时候,就很容易在应用层代码中完成“读-修改-写”的操作,导致无法使用数据库的原子操作。
另外一个情况,如果数据库不支持原子操作,或者在某一些场景,原子操作不能处理的时候,可以通过对 查询结果 显示加锁来解决。对于mysql来说,就是 select for update,通过for update告诉数据库,我查询出来的数据行一会是需要跟新的,需要帮我加锁防止其他的事务也来读取更新导致更新丢失。
一种更好的避免更新丢失的方式是数据库提供 自动检测 更新丢失的机制。数据库先让事务都并发执行,如果检测到有更新丢失的风险,直接中止当前事务,然后业务层在重试即可。目前PostgreSQL的可重复读,Oracle的可串行化等都提高自动检测 更新丢失的机制,但是mysql的 InnoDB的可重复读并不支持。
有某一些情况下,还可以通过 原子比较和设置来实现,例如:update table set value=newvalue where id=* and value=oldvalue。但是该方式有一个问题,如果where条件的判断是基于某一个旧快照来执行的,那么where的判断是没有意义的。所以如果要采用 原子比较和设 来避免更新丢失,那么一定要确认 数据库 比较-设置 操作的安全运行条件。

怎么避免幻读中的写倾斜

在 怎么避免更新丢失 我们提供了很多种方式来避免 更新丢失,那么在 写倾斜 的时候可以使用吗?
1、原子操作上不行的,因为涉及到多个对象的更新;
2、所以的数据库几乎都没有自动 自动检测 写倾斜的机制;
3、数据库自定义的约束功能对于多个对象也基本不支持;
4、显式加锁 方式上可以的,通过select for update,可以确保事务以可串行化的隔离级别,所以这个方案上可行的。但是不是对于所有的方式都可以使用,如果select for update 在select的时候不能查询到数据,这个时候数据库无法对数据进行加锁。例如:
在订阅会议室的时候,select的时候会议室还没有被订阅,所以查询不到,数据库也没有办法进行加锁,update的时候,多个事务都可以update成功。
所以,显式加锁对于写倾斜不能适用的原因是因为在select阶段没有查询到临界区的数据,导致无法加锁。所以在这种情况下,我们可以人为的引入用于加锁的数据,然后通过 显式加锁 来避免 写倾斜的问题。
比如在订阅会议室的问题中,我们为所有的会议室的所有时间都创建好数据,每一个时间-会议室一条数据,这个数据没有其他的意义,只是用来select for update的时候由于select 查询到数据,用于数据库来加锁。
另外一种方式是在数据库提供可串行化隔离级别,并且性能满足业务要求时,直接使用可串行化的隔离级别。

从B tree、B-tree、B+tree到LSM tree

通过分别对B tree、B-tree、B+tree和LSM tree的读写时间复杂度的分析,以及它们对io的访问方式(顺序io或者随机io)进行一一对比,以了解为什么文件索引系统是B+tree或者LSM tree。
B tree(二叉搜索树):
1.所有非叶子结点至多拥有两个儿子(Left和Right);
2.所有结点存储一个关键字;
3.非叶子结点的左指针指向小于其关键字的子树,右指针指向大于其关键字的子树;

读写时间复杂度:
从B tree的定义可以得出,如果B Tree是平衡二叉树,那么查询性能和二分查找相当;但是更新操作则比连续内存的二分查找要高效很多,更新连续内存的二分查找的记录可能需要移动大段的内存数据,而B Tree则只需插入和删除节点,基本为常数级别的开销(修改操作的对比与数组和链表结构类似)
io访问方式:
依照B tree的定义,每一个节点存储一个关键字,那么在读写过程中,每读一个关键字,都需要一次随机io读,这个对于文件索引系统是非常致命的,所以B tree只用于内存索引系统。(内存的随机io非常快,磁盘的随机io非常慢)

B-tree(多路搜索树):
1.任意非叶子结点最多只有M个儿子;且M>2;
2.根结点的儿子数为[2, M];
3.除根结点以外的非叶子结点的儿子数为[M/2, M];
4.每个结点存放至少M/2-1(取上整)和至多M-1个关键字;(至少2个关键字)
5.非叶子结点的关键字个数=指向儿子的指针个数-1;
6.非叶子结点的关键字:K[1], K[2], …, K[M-1];且K[i] < K[i+1];
7.非叶子结点的指针:P[1], P[2], …, P[M];其中P[1]指向关键字小于K[1]的子树,P[M]指向关键字大于K[M-1]的子树,其它P[i]指向关键字属于(K[i-1], K[i])的子树;
8.所有叶子结点位于同一层;

读写时间复杂度:
从B-tree的定义可以得出,在B Tree的基础上将二叉扩展到三叉,并且增加了对节点利用率的限制(第4点),同时确保为平衡树(第8点)。这样保证了B-tree的查询性和二分查找相当;修改操作为了满足节点的约束条件,在插入结点时,如果结点已满,需要将结点分裂为两个各占M/2的结点;删除结点时,需将两个不足M/2的兄弟结点合并;
io访问方式:
依照B-tree的定义,每一个节点存储多个关键字,那么在读写过程中,每经过一个层级,都需要一次随机io读,一次随机读,可以读到多个关键字,这比B tree已经有了非常大的改进,但是由于关键字分布在所有的节点上,所以不支持有序遍历。

B+树:
1.其定义在B+树的基础上,增加了:
2.非叶子结点的子树指针与关键字个数相同;
3.非叶子结点的子树指针P[i],指向关键字值属于[K[i], K[i+1])的子树(B-树是开区间);
5.为所有叶子结点增加一个链指针;
6.所有关键字都在叶子结点出现;

读写时间复杂度:
从B+树的定义可以得出,它的查询性能和二分查找相当;修改操作与B-tree相当;
io访问方式:
依照B+tree的定义,每一个节点存储多个关键字,那么在读写过程中,每经过一个层级,都需要一次随机io读,并且由于关键字都存储在叶子节点,一次随机读,可以读到更多的关键值,所以这比B+tree已经有了非常大的改进。同时由于所有叶子结点增加一个链指针指向其兄弟节点,所有关键字都在叶子结点出现,所以B+tree能很好的支持有序遍历,因而在文件索引系统中广泛使用;

LSM-tree:
LSM-tree是由两个或两个以上存储数据的结构组成的。最简单的LSM-tree由两个部件构成。一个部件常驻内存,称为C0树(或C0),可以为任何方便键值查找的数据结构,另一个部件常驻硬盘之中,称为C1树(或C1),其数据结构与B-tree类似。C1中经常被访问的结点也将会被缓存在内存中。

当插入一条新的数据条目时,首先会向日志文件中写入插入操作的日志,为以后的恢复做准备。然后将根据新条目的索引值将新条目插入到C0中。将新条目插入内存的C0中,不需要任何与硬盘的I/O操作,但内存的存储代价比硬盘的要高上不少,因此当C0的大小达到某一阈值时,内存存储的代价会比硬盘的I/O操作和存储代价还高。故每当C0的大小接近其阈值时,将有一部分的条目从C0滚动合并到硬盘中的C1,以减少C0的大小,降低内存存储数据的代价。C1的结构与B-tree相似,但其结点中的条目是满的,结点的大小为一页,树根之下的所有单页结点合并到地址连续的多页块中。

读写时间复杂度:
从LSM-tree的定义可以看出,写入操作的时间复杂度很低,只需要进行一次顺序写入,另外加一次内存写入(内存数据结构的写入时间复杂度应该和二分查询相当,不过数据量会更小);读操作的时间复杂度为多次二分查询,因而也为logN。
io访问方式:
它的写入操作只需要一个顺序写和一个内存更新,因而写入的io效率非常高(其中更新和删除都是以写入记录的方式实现);读取操作最少需要一个内存读,另外可能还有多次类似B+tree的读取,读性能要低于写性能。

服务器连锁故障剖析

连锁故障的由于正反馈循序导致不断扩大规则的故障。一个连锁故障通常是由于整个系统中一个很小的部分出现故障于引发,进而导致系统其它部分也出现故障。比如某一个服务的一个实例出现故障,导致负载均衡将该实例摘除而引起其它实例负载升高,最终导致该服务的所有实例像多米诺骨牌一样一个一个全部出现故障。
一个正常运行的服务是怎么发生连锁故障的呢?

一、服务器过载

服务器过载是指服务器只能处理一定qps的请求,当发往该服务器的qps超出后,由于资源部够等原因导致崩溃、超时或者出现其他的异常情况,结果导致服务器成功处理的请求远远不及正常情况可处理的qps。这种成功处理请求能力的下降可能会导致服务实例的崩溃等异常情况,当服务崩溃后,负载均衡器会将请求发送给其他的集群,使得其他的集群的实例也出现过载的情况,从而造成整个服务过载的故障。一个过程通常非常快,因为负载均衡器的响应速度通常是非常快的。

二、资源耗尽

资源耗尽会导致高延迟、高错误率或者低质量的回复发生。而这些问题不导致负载上升直至过载,从而发生连锁故障。
下面来分析不能重量资源耗尽对服务器产生的影响:

1、cpu资源不足一般会导致请求变慢,有以下几种情况:

  • 正在处理的请求数量上升,这会导致同一时间服务器必须同时处理更多的请求,也将会导致其他的资源的需求上涨,包括内存,线程,文件描述符等等资源的上涨;
  • 正在等待处理的队列过长,这会导致请求的延迟上升,并且队列过长也会导致内存使用量上升;
  • 线程卡住,如果一个线程由于等待某一个锁而无法处理请求,可能服务器无法在合理的时间内处理健康检查请求而被重启;
  • cpu死锁或者请求卡住,由于cpu死锁或者请求卡住,导致健康检查无法通过而被重启;
  • rpc超时,由于cpu资源不足导致响应变慢引起rpc超时,而rpc超时可能会导致客户端的重试,造成系统的过载;
  • cpu缓存效率下降,cpu使用率越高,导致任务被分配到多个cpu核心上的几率越大,从而导致cpu核心的本地缓存失效,进而降低cpu处理的效率;

2、内存资源不足会导致以下的情况发生:

  • 任务崩溃,内存不足可能会导致任务被系统oom或者自身逻辑导致服务崩溃;
  • gc速率加快,导致cpu使用率上升,cpu使用率上升导致请求变慢,进一步导致内存上升。(gc死亡螺旋)
  • 缓存命中率下降,可用内存的减少会导致缓存命中率的降低,导致向后端发送更多的rpc,可能会导致后端服务过载;

3、线程不足会导致以下情况的发生:

  • 导致请求错误,这可能会导致客户端的重试,造成系统的过载;
  • 导致健康检查失败而被重启;
  • 增加的线程会消耗更多的内存;
  • 极端情况下回导致进程id不足;

4、文件描述符不足会导致以下情况的发生:

  • 导致无法建立新的网络连接导致请求错误;
  • 导致健康检查失败而被重启;

三、服务不可用

当资源耗尽导致服务的崩溃,比如内存耗尽等等,一个服务实例不可用,比如崩溃等等。由于负载均衡会自动忽略不健康的实例,导致其他健康实例的负载升高,从而导致连锁故障。

那么怎么来应对连锁故障呢?一般来说可以采用下面的方法来避免连锁故障,按优先级排列为:

  • 进行压力测试,测试服务器的极限,同时测试过载情况下的失败模式;
  • 提供降级结果;
  • 在过载情况下服务主动拒绝请求;
  • 在反向代理层,针对请求的特性进行数量限制(ip),防止ddos攻击;
  • 在负载均衡层。在服务进入全局过载时进入主动丢弃请求。
  • 服务自身避免负载均衡的随机抖动导致过载;
  • 进行容量规范,容量规范只能减少连锁故障的可能性,不能避免连锁故障;

避免连锁故障的具体的策略为:

队列管理

提前规划好请求队列容量,当队列满时服务器主动拒绝新的请求。另外在服务器过载的时候,后进先出的队列模式比先入先出要好。

流量抛弃

流量抛弃有两个方式:
1、服务器流量抛弃:
在服务器临近过载时,主动抛弃一定量的负载。比如cpu达到一定得使用率、内存达到一定得使用率或者服务器请求队列容量达到最大值的时候,服务器端可以对一些流量直接抛弃。
2、客户端流量抛弃:
反向代理或者负载均衡层在系统快进入或者已经进入连锁故障的情况下,直接抛弃一部分流量。
流量抛弃可以和一些策略进行结合,比如请求的优先级,用户的优先级等等。同时流量抛弃和截止时间配合起来效果非常不错。

优雅降级

优雅降级是在接受该请求的情况下,通过降低回复的质量来大幅较少服务器的计算量。流量抛弃已经让服务器直接少处理了很多请求,但是对于已经接受的请求,服务器是需要处理的,这个时候如果有优雅降级机制,能大大较少服务器的计算量,并且能一定程度的保证用户体验。

重试控制
  • 使用随机化,指数型递增的重试周期,防止重试风暴;
  • 限制每个请求的重试次数,防止在服务器过载的情况下,出现重试出错,出错重试导致连锁故障;
  • 考虑全局重试预算。比如每个服务每分钟只容许重试60次,重全局的角度控制重试的范围和力度;
  • 不要在多个层数上重试,一个高层的请求可能会导致各层的重试,所以重试的时候,一定要明确在一个层面重试,防止多层重试导致重试的放大;
  • 使用明确的错误码,将可重试错误和不可重试错误分开,不可重试的错误一定不要重试。一般来说临时错误是可以重试的,非临时错误或者服务过载的时候,就不应该再进行重试;
请求延迟和截止时间

在顶层给每一个请求增加一个截止时间,并且在每一层进行传递,同时每一层在请求之前进行检查,过期的请求直接抛弃。在服务器过载的情况下,请求的延迟会加大,请求会在队列中排队等待很长的时间,比如一个请求等待30s后才开始执行,但是对于客户端来说,用户早已经放弃等待该请求的结果了,所以这对这样的请求继续执行是没有意义的,只会浪费服务器的计算资源,进一步加速了连锁故障。所以对于这样的请求,应该在请求前直接抛弃,将服务器的计算资源应用在其他有意义的请求上面;

保持调用栈永远向下

同层通信容易导致分布式死锁,比如一个服务实例a由于线程池没有空闲线程而将请求挂起,这个时候如果实例b将一个请求将请求转发到实例a而导致实例b线程的消耗,在最坏的情况下可能会导致连锁故障的发生。一个比较好的方式是将同层通信的逻辑转交给客户端来处理,比如一个前端需要后后端通信,但是猜错了后端服务,这个时候后端服务应该返回正确的后端服务,让客户端再次发起请求,而不是直接代理请求到正确的后端服务。

连锁故障测试

测试直到出现故障,再继续测试,通过测试发现连锁故障出现的原因;并且也应该测试非关键性的后端,确保它们的不可用不会影响到系统中的其他关键组件,比如它们会不会影响请求的时延,会不会导致正常请求的超时等等。

总结

当一个系统过载的时候,一定需要牺牲一些东西的,这样比尝试继续请求而导致所有请求都不能正常服务要好。理解这些临界点以及超过这些临界点后系统的行为模式,是我们避免连锁故障必须掌握的。
一般来说,我们为了降低服务背景错误率活着优化稳定状态的改变反而会让服务更容易出现事故。比如在请求失败时的重试、负载自动转移、自动杀掉不健康的服务器、增加缓存提高性能或者降低延迟等等这些手段都是为了优化正常情况下服务器的性能,但是这也会提高大规模服务故障的几率。所以一点要小心评估这些改变!

接入服务器的负载均衡技术剖析

为什么需要负载均衡技术

负责均衡技术是用来解决下面两个问题的:
1、服务器的高可用问题,通过负责均衡将流量按一定的规则转发到后端的业务服务器,保证后端服务都可以多副本部署,从而解决服务部署的单点问问题;

2、单台服务器的性能瓶颈问题,单台物理机器的性能瓶颈是有上限的,当访问流量达到一定程度后,单机是无法处理的,通过负载均衡将流量按一定规则转发的后端多台业务服务器上,从而达到性能的提升;

使用DNS进行负载均衡

客户端在向服务器发送http请求之前,通常会使用dns将服务器的域名地址解析为ip,所以使用dns进行负载均衡是非常简单可行的。一般的方式是在dns的域名解析中提供多个A纪录或者AAAA纪录,客户端中解析的时候任意选择一个ip地址使用。这个方式虽然简单,但是有一下三个问题:
1、这个机制对客户端的行为的控制力很弱:
因为客户端是随机选择一个ip地址,所以理论上会导致所有的接入服务器都会分流等量的流量,这个是一个很大的问题,它会导致接入服务器或者集群只能平均部署。虽然目前SRV协议支持设置每一个解析ip的优先级与权重,但是http协议目前不支持;

2、不能根据用户的地理位置来返回最近的接入服务器ip:
理论上,数据在光纤中以光速折线运行,所以离接入服务器越近,访问的时延会越低。但是根据dns协议,用户很少与权威域名服务器直接联系,而是通过一个递归解析器来代理解析,并且递归解析器通常还有缓存,这样会带来下面几个问题:
a、递归的方式解析ip地址,导致权威服务器拿不到用户的ip,只能拿到递归解析器的ip地址,因而不能根据用户的信息来返回离用户最近的接入服务器ip。虽然edns0扩展协议中在递归解析器代理解析的时候,会带上用户ip的子网段,但是该扩展协议目前并没有正式通过,并且国内域名解析服务的混乱,很难保证能正常运行;
b、域名解析结构缓存的问题。域名递归解析器缓存了解析纪录,导致一次解析的结果可能只给一个用户,也可能是数万个,导致对接入服务器流量控制很慢。一个解决方案是通过分析流量的变化,来评估解析器的预期影响,从而来纠正缓存导致的数据偏差;

3、不能立即删除dns解析纪录。由于dns权威服务器不提供清除解析纪录缓存的行为,只能给dns纪录保持一个比较低的ttl,更有问题的是不是所有的dns解析都遵守权威解析服务器的ttl,这样都解析纪录中的某一个接入服务器出故障的时候,还是有一段时间用户通过dns解析访问到有故障的接入服务器。

4、dns解析结果的长度是有限制的,rfc1035将dns回复长度限制为512字节,这导致如果接入服务器过多的时候,导致接入服务器的ip不能完全返回,实际是限制了接入服务器的数量,这个对于流量大的业务是有很大问题的。

从上面可以看出,dns虽然可以进行负载均衡,但是有很多的问题,那有没有办法来解决这些问题呢?当然有,在dns负载均衡与接入服务器之间加上一层虚拟ip就可以解决这个问题。

虚拟ip

虚拟ip是通过一个调度节点来接收外地流量,然后调度节点将流量转发给后端的接入服务器来进行处理,调度节点的ip地址就是虚拟ip。在linux环境下,虚拟ip实现的技术一般都是lvs来实现的,下面来分析虚拟ip实现的几种方案:

1、nat模式:

nat模式简单来说就是调度节点收到数据包后,通过修改数据包的源ip为调度节点的ip,目的ip为后端接入服务器的ip(按一定负载均衡的策略),并且纪录好映射关系,这样后端服务器就可以收到调度节点转发过来的数据包。同时,由于后源ip是调度节点的ip,所以后端服务器处理完后,数据包会先发到调度服务器,调度服务器再按映射关系将数据包的源地址修改为调度节点的IP,目的地址修改为用户的ip地址,从而达到负载均衡的目的。这种方式存在以下问题:
a、调度服务器需要维护好映射关系,这个在高并发高qps的情况下是一个不小的负担;
b、来回的数据包都需要在调度服务器进行nat的映射,一般web服务器都是用户请求的流量比较小,服务器响应的流量比较大,这个会极大的影响调度服务器的性能;
nat模式上面的两个问题,我们可以通过dr模式来解决。

2、dr模式:

dr模式是在调度节点收到数据包后,不再修改数据包的源ip地址和目的ip地址,而是直接将数据包需要转发的mac地址修改为后端接入服务器的mac地址(按一定负载均衡的策略),然后直接发送到网卡,接入服务器收到后进行回包的时候,由于数据包的源ip地址和目的ip地址都没有修改,所以数据包直接从接入服务器发送出去,到达用户。从上面的描述中,可以看出,调度节点和接入服务器节点之前二层是需要连通的,所以这种方法存在一个问题,调度节点与接入服务器的数据包都必须在一个子网络中进行广播,当流量足够大的时候,这将是整个系统的瓶颈,因而不能应用在大流量的环境中。
dr模式导致在一个子网冲突域的问题,我们可以通过tull模式来解决。

3、tull模式:

tull模式是在调度节点收到数据包后,再进行一次封装,在原来的数据包再加一个包头,源IP为调度节点的IP,目的ip为后端接入服务器的ip(按一定负载均衡的策略),接入服务收到数据包后,去掉调度节点的封装头,按数据包中的源ip和目的ip进行回包,即回包的源ip为调度节点ip,目的ip为用户的IP,数据包直接发送给用户而不需要经过调度节点,并且由于再进行了一次封装,数据包从调度节点到接入服务器直接是通过ip层进行路由的,所有后端接入服务可以处于不同的网络,避免了dr模式只能在一个子网的限制。

总结

从上面的分析可以得出,前端接入服务的负载均衡的一个最佳实践为;利用dns解析进行第一层负载均衡,将用户的流量按一定规则负载均衡到数据中心的虚拟ip,然后虚拟ip通过tull模式再将流量负载均衡到真实的接入服务器上,如果能将虚拟ip的实时流量负载和状态反馈到dns解析服务器,实时调整虚拟ip的权重,那将是最优的方式了(google就是这么干的)。

MongoDB复制集成员状态详解

mongodb复制集成员状态一共有11种,mongodb将其分为三类:核心状态,其它状态,错误状态。下面分开细说:

核心状态

PRIMARY
处于该状态的成员接受所有的写请求,同时一个副本集最多只能有一个出于该状态,处于SECONDARY状态的成员可以通过选举到PRIMARY状态。该状态可以投票。

SECONDARY
处于该状态的成员通过oplog同步PRIMARY的数据(直接从PRIMARY同步或者从另一个SECONDARY同步)。SECONDARY成员默认是不可读写的,可以通过配置SECONDARY能够读数据,从而实现读写分离。一般驱动都会提供三种一致性级别:强一致性(读写都从PRIMARY节点),单调一致性(写请求通过PRIMARY节点,同一个session的读请求可以读到当前session最新的写请求的结果),最终一致性(写请求通过PRIMARY节点,读请求随机发送到一个SECONDARY节点)。该状态可以投票。

ARBITER
处于该状态的成员不同步数据,也不接受读写请求。这个状态的作用是用于打破平衡的。比如当前集群是一个PRIMARY,一个SECONDARY节点,这样数据是多副本的,但是不说高可用的,当集群中的任何一个节点宕机后,由于集群只剩下一个节点,不能达到当前集群成员半数以上的成员成活,这样当前集群中的PRIMARY节点会自动变更状态为SECONDARY,可用通过在一个新的节点上增加一个ARBITER来解决上面的问题。该状态可以投票。

其它状态

STARTUP
每一个新加入的成员,在还没有同步到副本集的配置的时候为STARTUP状态。该状态下的成员还不是一个公认的集群成员,所以该状态的成员不能投票。

STARTUP2
STARTUP状态的成员在同步完成副本集的配置后为变更为STARTUP2状态。在该状态下的成员已经是副本集的公认成员,所以该状态下的成员可以投票。如果当前成员是保存数据和索引的,那么该状态下的成员会去同步副本集的数据和索引,该状态一直维持到到数据和索引同步完成为止。

RECOVERING
RECOVERING状态的成员可以简单理解为一个oplog落后太多的SECONDARY节点,为来保证数据最终一致性的时间窗口不会太大,所以RECOVERING是不可读的。由于SECONDARY过载导致oplog同步落后太多或者新挂在的节点第一次sync后会变更为该状态,这样可以通过停止一个正常的SECONDARY节点,然后拷贝SECONDARY节点到数据到RECOVERING节点,重新启动RECOVERING节点来恢复状态为SECONDARY节点。RECOVERING是一个正常状态,所以该状态的成员可以投票。

错误状态

UNKNOWN
如果当前成员不能将自己的状态同步到副本集的成员节点,在集群的其它成员看来为UNKNOWN状态。很显然,该状态的成员不能投票。

DOWN
如果当前成员不能将连接到副本集的成员节点,在集群的其它成员看来为DOWN状态。很显然,该状态的成员不能投票。

REMOVED
被手动移除的成员会进入REMOVED状态,并且在日志中会打印回来。很显然,该状态的成员不能投票。

ROLLBACK
PRIMARY状态的成员由于宕机等原因被移除出集群,然后重新加入集群的时候,如果该成员宕机前还有未必同步到集群的数据,当前节点会进入ROLLBACK状态,回滚所以未同步的操作。该状态的成员是可以投票的。

FATAL
3 .0以及以后的版本已经删除,忽略。

Codis原理分析

Codis是豌豆荚开源的一个redis集群管理方案,对于上层的应用来说, 连接到 Codis Proxy 和连接原生的 Redis Server 没有显著区别 (除了几个不支持的命令列表), 上层应用可以像使用单机的 Redis 一样使用, Codis 底层会处理请求的转发, 不停机的数据迁移等工作, 所有后边的一切事情, 对于前面的客户端来说是透明的, 可以简单的认为后边连接的是一个内存无限大的 Redis 服务。

不过在codis出现之前,redis集群方案如下几个:
1:smart client,业务代码中去sharding
2:twemproxy
3:redis cluster
既然有了这三个方案,那么为什么需要codis呢,我们分别来看看以上三个方案存在的问题。

smart client,在业务代码中去shard,这个方案的性能的最好的,直接在客户端路由到目标redis实例,中间没有任何额外的网络等其它开销,因而延迟也少最低的,但是这个方案有一个非常严重的问题是集群的路由逻辑前置到client端并且被集成到业务代码中,导致升级必须升级业务服务,这是一个非常麻烦的事情。并且缺少一个统一的入口,也就很难做统一的监控与统计。

twemroxy中业务服务与redis集群之间增加了一个代理层,解决了smart client升级困难的问题,但是无法平滑的扩容与缩容,甚至修改一个配置都需要重启启动服务,并且也没有dashboard,这样对运维相当的不友好,不上一个可持续扩展的集群方案。

redis cluster是redis官方出品的redis集群解决方案,这是一个基于smart client的无中心的设计,client必须按key的哈希将请求发送到对应的redis节点,可能需要多次请求才能找对应的redis节点。由于路由和存储没有分层,集群运维的复杂度是比较高的,升级风险会比较大 ,并且如果要做二次开发难度非常大。同时也缺少一个统一的入口,也就很难做统一的监控与统计。

现在我们再来看codis是怎么来解决这些问题:
1:在业务服务和redis集群之间增加了一个代理层,由代理层来进行路由转发,并且代理层是无状态的,这样路由逻辑的修改升级十分方便;
2:codis修改了redis的源码,增加了slot相关操作以及数据迁移的指令,并且在这基础上实现了对用户透明,平滑的扩容与缩容;
3: 提供了dashboard来监控redis集群的容量,proxy状态,qps,group信息等等信息,并且提供图形化的集群扩容与缩容等运维操作;

了解了codis的在各种redis集群方案中的优势后,我们来看看codis是怎么样来实现的。

从codis的结构图,业务服务用原生的redis client连接codis-proxy,codis-proxy从zookeeper或者etcd中获得路由表,并依据路由表进行路由,redis集群被划分成一个个的group,每一个group是一个高可用的redis节点(一个master节点,最少一个从节点),从而保障了集群的高可用。并且通过增加group同时动态调整路由表来实现对用户透明,平滑的扩容与缩容。

对于一个缓存系统,在CAP理论的取舍中,通常都会选择AP,因而一般来说会实现为一个BASE(基本上可用、软(弱)状态、最终一致性)的数据库,而不是一个ACID的数据库。下面我们来看codis在这方面的怎么取舍的。
codis-proxy的路由信息在存放在ZK或者ETCD中,那么我们的路由信息发生变更的时候,codis-proxy会先更新路由表,有些codis-proxy会晚一点跟新路由表,在这一个过程中,对于业务服务来说,通过不同的codis-proxy来访问到的缓存数据是不一样的,这是一个不一致的情况,对于读请求,这是最终一致性性的,经过一个时间窗口后,codis-proxy都将会全部同步最新的路由信息,后续的访问都会访问到最终的结果,对于一个AP系统,这个是没有问题的,但是对于写请求,不一致的路由信息会导致写请求的结果丢失,或者旧的写请求的结果覆盖新的写请求,这样的问题就不只是不一致的问题,而是bug了。

这样就带来一个问题,codis中扩容与缩容过程中,如果一个key1从redis1迁移到redis2,如果在迁移过程中有一个写操作,写到redis1,但是这时key1已经迁移到redis2,那么这一个写操作将会丢失,同样如果中迁移过程中有一个写操作写到redis2,但是随后迁移过来这个key的旧值,那么这一个写操作将会被覆盖,这样会导致数据最终不一致的问题。那么codis是怎么解决这个问题的呢?
首先,单个key的迁移过程必须是原子,这样才能保障中迁移过程中单个key在系统中的状态是一致的,不会出现迁移过后两个redis都存在,或者两个redis都不存在。这个通过扩展redis命令SLOTSMGRT族来实现的。
其次,在迁移过程中的读写请求,codis-proxy是怎么来路由与处理?这个中codis-proxy中,如果当前key对应的slot正在迁移状态,那么codis-proxy先对当前key执行一个SLOTSMGRT命令,将当前key迁移到目标redis,然后将当前key的读写请求都路由目标redis。
最后,一个最关键的问题,我们知道codis-proxy获得ZK或者ETCD中的路由信息,是一个最终一致性的结果,在一个时间窗口内,各个codis-proxy中的路由信息是不一致的,这样会导致key请求丢失或者写请求覆盖的问题,codis是通过分布式一致性算法2PC来保障的,下面来看看具体的流程:
1:dashboard通过ZK或者ETCD向codis-proxy下发一个pre_migrate命令,如pre_migrate slot_1 to group 2,codis-proxy中收到这个命令后,将slot_1标记为pre_migrate状态,并且回复dashboard;
2:处于pre_migrate状态的slot中当前codis-proxy只能处理读请求,不能处理写请求;
3:当dashboard收到了所有的codis-proxy的回复,dashboard标记slot_1为migrate状态并将slot_1的路由修改为group 2,并通过ZK或者ETCD向codis-proxy提交这个状态;
4:如果有处于migrate状态的slot的key请求,codis-proxy会在强行执行一次MIGRATE key 将这个键值提前迁移过来. 然后codis-proxy再将请求路由到group2上正常读写;
上面其实是一个很典型的2PC流程,通过它来保证了数据的最终一致性。

这样我们可以得出结论,codis中迁移数据的时候,当前slot会有短时间的不可写,并且slot迁移数据的整个时间周期中,对当前slot的操作都会执行一次MIGRATE key操作,这个会增加请求的时延。因此,虽然codis提供了运维友好的平滑扩容与缩容,但是中扩容与缩容的过程中,对业务还说有一点的影响的,这样应该提前规划好codis集群的容量,有计划在业务低峰期间进行扩容与缩容的操作;

Promethus简析

Prometheus是继Kubernetes后第二个正式加入CNCF基金会的项目,是容器和云原生领域事实的监控标准解决方案,是下一代监控系统的事实标准,作为一个后台技术人员,很有深入了解的必要。下面我们从数据模型,服务发现以及高可用和水平扩展等方面来分析Prometheus为什么是下一代监控系统。

多维度数据模型

Prometheus本质上存储的所有数据都是时间序列:具有时间戳的数据流只属于单个度量指标和该度量指标下的多个标签维度,其中metrics为度量指标,利用labels来支持数据的多维度,具体的表示形式为:
[metric name]{[label name]=[label value], …},
其中metric是度量指标名字,lable为标签,一个样本点是以metric和lable组成的key,一个64位的浮点值的value和一个精确到毫秒级的时间戳的采样时间构成。所以样本集本质上是一个实时的时间序列数据。
metric的命名规范为:ASCII字母、数字、下划线和冒号,他必须配正则表达式[a-zA-Z_:][a-zA-Z0-9_:]*;label的命名规范为:可以包含ASCII字母、数字和下划线。它们必须匹配正则表达式[a-zA-Z_][a-zA-Z0-9_]*,带有_下划线的标签名称被保留内部使用。标签labels值包含任意的Unicode码。具体详见metrics和labels命名最佳实践

下面我们用一个例子来说明metric和labels的关系:
例如我们要统计服务rpc的请求总数,我们可以这样设计metrics和labels:
metrices为rpc_requests_total,
labels为protocol=“thrift/grpc”,servername=“account/rtc”,method=“GetUserInfo/GetUserName”
如果现在account服务有一个thrift的GetUserInfo方法调用了100次,在Prometheus以这样的形式表示:
rpc_requests_total{protocol=“thrift”,servername=“account”,method=“GetUserInfo”} 100

度量指标

  • Counter
    counter 是一个累计度量指标,它只能递增的数值或者重置为0,不能减少。计数器主要用于统计服务的请求数、任务完成数和错误出现的次数等递增的计数结果。但是是可以置0,比如统计机器的开机时间是一个递增的数,但是机器重启的时候,counter是会重置为0的,这样counter的计算只需要在内存中维持递增就够了,重启后重新计数。由于counter的特点,所以对应cpu使用率这样的非递增的状态型的数据就不能用counter来度量了。

下面是一个counter的内部存储情况:
rpc_requests_total{protocol=“thrift”,servername=“account”,method=“GetUserInfo”} 100
rpc_requests_total{protocol=“thrift”,servername=“account”,method=“GetUserInfo”} 156

  • Gauge
    gauge是一个度量指标,它表示一个既可以递增, 又可以递减的值。gauge主要测量类似于温度、当前内存使用量等,也可以统计当前服务运行随时增加或者减少的Goroutines数量。

下面是一个gauge的内部存储情况:
memory_usage_bytes{host=”master-01″} 100 
memory_usage_bytes{host=”master-01″} 50

  • Histogram
    histogram是柱状图, 用来描述数据的分布,通常的使用场景为请求持续时间和响应大小等等。它会对样本集进行处理,采集下面三个方面的数据:
    1、对每个采样点进行统计,打到各个分类值中(bucket)
    2、对每个采样点值累计和(sum)
    3、对采样点的次数累计和(count)

下面是一个采集/metrics接口响应大小的histogram数据:
prometheus_http_response_size_bytes_bucket{handler=”/metrics”,le=”100″} 0
(表示metrices为prometheus_http_response_size_bytes,标签为handler=”/metrics”,响应字节数小于100的为0个)
prometheus_http_response_size_bytes_bucket{handler=”/metrics”,le=”1000″} 0
prometheus_http_response_size_bytes_bucket{handler=”/metrics”,le=”10000″} 46
prometheus_http_response_size_bytes_bucket{handler=”/metrics”,le=”100000″} 46
prometheus_http_response_size_bytes_bucket{handler=”/metrics”,le=”1e+06″} 46
prometheus_http_response_size_bytes_bucket{handler=”/metrics”,le=”1e+07″} 46
prometheus_http_response_size_bytes_bucket{handler=”/metrics”,le=”1e+08″} 46
prometheus_http_response_size_bytes_bucket{handler=”/metrics”,le=”1e+09″} 46
prometheus_http_response_size_bytes_bucket{handler=”/metrics”,le=”+Inf”} 46
prometheus_http_response_size_bytes_sum{handler=”/metrics”} 234233
(表示metrices为prometheus_http_response_size_bytes,标签为handler=”/metrics”,响应字节数总和为234233)
prometheus_http_response_size_bytes_count{handler=”/metrics”} 46
(表示metrices为prometheus_http_response_size_bytes,标签为handler=”/metrics”,请求次数为46)

  • Summary
    和histogram类似,summary是采样点分位图统计,通常的使用场景为请求持续时间和响应大小等等。它也会对样本集进行处理,采集下面三个方面的数据:
    1、对于每个采样点进行统计,并形成分位图。(如:正态分布一样,统计低于60分不及格的同学比例,统计低于80分的同学比例,统计低于95分的同学比例)
    2、统计班上所有同学的总成绩(sum)
    3、统计班上同学的考试总人数(count)

下面是一个采集go程序gc耗时的summary数据:
go_gc_duration_seconds{quantile=”0″} 7.3318e-05
(表示metrices为go_gc_duration_seconds,0%分位的耗时为7.3318e-05秒)
go_gc_duration_seconds{quantile=”0.25″} 0.000118693
go_gc_duration_seconds{quantile=”0.5″} 0.000236845
go_gc_duration_seconds{quantile=”0.75″} 0.000337872
go_gc_duration_seconds{quantile=”1″} 0.000707002
go_gc_duration_seconds_sum 0.003731953
(表示metrices为go_gc_duration_seconds的总耗时为0.003731953秒)
go_gc_duration_seconds_count 14
(表示metrices为go_gc_duration_seconds的总gc次数为14次)

  • 总结
    这里简单总结一下promethus的数据模型和量度指标:
    metrics为量度指标名称,label为来实现数据的多维度,counter用来度量随着时间只会递增的结果,gauge用来度量随时间可以增减的状态,histogram和Summary都常用于跟踪事件发生的规模,不过histogram描述的是原始值,可以通过histogram_quantile()函数计算出Summary,所以Summary是结果,性能会更高,但是histogram是可用聚合的,比如现在每隔1分钟有一个histogram和Summary,那么Summary只能看每分钟的Summary情况,但是histogram能够将10个连续的histogram累积起来,就可以看到这10分钟的Summary情况。

获取监控数据的方式

promethus推荐的获取监控数据的方式是pull而不是push,有以下三个原因:
1、当开发环境变化时,可以直接在笔记本上运行promethus获得监控数据,比如在本地开发就可以在本地起一个promethus来获得监控;
2、如果目标实例挂掉,promethus可以很容易地知道;
3、你可以手动指定一个目标,并通过浏览器检查该目标实例的监控状况;
4、如果要实现高可用,pull方式只需要部署多台promethus就可以了,push的方式则需要修改被监控服务程序和配置,想多个promethus实例上报采样的数据。

当然某一些场景是不适合用pull方式来提交监控数据的,比如短进程程序,所以promethus也提供了Pushgateway来接受应用程序push的数据,然后promethus再以pull的方式从pushgateway获得数据;

查询方式

Prometheus提供一个函数式的表达式语言,用户可用很方便查找和聚合时间序列数据,并且提供http api的形式将查询结果返回给第三方系统图形化显示或者进一步分析报警等等。

下面通过几个例子简单介绍一下promethus的查询语法。
1、http_requests_total{environment=~”staging|testing|development”, method!=”GET”}
表示metrics为http_requests_total,正则表达式匹配labels environment为staging, testing, development的值,且http请求方法不等于GET的所有时间数据。其中metrics和labels都支持正则表达式。
2、http_requests_total{job=”prometheus”}[5m]
表示查询过去5分钟内,度量指标名称为http_requests_total, 标签为job=”prometheus”的时间序列数据。[5m]表示5分钟内,还支持这些的单位:s(seconds),m(minutes),h(hours),d(days),w(weeks),y(years)。
3、http_requests_total offset 5m
表现查询相对于当前时间的前5分钟时的时刻, 度量指标名称为http_requests_total的时间序列数据。
4、sum(http_requests_total{method=”GET”} offset 5m)
表现查询相对于当前时间的前5分钟时的时刻, 度量指标名称为http_requests_total,标签为method=”GET”的时间序列数据总和。其中sum为内置的函数,Prometheus支持非常丰富的查询函数,比如histogram_quantile用来计算histogram数据的分位数等等。

从上面可以看出,Prometheus提高了非常简单、非常灵活的查询语言,通过定期执行查询语言,可以很方便生成新的时序数据(记录规则),也可以很方便的进行报警(警告规则)这对于一个监控系统来说是非常重要的。

服务发现

由于promethus推荐的或者监控数据的方式是pull的方式,因而promethus需要知道被监控程序的提供监控数据服务的ip和端口,如果通过手动配置的方式来维护被监控对象的ip和端口,那将是一件非常大的负担,所以promethus集成了很多通用的服务发现的机制,让我们可以通过服务发现来自动发现需要监控的服务。

如果你的微服务是用scrape_config、tls_config、azure_sd_config,Consul_sd_config,dns_sd_config,ec2_sd_config,openstack_sd_config,file_sd_config,gce_sd_config,kubernetes_sd_config,marathon_sd_config,nerve_sd_config,serverset_sd_config,triton_sd_config等方式进程服务发现的,那么集成到promethus会相当的简单,只需要一个配置就可以了。

但是如果你的微服务的服务发现机制以上都不是,有一种比较简单的方式:将你的服务发现的服务部署情况通过程序获得下来,按file_sd_config服务发现要求的格式写入一个文件,这样就可以利用file_sd_config来进行服务发现了。比如微服务是基于etcd自己实现的服务发现,那么可以采用confd来讲etcd上的服务写入文件,然后通过file_sd_config来进行服务发现。

高可用与水平扩展

高可用与水平扩展是promethus不够完善的地方,目前官方提供的高可用方案是在多台服务器上运行相同的promethus,这样每一个服务的监控数据都会被多个promethus获得,alertmanager在进行报警的时候会进行去重处理,从而实现高可用。目前官方提供的水平扩展方式Remote Read/Write,即将监控按业务拆分到不同promethus实例,实现监控数据收集的水平扩展,然后部署一些专门用于查询的promethus去Remote Read所有收集数据的promethus,实现查询的全局视图。将上面的高可用和水平扩展的方式结合起来,就是promethus目前官方的高可用与水平扩展的集群部署方式,具体的架构图如下:

架构说明:
1、蓝色的promethus服务负责采集监控数据,按业务或者数据中心横向拆分;
2、红色的promethus服务通过Remote Read的方式去查询蓝色promethus服务的采集的数据,负责全局视图;
3、通过Remote Write的方式将监控数据持久性保存在第三方储存系统,不再担心单机存储瓶颈的问题;
4、数据流统一采用拉取的方式;

不难发现,架构中的 Prometheus 主要分为两类, 用于数据收集和用于数据查询。数据查询的 Prometheus 会从收集到数据的节点中读取数据,请注意,它只做实时的查询以及内存运算,不做数据存储。通过这样的架构,我们就很容易将整个监控的数据收集查询分离开了,也更容易实现高可用。
另外,promethus的有一个开源的集群方案Thanos,这是一个较为优秀的解决方案,可以结合远程存储来解决promethus高可用、水平扩展的问题。

总结

通过上面的分析,promethus支持多维度数据模型,提供灵活的查询语言,通过http pull模型拉去时间序列数据,同时也支持通过中间网关支持http push模型。对于被监控对象提供服务发现或者静态配置来发现目标服务对象,同时它还支持多种多样的图表和界面展示,比如grafana,而且部署简单,不依赖分布式存储,单个服务器节点是自主的。这样一些优点非常合适在云环境中去部署和实时监控,所以基于上面的这些特点,Promethus才会被公认为下一代监控系统。

docker容器原理分析—隔离与限制

docker的使用非常简单,docker run 命令就可以轻松的启动一个docker容器,但是执行docker run背后到底发生了什么,docker是怎么实现容器化的呢?这正是这篇文章想要讨论的主题。

docker是一个C/S架构,在执行docker run命令的时候,其实是docker client通过网络将启动参数提交到docker daemon,由docker daemon来启动我们想要运行的docker容器,当然这是一个非常复杂的过程,但是所有最关键的启动容器操作会落到

int clone(int *child_func)(void *),void *child_stack, int flags, void *arg);

这个系统函数的调用上。熟悉linux的同学都会知道,clonelinux系统调用fork()的一种更通用的实现,用来创建新进程的。这样我们的第一个问题来了,docker容器和虚拟机的区别是什么?

通过上面的我知道docker容器其实是一个进程,而虚拟机是运行在hypervisor之上一个操作系统。虚拟机本身是一个操作系统,在虚拟机上运行的进程天然是以操作系统的粒度进行隔离的,虚拟机除了共享宿主机的硬件资源外,在软件上是完全隔离的。而docker容器只是一个进程,那么容器与宿主机的关系则要复杂的多,容器不仅需要使用宿主机的cpu,内存等硬件资源,还需要宿主机的操作系统为它提供运行时的环境。这样我们的第二个问题来了,docker容器和宿主机是怎么样进行隔离的呢?

docker容器和宿主机需要进行哪些隔离呢?最简单的答案当然是越隔离越好,它们之间最好老死不相往来,最好都不知道对方的存在,但是这明显是做不到的,毕竟docker容器在宿主机上的一个进程,这是一个天生的依赖关系,那么务实来说,docker容器需要哪些隔离?

首先,容器内部不能看到其他的进程,则进程之间的关系需要隔离;其次,容器内部不能看到其他进程对文件系统的修改,则文件系统需要隔离;再次,容器需要有自己的ip、端口和路由等,则网络需要隔离;另外,容器需要自己独立的主机名以便在网络中标识自己,则主机名和域名需要隔离;还有,容器内部也不能看到其他的用户和组,并且容器的创建需要有内部的超级用户权限,但是在宿主机上则只是普通权限,则用户ID、用户组IDroot目录,key以及特效权限等等需要进行隔离;最后,容器内部也不同和宿主机的进程进行进程间的通信,则信号,管道已经共享内存等需要隔离。这个正好是linux支持的利用namespace进行隔离的方法,下面我们一起来看看docker是怎么利用namespace进行实现的。

linux中一个PID namespace对应linux内核是一颗描述进程的层次体系的树,同样PID namespace本身也是一颗树状结构,顶层是系统启动时创建的root namespace,下面依次是新创建的PID namespace。我们在启动容器操作的函数cloneflags增加一个CLONE_NEWPIDflag,操作系统就会对新创建的进程进行PID namespace隔离,对它的进程PID进行重新编号,即该容器进程在宿主机的PID namespace和容器进程新的PID namespace分别有不同的pid,在宿主机中该容器为普通的pid(比如10000),在容器的PID namespace中则是PID1PID1的进程在linux中是一个特殊的进程(进行子进程回收等操作),在容器的PID namespace只能看到属于该PID namespace的进程,这样就实现的进程之间的隔离。

文件系统的隔离和进程之间的隔离也类似,容器进程在启动的时候会增加CLONE_NSflag,通过mount namespace进行隔离的,不同的是容器进程启动后,还是能看到宿主机的文件系统,但是在容器进程的mount namespace进行mount的时候,宿主机是无感知的,这样对于同一个目录,如果在容器中mount后,看到的最新mount的内容,宿主机看到的还是原来的内容,从而实现了文件系统的隔离。

网络隔离,主机名和域名的隔离,用户和组的隔离,进程间通信的隔离分别可以利用linuxnamespaceNetworkUTSUserPID进行隔离,这里就不再一一叙述了。

这样,我们就为docker容器提供了一个独立空间,让容器进程感觉自己独占了一个操作系统,但是作为一个容器,现在宿主机和容器进程还共享cpu,内存等硬件资源,相互之间会影响,所有仅有上面的隔离还是不够的。这样我们的第是三个问题来了,docker容器和宿主机是怎么样进行资源限制的呢?

docker容器和宿主机之间的资源限制,可以通过linuxcgroup机制来解决这个问题。linux Cgroups的全称是linux control group。它主要的作用是限制一个进程组能够使用的资源上限,包括cpu、内存、磁盘和网络带宽等等。另外cgroup还能够对进程进行优先级设置、审计等操作。下面我们来看看怎么使用cgroup对进程的资源进行限制。

linux cgroups的设计是比较简单易用的,可以简单理解为一个子系统目录加一个一组资源限制文件的组合,我们简单看看cgroup的使用方式。

首先,LinuxCGroup这个实现成了一个file system,你可以mount。如果是系统是Ubuntu 14.04,你输入以下命令你就可以看到cgroup已为你mount好了。

mount -t cgroup

cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,relatime,cpuset)

cgroup on /sys/fs/cgroup/cpu type cgroup (rw,relatime,cpu)

cgroup on /sys/fs/cgroup/cpuacct type cgroup (rw,relatime,cpuacct)

cgroup on /sys/fs/cgroup/memory type cgroup (rw,relatime,memory)

cgroup on /sys/fs/cgroup/devices type cgroup (rw,relatime,devices)

cgroup on /sys/fs/cgroup/freezer type cgroup (rw,relatime,freezer)

cgroup on /sys/fs/cgroup/blkio type cgroup (rw,relatime,blkio)

cgroup on /sys/fs/cgroup/net_prio type cgroup (rw,net_prio)

cgroup on /sys/fs/cgroup/net_cls type cgroup (rw,net_cls)

cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,relatime,perf_event)

cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,relatime,hugetlb)

我们可以看到,在/sys/fs下有一个cgroup的目录,这个目录下还有很多子目录,比如: cpucpusetmemoryblkio……这些,这些都是cgroup的子系统。分别用于干不同的事的。如果你没有看到上述的目录,也可以自己mount。然后,我们这前不是在/sys/fs/cgroup/cpu下创建了一个palfishgroup。我们先设置一下这个groupcpu利用的限制:

cat /sys/fs/cgroup/cpu/palfish/cpu.cfs_quota_us

-1

(默认为-1,表示没有限制)

echo 20000 > /sys/fs/cgroup/cpu/palfish/cpu.cfs_quota_us

(表示一个cpu periodgroup最多可以运行20000us,默认cpu period100ms,即表示限制为20%

如果我们需要限制某一个进程,那么将这个进程的pid加到这个cgroup中,下面以pid3529为例:

echo 3529 >> /sys/fs/cgroup/cpu/palfish/tasks

这样,就会3529进程消耗的cpu最大为20%了。

按上面的cgroup的使用方式,对于docker容器来说,只需要为每一个子系统下面为每一个容器创建一个控制组,然后在容器进程启动之后,把进程的pid写入对于控制组的tasks文件中就可以了。这样我们就实现了docker容器和宿主机之间的资源限制。

通过上面的分析,docker容器只是宿主机上的一个普通进程,然后通过namespace对该进程进行隔离,通过cgroup对硬件资源进行限制而实现的一个容器,和虚拟机以一个独立的操作系统进行隔离的方式是完全不一样的技术。这样docker容器的实现很轻量,一个容器和一个进程消耗的资源差不多,使得docker容器具有秒级启动并且单个宿主机可以启动上千个docker容器的优点。但是docker容器由于只是宿主机上的一个进程,那么docker容器必须依赖宿主机的操作系统内核,所有在windows上运行linuxdocker容器是不行,并且一个依赖高版本linux内核的的docker容器在低版本宿主机是不能运行的,而这些问题对于虚拟机来说是不存在的。另外需要利用namespacedocker容器进行了pid,网络等方面的隔离,但是有一些资源没有办法隔离的,比如时间等等,所以在个docker容器修改了时间,那么宿主机上所有的docker容器都会感知。并且由于系统调用是直接操作内核的,所以docker容器操作系统调用这个是不能完全隔离的。