基础架构
中间件与大数据平台
业务系统架构
操作系统、网络、数据库
什么功能已经有了,什么还没有
熟系原理,更容易理解实现,有什么潜在问题
思维借鉴
TCP如何在一个“不可靠”的通信网络上实现一个“可靠”的通道,比如数据库如何利用Write-ahead Log解决I/O问题,利用Checksum保证日志完整性,利用 MVCC(CopyOnWrite)解决高并发问题。
应用程序内存
malloc/free 分配的内存 对象?
用户缓冲区
buffer Stream?
内核缓冲区
Page Cache
磁盘
socket缓冲区
send Buffer/recv Buffer
网络
读:磁盘→内存缓冲区→用户缓冲区→应用程序内存
写:应用程序内存→用户缓冲区→内存缓冲区→磁盘
函数:fflush、fsync
读:磁盘→内核缓冲区→应用程序内存;
写:应用程序内存→内核缓冲区→磁盘。
函数:read/write、pread/pwrite
读:磁盘→应用程序内存映射的内核缓冲区;
写:应用程序内存映射的内核缓冲区→磁盘。
函数:mmap java:MappedByteBuffer
读:socket映射内核→网络
函数:sendFile java:FileChannel.transferTo
拷贝是把数据从一块内存中复制到另外一块内存里;映射相当于只是持有了数据的一个引用(或者叫地址),数据本身只有1份。
把文件数据发送到网络的这个场景,直接I/O、内存映射文件、零拷贝对应的数据拷贝次数分别是4次、3次、2次,内存拷贝次数分别是2次、1次、0次。
直接IO:磁盘→内核缓冲区→应用程序内存→socket缓冲区→网络
内存映射:磁盘→应用程序映射的内核缓冲区→socket缓冲区→网络
零拷贝:磁盘→socket映射的内核缓冲区→网络
同步和异步
阻塞和非阻塞
read/write函数,用户线程等I/O完成,磁盘到应用程序的过程都得block等待。
O_NONBLOCK/O_NDELAY,打开fd的时候带有O_NONBLOCK参数。于是,当调用read和write函数的时候,如果没有准备好数据,会立即返回,不会阻塞。然后让应用程序不断地去轮询很多fd,应用程序的线程是一直被占用轮询的。
select、poll、epoll
epoll:
(1)事件注册。通过函数epoll_ctl实现。对于服务器而言,是accept、read、write 三种事件;对于客户端而言,是connect、read、write 三种事件。
(2)轮询这三个事件是否就绪。通过函数epoll_wait实现。有事件发生,该函数返回。
(3)事件就绪,执行实际的I/O操作。通过函数accept/read/write实现。
windows iocp,aio(不成熟),读写由底层完成(操作系统或者框架),读写完成之后,以某种方式通知应用程序。
网络框架的两种设计模式,无论操作系统的网络 I/O 模型的设计,还是上层网络框架的网络I/O模型的设计,用的都是这两种设计模式之一。
Reactor模式
主动模式。所谓主动,是指应用程序不断地轮询,询问操作系统或者网络框架、I/O是否就绪。Linux系统下的select、poll、epoll就属于主动模式,需要应用程序中有一个循环一直轮询;Java中的NIO也属于这种模式。在这种模式下,实际的I/O操作还是应用程序执行的。
Proactor模式
被动模式。应用程序把read和write函数操作全部交给操作系统或者网络框架,实际的 I/O 操作由操作系统或网络框架完成,之后再回调应用程序。asio 库就是典型的Proactor模式。
1+N+M个线程,一个监听线程,N个I/O线程,M个Worker线程。N的个数通常等于CPU核数,M的个数根据上层决定,通常有几百个。
进程
优势:通过ipc通信,不需要锁;进程独立运行,安全性高,并发效率高。nginx的worker。
劣势 :需要系统提供IPC机制;数量级少,和cpu数差不多;通信需要异步IO支持。
线程
优势:方法成熟,功能强大;线程数能到几百;
劣势 :锁;线程切换开销
协程
优势:上万个;堆栈不固定,内存利用率高
劣势 :go、rust支持,java不成熟
RingBuffer,允许一个线程写、一个线程读。示例:Disruptor,其核心就是“一写多读,完全无锁”。
CPU层面提供的一个硬件原子指令,实现对同一个值的Compare和Set 两个操作的原子化。示例:实现乐观锁、无锁队列、无锁链表
请求一来一回,不支持服务推送
连接建立、关闭性能差?
keep-alive,请求和返回加keepalive 复用tcp连接;通过timeout关连接。
连接复用了,客户端怎么确定消息传完了?
Content-Length
1.0的Response Content-Length要计算?
Chuck机制:将分块 25-1C-…0
连接复用后,请求串行,并发度不够?
pipline,多请求并行发送,返回按发送顺序返回。请求1如果响应时间长,2、3不能先返回,这个叫队头阻塞(Head-of-Line Blocking)。所以很多浏览器就关掉了这个功能。
pipline关掉后,性能提升的一些奇巧淫技
图片拼接、js压缩、多域名(浏览器单一域名下只能发6到8个连接)
断点下载
客户端从服务器下载文件时,如果下载到一半连接中断了,再新建连接之后,客户端可以从上次断的地方继续下载。具体实现也很简单,客户端一边下载一边记录下载的数据量大小,一旦连接中断了,重新建立连接之后,在请求的头部加上Range:firstoffset-last offset 字段,指定从某个offset下载到某个offset,服务器就可以只返回(first offset,last offset)之间的数据。
服务器推送
客户端定期轮询
WebSocket(TCP)
长轮询
客户端发请求,有消息返回,然后再发请求,如果没消息服务端夯住(wait),约定时间未返回,就返回空,客户端再连。目前最常用的服务端推送。
HTTP Streaming(基于1.1)
服务器端利用Transfer-Encodeing:chunked机制,发送一个“没完没了”的chunk流,就一个连接,但其Response永远接收不完。与长轮询的差异在于,这里只有一个HTTP请求,不存在HTTP Header不断重复的问题,但实现时没有长轮询简单直接。
相当于在HTTP 1.1和TCP之间多了一个转换层
解决pipline问题?
二进制分帧,流ID,请求和响应被打散,队头阻塞细化到帧,可以对流制定优先级。
其他调优措施
头部压缩等
SSL(Secure Sockets Layer)的中文名称为安全套接层,TLS(Transport Layer Security)的中文名称为传输层安全协议。TLS是SSL标准化的结果,TLS 1.0相当于SSL 3.1;TLS 1.1、TLS 1.2 相当于SSL 3.2、SSL3.3。TCP层面的协议,所以可以支撑应用层的协议。在应用层里,习惯将两者并称为SSL/TLS。
对称加密
非对称加密
分双向和单向,双向只能固定密钥,或者定期线下更换;单向可能会有中间人攻击的风险
数字证书与证书认证中心(CA,Certification Authority)
CA公钥服务器和客户端都知道
服务器拿公钥找CA换证书,然后发给客户端,客户端可以拿CA的公钥验证证书是不是真的
根证书与CA信任链
如果CA是假的,怎么办?
Root CA机构都是一些世界上公认的机构,在用户的操作系统、浏览器发布的时候,里面就已经嵌入了这些机构的Root证书。你信任这个操作系统,信任这个浏览器,也就信任了这些Root证书。
认证时,ca4→ca3→ca2→ca1→ca0(ROOT,无条件信任)
下发时,ca0→ca1→ca2→ca3→ca4
tcp 3次握手→SSL四次握手→http对称加密
TCP三次握手
syn j→ack k,syn j+1→ack k+1
SSL四次握手
客户端询问证书→服务器证书下发→客户端验证证书和公钥后,将通信使用的对称密钥使用公钥加密给服务器→服务器明文说收到
升级协议,使用HTTP对称加密通信(可以复用连接)
不丢、不重、不乱
不丢
接收方ack包的序号,比如1,2,3,ack3;接收反馈超时重发;
不重
接收到6,此时又有1,2,3被重发,不接收。
不乱
比如1,2,3,5,6,7,ack3,此时4到达,ack7;
无连接,不检查数据报,不等对方应答
UDP 在传输数据前不建立连接,不对数据报进行检查与修改,无须等待对方的应答,所以会出现分组丢失、重复、乱序,应用程序需要负责传输可靠性方面的所有工作;
UDP 具有较好的实时性,工作效率较 TCP 协议高;UDP 段结构比 TCP 的段结构简单,因此网络开销也小。
不丢包
Raid5,每发送5个数据包,就发送一个冗余包。冗余包是对5个数据包做异或运算得到的。这样一来,服务器收到6个包,如果5个当中,有一个丢失了,可以通过其他几个包计算出来。这就好比做最简单的数学运算:A+B+C+D+E=R,假设数据包D丢失了,可以通过D=R-A-B-C-E计算出来。
更少的RTT(Round-Trip Time往返时延)
建立一个HTTPS连接需要七次握手,TCP的三次握手加上SSL/TLS的四次握手,是三个RTT。而造成网络延迟的原因,一个是带宽,另一个是RTT。因为RTT有个特点是同步阻塞。在数据包发出去之后,必须要等对方的确认回来,接着再发下一个。基于QUIC协议,可以把前面的七次握手(三个RTT),减为0次。
连接迁移
TCP 的连接本来就是“假”的,一个逻辑上的概念而已,QUIC 协议也可以创造一个逻辑上的连接。具体做法是,不再以4元组(客户端IP,客户端Port,服务器IP,服务器Port)来标识连接,而是让客户端生成一个64位的数字标识连接,虽然客户端的IP和Port在漂移,但64位的数字没有变化,这条连接就会存在。这样,对于上层应用来说,就感觉连接一直存在,没有中断过。
第一范式:一个字段不能再拆分,反例:json、数组
第二范式:表中必须有主键,或者组合主键;非主键必须完全依赖主键,不能和主键无关
第三范式:非主属性必须直接依赖主键,不能间接依赖
为性能或者开发,做反范式设计
业务拆分、易扩展
应对高并发,读多写少,读从库,加缓存;写多读少,分库分表
数据隔离,优先级
业务规避,通过回查机制来做;
业务可以异步,事务消息;
业务不能异步,saga;
思考题:为什么TCC的落地不友好?
mysql innodb为例
(1)在叶子节点一层,所有记录的主键按照从小到大的顺序排列,并且形成了一个双向链表。叶子节点的每一个Key指向一条记录。(2)非叶子节点取的是叶子节点里面Key的最小值。这意味着所有非叶子节点的Key都是冗余的叶子节点。同一层的非叶子节点也互相串联,形成了一个双向链表。
优势:双向链表,范围查找方便;主键前缀模糊匹配;天然有序,排序和分页很方便
Page,默认16k,设一个page可存1000个key,非叶子节点能存1000,第3层+数据(设100字节),3层大概能存2亿条,16G左右。
id自增插入,顺序写,追加即可,如果随机,可能会造成页分裂,影响插入效率;删除的时候软删除,这样不用页合并(表优化)。
非主键索引结构也是b+树,但叶子节点存储的是主键的值。另外非叶子节点上,存储着最小的主键值。
RU、RC、RR、Serialization
ACID
先在内存中提交事务,然后写日志(所谓的Write-ahead Log),然后后台任务把内存中的数据异步刷到磁盘。日志是顺序地在尾部Append,从而也就避免了一个事务发生多次磁盘随机 I/O 的问题。明明是先在内存中提交事务,后写的日志,为什么叫作Write-Ahead呢?这里的Ahead,其实是指相对于真正的数据刷到磁盘,因为是先写的日志,后把内存数据刷到磁盘,所以叫Write-Ahead Log。
ARIES恢复算法(Algorithms for Recovery And Isolation Expoliting Semantics)
从宕机点找最后一次的checkpoint,当时有t1,t2,t3,t4;开始循环,发现t1,t3已提交,则未提交列表为t2,t4;
然后开始从t1刷盘,db的page里有个字段为pageLsn,如果log里的lsn<=pageLsn,则表示已经刷盘,不处理;否则就刷入page,用这个方法实现幂等;此时t2,t4未提交的记录也刷盘了;
对t2,t4生成Undo log,进行回滚。回滚时从后向前滚,如t2的lsn为2,t4的lsn为4,回滚4的时候,会记录UndoNxtLsn为2,这样回滚过程如果再次宕机,也不会继续回滚4,避免回滚嵌套。
MVCC:解决快照读写的问题
CopyOnWrite:写的时候拷贝一份对象,再这个对象上做修改;写完后把对象指针指向新的对象。读读并发,读写并发,写写并发。
undoLog是CopyOnWrite的实现,生成了一个数据快照。快照读就是最常用的select语句,当前读包括了加锁的select语句和insert/update/delete语句。MVCC解决了快照读写的问题。
写写问题要靠锁来解决。
互斥锁(X锁):对象上只有一把锁,只要有一个线程就互斥,读读互斥,读写互斥,写写互斥
共享锁(S锁):对象上只有一把锁,但是有两个视图,读写互斥,写写互斥,读读可以并发
表和行的共享锁、互斥锁好理解。
如果线程A锁了某行,线程B要锁全表是否要遍历?不需要,意向锁(IS锁、IX锁),线程A会对行加锁,对表加意向锁。
自增锁AI(Auto-inc Locks)、间隙锁(Gap Lock)、临键锁(Next-Key Lock)和插入意向锁(InsertIntension Lock)
BinLog是Server层面的Log,Redo、Undo是引擎层面的Log。
串行化的log
Prepare: InnoDB ok;BinLog ok;
Commit:先刷BinLog,再刷InnoDB。
RedoLog向BinLog对齐:
Prepare阶段宕机,BinLog在内存:消失;RedoLog没提交,回滚;
Commit阶段BinLog写一半宕机,BinLog没提交标记,回退;RedoLog没提交,回滚;
Commit阶段BinLog写完,RedoLog没写完宕机:遍历BinLog,存在BinLog有RedoLog没有的事务,提交。
举例:搜索
策略:
举例:点击流
策略:
举例:库存
策略:按业务使用上面的方法来搞
吞吐量、响应时间与并发数:吞吐量(QPS)*响应时间=并发数。对一个单机单线程的系统,假设处理每个请求的时间是 1ms,也就是响应时间是1ms,意味着1s可以处理1000个请求,QPS为1000。则并发数=1000qps乘以0.001s=1
无状态,加机器;有状态(缓存、数据库),考虑数据同步和一致性问题;
举例:应用多副本、mysql的主从(半同步写)、redis主从
数据隔离、机器隔离、线程池隔离、信号量隔离
技术层面的限流:限制并发数、限制速率
业务层面的限流:秒杀100,2万人抢购,只放进来500,其他直接屏蔽掉
算法:滑动窗口计数、令牌桶、漏桶
根据请求失败率做熔断
根据请求响应时间做熔断
兜底,mq存数据;主动关闭部分功能;做默认展示页
资源监控
cpu、磁盘、内存、带宽
系统监控
接口失败率、平均响应时间、最大响应时间、TP(top percentile )50,90,95,99、慢sql、GC时间等
业务监控
支付成功率、异常响应时间
角色:事务协调者、参与者
过程:
缺点:
角色:事务协调者、参与者
过程:
缺点:
角色:系统A、系统B、kafka
过程:
缺点:
系统A需要增加消息表和定时,增加了业务方代码的复杂性。不过可以抽象成共有组件来做这个事情。
角色:系统A、系统B、rocketmq
过程:
缺点:
RocketMQ 最大的改变其实是把“扫描消息表”这件事不让业务方做,而是让消息中间件完成。至于消息表,其实还是没有省掉。因为消息中间件要询问发送方事物是否执行成功,还需要一个“变相的本地消息表”,记录事务执行状态和消息发送状态。同时对于消费方,还是没有解决系统重启可能导致的重复消费问题,这只能由消费方解决。需要设计判重机制,实现消息消费的幂等。以上两种方式,如果一直消费失败,则需要人工介入来解决。
角色:调用方、服务方系统A、服务方系统B
过程:
缺点:
TCC实质上是业务上的2PC,需要TCC框架做支持,因为需要涉及的相关方都实现三个接口,一般业界少有实践;需要判重和幂等。
事件方式(Choreography)
角色:多服务方,向前补偿、向后补偿
过程:
缺点:
需要确保事件不丢失
仅适用于参与者比较少的情况,参与者多了订阅关系就不清楚了;而且还有可能出现环状事件
命令方式(Orchestration)
角色:协调中心、多服务方,向前补偿、向后补偿
过程:
缺点:
需要维护协调中心,但这个中心无业务方负责
依然要保证命令不丢失
角色:调用方、服务方
过程:
缺点:
只适应于内部业务,可控性很差,不能用于不支持幂等的三方服务调用等。
基于数据关系的补偿。
角色:Proposer、Acceptor、Learner(不参与协商过程,根据Quorum读取协商完成的值)
过程:
Prepare
Accept
Proposer广播accept(n,v)。这里的n就是Prepare阶段的n,v可能是自己的值,也可能是Prepare阶段的acceptValue。
Acceptor收到accept(n,v),做如下决策:
n>=当前最小提案号,当前最小提案号赋值为n,值赋值为acceptValue,返回yes。否则no。
Proposer如果收到半数以上的yes,并且minProposalId=n,则算法结束。否则,n自增,重复P1a。
问题:
角色:Proposer(转为Leader)、Acceptor、Learner
过程:
单点写入,只允许有一个Leader。
过程:和ZAB相同
概念
term(epoch):Leader的任期。
作用1:可以让Follower感知到Leader过期了。
作用2:分区发生并且合并后,最后只允许有一个Leader。
作用3:term是分区存储的,不会发生有延迟的节点被选成Leade,选举要半数同意,表示大部分节点存储的是新的term,选举的时候要选自己同步的最大最新的term。
index:日志的顺序。
commitIndex:已提交日志的index
类tcp的设计,commitIndex=7表示7之前的日志都确认了。
作用1:不需要为每条日志都维护一个 commit 或 uncommit 状态,而只需要维护一个全局变量commitIndex即可。
作用2:Follower不需要逐条日志地反馈Leader,哪一条commit了,则哪一条uncommit。
lastApplied:记录哪些日志已经被回放到了状态机。
节点的State变量
任何一个节点也是有三种状态:Leader、Follower 和Candidate。初始时,所有机器处于Follower状态,等待Leader的心跳消息(一个机器成为Leader之后,会周期性地给其他Follower发心跳)。很显然,此时没有Leader,所以收不到心跳消息。
当Follower在给定的时间(比如2000ms)内收不到Leader的消息,就会认为Leader宕机,也就是选举超时。然后,随机睡眠0~1000ms之间的一个值(为了避免大家同时发起选举),把自己切换成Candidate状态,发起选举。
选举结束,自己变成Leader或者Follower。
两条日志a和b,日志a比日志b新:
term>b.term
term=b.term 且 a.index>b.index。
日志比自己新,对选举返回yes,且一个term只能选一次;否则就no。
对于Leader,发现有更大term的Leader存在,自己主动退位,变成Follower。这里有一个关键点:心跳是单向的,只存在Leader周期性地往Follower发送心跳,Follower不会反向往Leader发送心跳。后面要讲的Zab算法是双向心跳,很显然,单向心跳比双向心跳简单很多。
在Leader成功选举出来后,Leader会并发地向所有的Follower发送AppendEntries RPC请求,只要超过半数的Follower复制成功,就返回给客户端日志写入成功。
Follower接到请求后处理逻辑如下:
如果term 如果发现自己的日志中没有(prevLogIndex,pevLogTerm)日志,则拒绝接收当前的复制; 如果发现自己的日志中,某个index位置和Leader发过来的不一样,则删除index之后的所有日志,然后从index的位置同步接下来的日志。
当Leader宕机之后,选出了新的Leader,Follower是被动的,其并不会主动发现有新的Leader上台了;而是新的Leader上台之后,会马上给所有的 Follower 发一个心跳消息,也就是一个空的AppendEntries 消息,这样每个Follower都会将自己的term更新到最新的term。这样旧的Leader即使活过来了,也没有机会再写入日志。
单点,Primary-Backup模型
两种模型比较
Replicated State Machine(日志序列,Redis的AOF,mySql BinLog的statement,原语句)
Primary-Backup System(状态变化,Redis的RDB,mySql BinLog的raw,变更)
差异:
概念
任何一个节点也是有三种状态:Leader、Follower 和Election。Election状态是中间状态,也被称作“Looking”状态。在初始的时候,节点处于Election状态,然后开始发起选举。
在Zab里面是双向心跳,Follower收不到Leader的心跳,就切换到Election状态发起选举;反过来,Leader收不到超过半数的Follower心跳,也切换到Election状态,重新发起选举。
选举方式,Raft选取日志最新的节点作为新的Leader,Zab的FLE(FastLeader Election)算法也类似,选取zxid最大的节点作为Leader。如果所有节点的zxid相等,比如整个系统刚初始化的时候,所有节点的zxid都为0。此时,将选取节点编号最大的节点作为Leader(Zookeeper为每个节点配置了一个编号)。
选举结束,处于Leader或者Follower状态。
FLE(FastLeader Election)算法,选取zxid最大的节点作为Leader。如果所有节点的zxid相等,比如整个系统刚初始化的时候,所有节点的zxid都为0。此时,将选取节点编号最大的节点作为Leader
Leader的日志不会动,Follower上传zxid。Leader拿自己日志和Follower做日志比对,然后发送日志的截断、日志的补齐或全量同步等操作给Follower。
一致性、可用性、网络分区
分布式场景下的不一致:
底层调用上层
是否是设计不合理?能否依赖反转,底层做接口,上层做实现?
同层之间双向调用
是否可以抽象公共服务,同层做隔离
参数层层传递
隔离到对应的层做处理,比如app版本,严格放到最外层,隔离出去
聚合层多
技术能力、独立能力、思维能力