|
作者 | 董鹏 阿里巴巴技术专家
微服务
好处:实现跨团队的解耦,实现更高的并发(目前单机只能实现 c10k)不用再拷贝代码,基础服务可以公用,更好的支持服务治理,能够更好的兼容云计算平台。 RPCrpc:像调用本地方法一样调用远程函数;客户端:一般利用动态代理生成一个接口的实现类,在这个实现类里通过网络把接口名称、参数、方法序列化后传出去,然后控制同步调用还是异步调用,异步调用需要设置一个回调函数;客户端还需要维护负载均衡、超时处理、连接池管理等,连接池维护了和多个 server 的连接,靠此做负载均衡,当某个服务器宕机后去除该连接。请求上下文维护了请求 ID 和回调函数,超时的请求当回复报文到达后由于找不到请求上下文就会丢弃。服务端:维护连接,网络收到请求后反序列化获得方法名称,接口名称,参数名称后通过反射进行调用,然后将结果在传回客户端;序列化的方式:一种是只序列化字段的值,反序列化的时候重新构建对象再把值设置进去,另外一种方式直接将整个对象的结构序列化成二进制。前者节省空间,后者反序列化速度快,目前的序列化框架也是在反序列化时间和占用空间之间权衡。有点类似哈夫曼编码,或者数据库怎么存储一行一行的数据。 注册中心
一般有 3 种模式:f5 做集中式代理;客户端嵌入式代理例如 dubbo;还有一种是综合上面两种,多个客户端共用一个代理,代理作为一个独立进程部署在和客户端服务器同一台物理机上,ServiceMesh 就是这种模式。 zookeeper 不适合做注册中心的原因:zookeeper 为了一致性牺牲了可用性,但是注册中心实际上对一致性要求并不高,不一致产生的后果也就是某个服务下线了而客户端并不知道,但是客户端通过重试其他节点就可以了。
另外当发生网络分区的时候,如果超过半数节点挂了,zookeeper 就不可用,但是实际上它应该仍然可以对它所在机房的节点提供注册服务,例如三个机房分别放了 2 台、2 台、1 台,如果各个机房之间网络断了,但是机房内部上是通的,这样注册中心不可用即使内部节点也不能服务了。
zookeeper 并不是严格的一致性,它支持读写分离,其它节点收到写请求会转发给 master 节点,而其它节点可以支持读请求,当数据还没有从主节点复制过来的时候读到的可能是过期的数据。
配置中心
配置中心的需求:保证高可用、实时通知、灰度发布、权限控制、一键回滚、环境隔离(开发/测试/生产)等,目前的开源实现:nacos disconf apollo。disconf:scan 模块扫描注解和监听器;store 模块将远程获取到的配置存储到本地,本地一个 job 检测配置是否有变化,有变化就通知监听器;fetch 模块从远程通过 http 获取配置;watch 模块监听 zookeeper 上节点的变化,有变化就会调用 fetch 进行获取。 apollo 有以下 4 个模块:
portal 作为一个管理后台,提供管理员操作的入口。 有独立的数据库;adminservice 提供配置的修改和发布服务的底层服务,和 configservice 公用一个数据库 configdb,每次修改配置就会往数据库里插入一条记录 releasemessage;configservice 用一个定时任务去扫描数据库是否有新的 releasemessage,有的话就通知客户端,而客户端采用定时轮询的方式去查询 configservice 是否有新消息,这里采用 deferredresult 异步执行;eruka 为 adminservice 和 configservice 提供了注册发现的服务。客户端获取到配置文件后也会写入磁盘。 任务调度执行器也就是应用本身,任务单元也就是具体执行任务的线程,能够主动注册调度器中,并在启动的时候进行更新,例如删除已经清空的任务;调度中心支持集群部署避免单点,可以选举一个主节点,其它为 slave;支持负载均衡算法为每个任务随机选择执行器,能够支持失败重试,将执行很慢或者失去连接的执行器移除;支持控制任务并发,例如是否允许一个任务没执行完又被调度;支持任务依赖,例如一个任务没执行完另一个任务不能执行,或者自动执行另外一个任务;支持任务分片,将一个任务根据参数分片到不同的执行器上一起执行;可以取消一个任务;已经支持 glue 模式,可以不用发布就执行一个任务单元。 分布式锁redis setnx 里面已经有参数可以支持分布式锁,但是最好能把锁的拥有方存到 value 里,释放的时候做比较,不然可能释放错锁,也就是会出现 A 释放了 B 的锁;zk 采用创建临时节点,其他创建失败的线程监听锁的状态。SETresource_name my_random_value NX PX30000统一监控收集日志并分析,日志也可以和 rpc 链路进行关联,也可以对日志进行降噪或者压缩存储;提供 api 的方式以及拦截器模式,可以基于 javaagent 做到无嵌入;实现 opentracing 链路追踪;可以基于 disruptor ringbuffer 的生产消费者模式;海量数据的存储 elasticsearch;报表生成,监控指标设置;各个节点进行收集,消息上传到服务端统一处理;监控指标:rpc 链路、数据库、cpu 指标等、http 状态、各种中间件;日志收集可以通过直接在日志框架上加拦截器,或者用 flink+kafka 收集。 缓存
先清空缓存还是先更新数据库?如果是更新缓存而不是删除缓存:则不管哪种方式都会造成缓存和数据库不一致;如果是删除缓存:则先删除缓存在更新数据库,如果更新数据库失败了也没有太大影响,缓存被清了重新加载即可。但是也要考虑到缓存穿透的问题,如果这个时候大流量进来是否会压垮数据库?以上是考虑到分布式事务中一个成功一个失败的情况,但是这种概率毕竟是小的,可以用在并发量不是很高但是对数据一致性要求很高的情况,如果并发很高建议先更新数据库后清空缓存。
如果先清空缓存,后更新数据库,在还没有更新到数据库的情况下另外一个事务去查询,发现缓存没命中就去数据库取,然后又写入缓存,之后上一个事务的数据库更新,这样就导致了缓存和数据库不一致,如果先更新数据库再清空缓存,更新完数据库后缓存还没更新,这个时候来读取缓存是旧的值,也出现不一致,但是最终清空缓存后会一致。
不过这种方式也会产生永久不一致,但是概率很小,例如一个读请求,没有命中缓存,这个时候可能另一个线程刚好清空缓存,然后它就去数据里面取,但是又有一个线程在它读完数据库后将数据库改为另外一个值,这样那个读请求写入到缓存的数据就是脏数据了。 redis 采用单线程模型,对只有 io 操作来说性能很好,但是 redis 也提供了计算功能,如排序聚合,cpu 在计算的时候所有的 io 操作都是阻塞的。
memecached 先申请一块内存,将其分割成大小不等的若干内存块以存储不同大小的键值对。这种方式效率高但是可能产生空间浪费。而 redis 只是单纯的包装了下 malloc 和 free。
redis 提供了两种方式持久化数据,一种方式是把某一时刻所有的数据都写入磁盘,另外一种方式通过增量日志的形式
memecache 提供了 cas 来保证数据一致性;redis 提供了事务,将一连串指令一起执行或者回滚。
memechache 只能通过一致性哈希来进行集群,而 redis 提供了集群功能,客户端做路由选择那个 master 节点,master 节点可以有多个 slave 节点做为备用和读。
redis 中的字符串没有采用 c 语言里的结构,额外加上了空闲内存和已占用内存,这样读取的时候由于已经知道 char 数组大小,所以可以直接取出,避免遍历操作,当字符串变大或缩小的时候可以避免重新分配内存,可以用到空闲空间,也就是 redis 会预分配一个空间。
严格的一致,只能一个生产者,发送到一个 broker 上,然后只有一个队列一个消费者,但是这种模式有很多弊端,一个地方异常将阻塞整个流程,RocketMQ 将这个问题交给应用层处理,也就是发送端自己选择发送到哪个队列,例如同一个订单的消息发送到同一个队列。但是算法在其中一个队列异常的时候也会有问题。
如何保证消息不重复
只要网络上传输肯定会有这种问题,所以应用层最好能够支持幂等,或者用一张去重表存储每一个处理过的消息 ID。
发送消息流程先获取 topic 对应的路由信息(路由信息会从 namesrv 返回,在客户端缓存,返回这个 topic 对应哪几个 broker 以及每个 broker 上有多少个队列);如果没有获取到,可能没有 topic,需要自动创建,自动创建是客户端发信息个 namesrv,namesrv在去请求 broker,broker 创建好后返回根据路由策略获取一个 queue(从所有的 queue 中根据对应的路由策略获取 queue,然后再判断这个 queue 对应的 broker 是否健康,健康就返回),这个地方就可以做到 broker 的高可用;所以我们发现消息是发给哪个 broker 的哪个 queue 是在客户端发送的时候决定的,不是在生成 commitlog 之后再派发的,这样我们就可以指定都到某一个固定 queue 了;消息发送的时候会构建发送请求,里面包含了消息体、队列信息和 topic 信息等,消息体里面会增加一个消息ID;如果消息重试多次后还是失败就会进入死信队列,一个固定的 topic。消息存储
每个 commitlog 大小为 1G,第二个文件的起始偏移量就是 1G 的 byte 大小,当根据一个偏移量获取对应某个文件的时候,根据偏移量对 1G 取余就可以,这些 commitlog 文件通过一个文件队列维护,每次写文件返回队列的最后一个文件,然后需要加锁。
创建完文件后会进行预热,预热的时候会在每一个内存页 4kb 里面写一个 byte0,让系统对缓存页缓存,防止真正写入的时候发生缺页,mmap 的机制是只会记录一个虚拟地址,当缺页时才会去获取物理内存的地址。
创建文件有两种方式:一种是 FileChannel.map 获取 MappedByteBuffer;另外一种是使用堆外内存池,然后 flush。 消息的消费
一个队列只能被一个客户端消费。
当存在多个队列,但只有一个客户端的时候,这个客户端需要去 4 个队列上消费,当只有一个队列的时候只会有一个客户端可以收到消息,所以一般情况下需要客户端数量和队列数量一致,客户端一般会保存每个队列消费的位置,因为这个队列只会有一个客户端消费,所以这个客户端每次消费都会记录下队列的 offset,broker 端,也会记录同一个 grouo 消费的 offset。
MappedByteBuffer 的原理是老的 read 是先将数据从文件系统读取到操作系统内核缓存,然后再将数据拷贝到用户态的内存供应用使用,而使用 mmap 可以将文件的数据或者某一段数据映射到虚拟内存,这个时候并没有进行数据读取,当用户访问虚拟内存的地址的时候会触发缺页异常,这个时候会从底层文件系统直接将数据读取到用户态内存。
而 MappedByteBuffer 通过 FileChannel 的 map 方法进行映射的时候会返回一个虚拟地址,MappedByteBuffer就是通过这个虚拟地址配合 UnSafe 获取字节数据。
操作系统在触发缺页异常的时候会去文件系统读取数据加载到内存,这个时候一般会进行预读取,一般为 4KB,当系统下次访问数据的时候就不会发生缺页异常,因为数据已经在内存里了,为了让 MappedByteBuffer 读取文件的速度更高,我们可以对 MappedByteBuffer 所映射的文件进行预热,例如将每个 pagecache 写一个数据,这样在真正写数据的时候就不会发生缺页了。
分库分表 一般三种方式:在 dao 层和 orm 层利用 mybatis 拦截器,基于 jdbc 层进行拦截重写 JDBC 接口做增强,基于数据库代理。
jdbc 代理,实现 datasource,connection,preparestatement,druid 解析 sql,生成执行计划,利用 resultset 对结果集进行合并(group by order max sum)。 分表策略,一般是哈希,要保证分库和分表的算法完全没有关联,不然会数据分布不均匀。
数据扩容的时候可以通过配置中心动态的修改写入策略,如何一开始可以先读老表,数据同时写入新表和老表,等数据迁移完成后,在读新表并双写,之后在读新表写新表。
唯一 id
数据库自增 id,一次取多个,单机限制,另外数据库自增 id 内部也用了个锁,只是在 sql 执行结束即使事务没提交也会释放锁。
雪花算法变种 : 15 位时间戳,4 位自增序列,2 位区分订单类型,7 位机器ID,2 位分库后缀,2 位分表后缀,共 32 位。
利用 zookeeper 的顺序节点获取自增 ID。
分布式事务
两阶段提交:事务管理器,资源管理器,一阶段准备,二阶段提交 (XA 方案对业务无侵入,由数据库厂商提供支持,但是性能很差)。 事物补偿 TCC :也是两阶段,第一阶段尝试锁定资源,第二阶段确认或者回滚。
设计规范:业务操作分成两部,例如转账:尝试阶段为冻结余额,第二阶段提交为从冻结余额扣款,回滚为解冻;事务协调器记录主事务日志和分支事务日志,支持在任意一步发生异常后进行补偿或者逆向补偿保证最终一致性;并发控制,降低锁的粒度提高并发,保证两个事务间不需要加排他锁,例如热点账户的转账操作,由于第一阶段进行了冻结,所以后面的扣减余额不同事务之间没有影响;允许空回滚:可能一阶段的尝试操作发生超时,然后二阶段发起回滚,回滚的时候要判断一阶段是否进行过操作,如果一阶段没有收到请求,回滚操作直接返回成功;避免一阶段操作悬挂:可能一阶段超时,二阶段回滚后,一阶段的请求到达,这时候要拒绝一阶段的尝试操作;幂等控制,由于第一阶段和第二阶段的操作可能都会执行多次,另外操作接口最好能提供状态查询接口供后台的补偿任务正常执行。框架事务(seata)一阶段:框架会拦截业务 sql,根据语句执行前结果生成 undolog , 根据语句执行后对结果生成 redolog , 根据数据库表名加主键生成行锁;二阶段:如果事务正常结束,将删除 undolog redolog 行锁,如果事务将回滚,则执行 undolog sql , 删除中间数据,在执行 undolog 的时候会校验脏写,也就是有没有其他事务已经修改了这行记录,就用 redolog 做对比,如果出现脏写只能人工修数据 (二阶段的清理工作可以异步执行)。开启事务的时候会向 tc 申请一个全局的事务 id,这个事务 id 会通过 rpc 框架的拦截器传入到被调用端,然后放入 threadlocal,被调用方在执行 sql 的时候会去检查一下是否在一个全局事务里。
默认的隔离级别为读未提交,因为事务一阶段已经本地事务提交而全局事务并没有完成,后续可能会回滚,其他事务可以看到这个状态,提供的读已提交的方式是通过 for update,当解析到该语句的时候会检查是否存在行锁冲突,如果存在冲突就等待直到释放。tm 向 tc 发起开启一个全局事务,生成一个全局唯一的 xid;xid 在微服务调用链上进行传递;rm 向 tc 注册分支事务;tm 向 tc 发起全局提交或者回滚决议;tc 向 rm 发起回滚或提交请求。 一致性消息队列:先发送半消息,如果成功了在执行本地事务,本地事务成功就提交半消息,本地事务失败就回滚半消息,如果消息队列长期没有收到确认或者回滚可以反查本地事务的状态,消费端收到消息后,执行消费端业务,如果执行失败可以重新获取,执行成功发送消费成功的确认。 MYCAT CAPC:一致性A:可用性P:分区容忍性可以简单地这样理解:MySQL 单机是C;主从同步复制 CP;主从异步复制 AP。
Zookeeper 选择了 P,但是既没有实现 C,也没有实现 A,而是选择最终一致性。可以在多个节点上读取,但是只允许一个节点接受写请求,其他节点接收的写请求会转发给主节点,只要过半节点返回成功就会提交。
如果一个客户端连接的正好是没有被提交的 follower 节点,那么这个节点上读取到的数据就是旧的,这样就出现了数据的不一致,所以没有完全实现 C。由于需要过半节点返回成功才提交,如果超过半数返回失败或者不返回,那么 zookeeper 将出现不可用,所以也没有完全实现 A。
当然衡量一个系统是 CP 还是 AP,可以根据它牺牲 A 更多还是牺牲 C 更多,而 ZK 其实就是牺牲了 A 来满足 C,当超过集群半数的节点宕机后,系统将不可用,这也是不建议使用 zk 做注册中心的原因。
CAP 理论只是描述了在分布式环境中一致性、可用性、分区容忍不能同时满足,并没有让我们一定要三选二,由于网络分区在分布式环境下是不可避免的,所以为了追求高可用,往往我们会牺牲强一执行,采用弱一致性和最终一致性的方案,也就是著名的 BASE 理论,而 base 理论其实是针对传统关系型数据的 ACID 而言的。
但 ACID 的提出是基于单节点下的,在分布式环境下,如何协调数据一致性,也就是在数据的隔离级别上做出取舍,即使是单机的关系型数据库为了提高性能,也就是可用性,定义了隔离级别,去打破 ACID 里面的强一致性 C,当然数据库也是为业务服务的,某些业务或者说大部分业务都没有强一致性的需求。 秒杀的处理动静分离:ajax 不刷新页面,缓存,cdn;发现热点数据:业务流程上变通让热点业务隔离出来,也通过链路监控获取一段时间的热点数据;隔离:业务隔离,数据库隔离;兜底方案:服务降级,限流;流量削峰:排队,过滤无效请求,答题或者验证码,消息队列;减库存:(下单减库存用户不付款需要回滚,付款减库存最终可能库存不足需要退款,下单后占库存一段时间后在回滚)。正常电商采用第三种,秒杀采用第一种,不超卖的控制不用放在应用层,直接在 sql 层加 where 语句进行判断,但是 mysql 针对同一行记录也就是同一个商品的减库存,肯定会高并发下争取行锁,这将导致数据库的 tps 下降(死锁检测会遍历所有需要等待锁的连接,这个操作非常耗 cpu),从而影响其他商品的销售,所以我们可以将请求在应用层进行排队,如果份额较少可以直接舍弃,另一种方案是在数据库层排队,这种方案需要采用 mysql 的补丁。
docker
namespace
docker 在创建容器进程的时候可以指定一组 namespace 参数,这样容器就只能看到当前 namespace 所限定的资源、文件、设备、网络、用户、配置信息,而对于宿主机和其他不相关的程序就看不到了,PID namespace 让进程只看到当前 namespace 内的进程,Mount namespace 让进程只看到当前 namespace 内的挂载点信息,Network namespace 让进程只看到当前 namespace 内的网卡和配置信息,
cgroup
全名 linux control group,用来限制一个进程组能够使用的资源上限,如 CPU、内存、网络等,另外 Cgroup 还能够对进程设置优先级和将进程挂起和恢复,cgroup 对用户暴露的接口是一个文件系统,/sys/fs/cgroup 下这个目录下面有 cpuset,memery 等文件,每一个可以被管理的资源都会有一个文件,如何对一个进程设置资源访问上限呢?
在 /sys/fs/cgroup 目录下新建一个文件夹,系统会默认创建上面一系列文件,然后 docker 容器启动后,将进程 ID 写入 taskid 文件中,在根据 docker 启动时候传人的参数修改对应的资源文件。
chroot
通过 chroot 来更改 change root file system 更改进程的根目录到挂载的位置,一般会通过 chroot 挂载一个完整的 linux 的文件系统,但是不包括 linux 内核,这样当我们交付一个 docker 镜像的时候,不仅包含需要运行的程序还包括这个程序依赖运行的这个环境,因为我们打包了整个依赖的 linux 文件系统,对一个应用来说,操作系统才是它所依赖的最完整的依赖库。
增量层
docker 在镜像的设计中引入层的概念,也就是用户在制作 docker 镜像中的每一次修改,都是在原来的 rootfs 上新增一层 roofs,之后通过一种联合文件系统 union fs 的技术进行合并,合并的过程中如果两个 rootfs 中有相同的文件,则会用最外层的文件覆盖原来的文件来进行去重操作。
举个例子,我们从镜像中心 pull 一个 mysql 的镜像到本地,当我们通过这个镜像创建一个容器的时候,就在这个镜像原有的层上新加了一个增 roofs,这个文件系统只保留增量修改,包括文件的新增删除、修改,这个增量层会借助 union fs 和原有层一起挂载到同一个目录,这个增加的层可以读写,原有的其他层只能读,于是就保证了所有对 docker 镜像的操作都是增量。
之后用户可以 commit 这个镜像将对该镜像的修改生成一个新的镜像,新的镜像就包含了原有的层和新增的层,只有最原始的层才是一个完整的 linux fs, 那么既然只读层不允许修改,我怎么删除只读层的文件呢?这时只需要在读写层(也就是最外层),生成一个 whiteout 文件来遮挡原来的文件就可以了。
发布与部署
目前的大部分公司采用下面的部署方式。创建 pileline 指定项目名称和对应的 tag,以及依赖工程。一个 pipeline 指一个完整的项目生命周期(开发提交代码到代码仓库、打包、部署到开发环境、自动化测试、部署到测试环境、部署到生产环境);根据项目名称和 tag 去 gitlab 上拉取最新的代码(利用 java 里的 Runtime 执行 shell 脚本);利用 maven 进行打包,这个时候可以为 maven 创建一个单独的 workspace (shell 脚本);根据预先写好的 docfile,拷贝 maven 打的包生成镜像,并上传镜像 (shell 脚本);通过 K8s 的 api 在测试环境发布升级;通过灰度等方案发布到生产环境。查看更多:https://yq.aliyun.com/articles/743558?utm_content=g_1000102890
上云就看云栖号:更多云资讯,上云案例,最佳实践,产品入门,访问:https://yqh.aliyun.com/
|
|