微服务之间数据同步的思考

周末无聊,来一篇服务之间数据同步的博客吧(主要讲注意的问题)。具体什么业务场景就不举例了。

ps:纯属个人瞎说,有错误、不足请大侠指出。嗯,开始说正事了。

业务流程

主要业务流程如下:

保存数据
同步
用户端操作
服务A
服务B
  • 用户操作,保存数据到服务A;
  • 服务A保存成功后,然后将一些数据同步到服务B;
  • 服务B接收到数据,保存成功,流程结束。

这里我们就讨论服务A->服务B数据同步的问题,我们得保证以下两点:

1.首先是数据的准确性(你到银行存1万,你余额只增加了100你干不干。反之,你存了100,余额多了1万银行干不干?)

2.其次是数据同步效率(【下边话很长,可以不看】你找在外打工的隔壁老卢借了10万急用,老卢二话不说到某某银行app转你。过去几个小时了还没到账,你会咋想,老卢TM逗我了,是不是不想借我。但是这钱你急用,必须得借,你再放下面子,掏出手机给老卢了Q了一个微信电话:“老卢,可不可以借我10万,我真的急用…“。但是老卢说我真的几个小时前就转了你,并把转帐记录截图发给你。你一看时间,老卢没骗你,结果看到“转账中”这几个字,顿时就火了,TM什么银行,几个小时了还不到账…一大堆吐槽,许久钱终于到了。后边你有空了,二话不说,把自己某行账号注销了,导致某行损失了一个用户)

版本1-保证数据的准确性

画图太浪费时间,最终版再画图说明吧,这里用文字说明。

服务A伪代码:

	开启分布式事务{
		  //数据校验,耗时20ms
          //保存业务到DB,耗时10ms

		  //得到需要同步的数据,rpc远程调用服务B的save方法,耗时40ms

     }都成功,提交事务;失败,提交事务

服务B伪代码:

 //保存成功大约耗时40ms
 save方法{

 //解析校验数据,30ms

 //校验成功,保存数据,10ms

 //校验失败,返回错误,通知服务A自己保存失败

 }保存成功提交事务,操作失败回滚事务

最开始呢,公司用户少,流量小,业务也不复杂。加上网络延迟、数据传输各种时间,这个代码耗时一般不会超过100ms,几乎没有就no problem!但是突然有一天,公司被某大厂收购了,他们花钱给我们软件打广告。我们的用户数量指数增加,业务也变复杂了,DB的数据也指数增加,服务A的代码从30ms->100ms,服务B耗时从大约40ms->400m~1.5s,服务B的代码可能就成了这种:

save方法{
  
  if(情况1){
    
  }else if(情况2){
  	
  }else if(情况3){
  	//远程调用其他,或者从缓存中获取一些支撑业务数据
  	//校验数据
  	//记录各种日志
  	//保存数据
  	//更新xx表的状态
  }
}保存成功提交事务,操作失败回滚事务

  最终,这个接口从原来的不到100ms到了超过500ms。更糟糕的是,在流量高峰的时候,很多用户都无法操作了,保存就一直转呀转,甚至都出现各种超时,用户体验越来越不好(以tomcat为例,它内部处理请求就是线程池,资源有限,以前的请求资源不释放,后边的请求要不被拒绝,要不等待)。中间加了很多服务器,但是问题还是会时不时发生,投诉信一封又一封,有一天老板把技术经理喊到办公室:“你行不行,不行滚蛋!“。技术经理抹了抹头上的汗:“行,行“。老板:“行TM还不去解决“!(可见事情的严重性)

版本2-集成消息中间件(这里以rabbitMQ为例)

技术经理把一群开发喊到办公室,讨论了许久,决定用某某中间件,不仅能解决流量削峰的问题,代码还解耦,将各种细节、注意点问题讨论的可谓是一清二楚。整个团队连夜加班几天,将类似的几个业务点采用某某消息中间件逐一实现。代码也就变成了大概这样:

服务A伪代码:

开启事务{
	 //数据校验,耗时20ms
     //保存业务到DB,耗时10ms
	//数据同步到消息中间件,20ms
	if(发送不成功){
		//抛出异常
	}
}保存成功提交事务,操作失败回滚事务

服务B的伪代码:


从消息中间件取数据{
   执行save方法 
}
save方法{
        //业务逻辑
}保存成功提交事务,操作失败回滚事务

一些消费端异常处理和到mq和其他的具体细节在最后的版本说

上线头两天,完美!回到了最初的100ms,全团队心里美滋滋,今年的年终奖是跑不掉了。

但是,突然有一天,又收到了很多的投诉,说xxx数据对不上。负责这个业务的小王一脸懵逼啊,技术经理一上rabbitMq的控制台一看,这个队列此时平均生产1000/s,消费500/s,已经堆积了好多万的数据了。陆陆续续,其他的几条主业务先也有这样的问题,而且都是大量需要同步的堆积在mq。

加服务器?生产:消费=2:1,这开销老板能答应吗?而且又不是每天流量高峰,用户再增加,按照这个比例,钱可不是小数目。技术经理不亏是技术经理,立马又想到了使用多线程处理消息,又把负责的开发喊到一起开会。最终版就来了。。。。

最终版

这里的中间件以rabbitMq为例,保存数据的数据库以mysql(innodb引擎)为例。

注意点1、服务A发送到中间件

使用了消息中间件,采用了生产-消费的模式让程序代码解耦,解决流量削峰问题,但是也会增加我们程序的难度和数据一致性的问题,我们不得不考虑下边的问题。

中间件数据的持久化

如果mq的跌机,数据丢失了怎么办,这个时候我们需要考虑到配置队列数据的持久化。

是否要采用发布者确认

并不是我们调用了一个rabbitMq的客户端发送方法,数据就到了队列。我们调用客户端的api只能保证数据发送到Broker上,并不能保证数据到交换器(注意交换机并没有持久化的能力)、交换器到队列、到了队列数据已经持久化。中间某个时刻mq挂了,都可能出现消息丢失。

还有一种情况,我们一般会在消费端用@RabbitListener注解自动创建队列,给队列绑定交换机(或者用@Bean的方式)。最开始项目初始化阶段(刚上线),如果消费端没有启动,生产者生产消息,调用api是程序是不会报错的。这个时候队列什么都没有,你send的消息就丢失了。===>这点我已经尝试,当然我们可以手动创建。

这两种情况都有可能导致数据丢失。

磁盘数据丢失

这是极端中的极端吧,例如服务器的磁盘损坏(遇到了就是运气爆表,但是也有公司发生过)。或者那个程序员、运维啥的不小心将持久化的数据给误删了。

这种情况,可能造成大量数据丢失了。一般而言,我们考虑在生产端在数据同步给mq的时候,先将这些数据备份一份。

如果我们采用了定时任务补偿机制,可以用补偿机制解决。

注意点2、服务B从中间件中消费数据

是否手动签收

rabbitMq默认的情况下,是自动签收的。也就是你从消费端取了数据,这条数据就从对应的队列中删了。如果没有定时任务的补偿机制,就一定要加(你不能保证你的服务不挂,你的数据不挂,或者你的代码出现其他的异常),出现一点问题,数据就丢失了。

每次从mq中获取消息的数量(prefetch_count)

每个版本的客户值可能不同,我们可以自己在消费端设置这个值

公平分发

每个服务器的机器配置和性能是不同的,rabbitMQ默认是采用轮询模式。假设两个消费者A,B,100条消息,就会各50条。

各50条,公平呀,你五十,我五十,对半分(你是一个初级程序员,让你和高级程序员任务对半分,你心里舒服吗,你累死累活还没做一半,人家做完玩了好几天了)。这个时候就要按劳分配了,谁先处理完谁就去领任务,能者多劳。

重复消费

有一段下面的伪代码:

//步骤一:插入数据

//步骤二:记录日志

//步骤三:更新状态

//没有报错,手动签收

我们来考虑下面几种情况:

1.开启异常重试。

​ 假设我们在代码中突然执行到3,更新数据的时候,更新状态的数据库突然出现问题了。后边异常重试的时候,数据库又好了,插入操作就执行两次,我们应该采取措施,避免保证同一个消息,多次消费,造成重复操作(例如:增加一个消息的标识,消费了就不再插入了)

​ 2.手动签收

当步骤3报错,这个消息就没有手动签收,这条消息依然会存在消息队列中,重新投递(当然这里我们指定报错后的操作,例如:重回队列让其他消费者消费、将消息移除队列、消息确认等,报错了怎么处理需按钮业务处理)

​ 3.定时任务补偿

​ 服务A的同步状态是false,那么就会隔一段时间,再次将消息投递给消息中间件。步骤三的操作的数据库头次失败,后边这个同一条数据又会来。

注意点3、任务交给线程池处理

线程池参数设置

​ 首先我们应该将线程池一些主要参数设置合理,比如核心线程数量、总线程数量、队列大小(默认int的最大值)等,这些参数并不是一次就能设置好,我们的结合生产环境,观察线程池的拒绝率、线程利用率、堆积任务数量等设置合理的参数大小(每个机器的配置可能不一样,要具体机器具体分析),尽可能的扩大机器的利用率,也不导致服务跌机(例如不设置队列大小,一下来了过多任务,导致泄露)

其次必须知道一个知识点:线程池的拒绝策略( 默认的情况下,是拒绝抛出异常的策略)(可以看java.util.concurrent.RejectedExecutionHandler的实现类)。当任务超过了线程池的限定,就会对任务拒绝。拒绝了,我们是交给调用的线程处理(这种不错一般哟,不会丢弃线程任务),还是丢弃旧的任务,还是丢弃新来的任务,要不要抛出拒绝异常?我们都得考虑清楚。

注意点4、线程池异步执行save

保证数据的有序性

假设用户保存数据,同步到服务B(假设:同一个用户的数据不仅能修改值,数据的条数也会增加、删除),我们就有了下边的伪代码:

//删除原来的(delete)

//添加新的(insert)

我们必须要考虑一个用户的数据,是否存在多个线程中同一刻执行,执行的时候必须保证是数据的有序性。例如:

1.用户端存在自动保存的功能,每隔10秒保存一次,刚自动保存,立即又修改保存。

2.用户上次同步没有同步成功,定时任务开始同步,用户这个时候又在修改数据。

上边的情况都会导致新、旧在相差很短、甚至同时投递到消息中间件(ps:这里我们用新、旧两个汉字代表新数据和原来的老数据或者上一次的数据)

异常情况:

机器1首先取到“旧“,机器2取到“新“(每个机器对应的线程池都还有很多其他的任务)。但是机器1这个时候cpu过高、机器的性能不行执行的慢,或者这个机器执行“旧“的线程倒霉,很久没有抢到cpu的时间分片,从而导致他们一起去执行save方法,甚至是“旧”的执行到新的后边。会产生什么情况?

假设一起执行save方法,机器1的delete、insert和机器2的delete、insert一起执行,排列组合的问题,不知道数据成了什么鬼

例如:
情况1:
机器1(旧数据)-delete
机器2(新数据)-delete
机器1(旧数据)-insert
机器2(新数据)-insert
//本来新的数据中,我删了一行旧的数据,你现在又给我添加进来了

情况2:
机器2(新数据)-delete
机器2(新数据)-insert
机器1(旧数据)-delete
机器1(旧数据)-insert
//搞个鬼,我白改了?你还是保存的老的数据
其他的情况就不列举了

假设先执行新的,就跟上边的情况2一样了。

遇到这种情况我们应该怎么处理呢?也只能用分布式锁了。key是数据标识,value可以为数据的时间大小,来标识新旧数据。如果旧抢到锁,新就等待,数据完全没有问题。如果新的先抢到锁(先执行),就将旧数据跟这个时间值比较,小就不保存这个数据。

加锁的说明:这里我们只能以单个用户业务标识加锁。例如服务B是单机的时候,我们不能这样:

public synchronized void save(){....}

锁的粒度太大,锁住了无关的线程,影响了程序的效率。

保证delete、update操作利用到mysql索引

首先,我们得知道mysql在执行delete、update的操作的时候,没有利用到索引会锁表。没有利用到索引,删除或者更新的时候全表扫描本来就慢,你还把表给锁了,其他线程乃至其他服务都在等你,玩蛇呢?

查询的优化就不在这里说。

考虑使用自增的主键

> 每个`InnoDB`表都有一个称为聚集索引的特殊索引,用于存储行数据。通常,聚集索引与主键同义。为了从查询、插入和其他数据库操作中获得最佳性能,了解如何`InnoDB`使用聚集索引来优化常见查找和 DML 操作非常重要。
>
> - 在`PRIMARY KEY`表上定义时, `InnoDB`将其用作聚集索引。应该为每个表定义一个主键。如果没有逻辑唯一且非空的列或列集使用主键,请添加自动增量列。自动递增列值是唯一的,并在插入新行时自动添加。
> - 如果没有`PRIMARY KEY`为表定义 ,则`InnoDB`使用第一个 `UNIQUE`索引,并将所有键列定义为`NOT NULL`聚集索引。
> - 如果表没有索引`PRIMARY KEY`或没有合适的 `UNIQUE`索引,则`InnoDB` 生成以`GEN_CLUST_INDEX`包含行 ID 值的合成列命名的隐藏聚集索引 。行按`InnoDB`分配的行 ID 排序。行 ID 是一个 6 字节的字段,随着插入新行而单调增加。因此,按行 ID 排序的行在物理上是按插入顺序排列的

假设你搞个无序的主键,由于每次插入主键的值近似于随机,因此每次新纪录都要被插到现有索引页得中间某个位置,此时MySQL不得不为了将新记录插到合适位置而移动数据(mysql的innodb的B+TREE数据放到叶子节点的),甚至目标页面可能已经被回写到磁盘上而从缓存中清掉,此时又要从磁盘上读回来,这增加了很多开销,同时频繁的移动、分页操作造成了大量的碎片,得到了不够紧凑的索引结构,后续不得不通过OPTIMIZE TABLE来重建表并优化填充页面

不管是b-tree(数据放聚集索引树对应的节点),还是b+tree(数据只放聚集索引树对应叶子节点),使用无序的主键都会存在这个问题。

注意:

还有很多数据库用了b-tree,例如mongo,postgrepSql等都存在这个问题,我们都应考虑到无序的聚集索引树重构给我们带来的问题。

数据删除优化

如果你真的把数据删了,又会出现树重构的问题。不管是对数据库,还是我们程序效率都不友好。我们可以加一个删除状态,删除只更新状态。数据如果真的要删除,我们可以半夜空闲的时候用定时任务删。

注意点5、更新同步结果

在服务A中,用户每次保存的时候都会将同步状态设置false。我们同步成功后,就去改变状态值。这种操作用到索引的情况下一般更新很快,我们可以直接远程调用更新,如果使用中间件又增加程序的难度。

注意点6、备份需要同步的数据

备份需要同步的数据可以解决两点:

1.mq磁盘数据丢失数据的问题

2.其他各种异常,导致数据没有到服务B,定时服务定时调用服务A同步数据,让服务A有数据可以同步的问题。

对于这部分已经同步成功的数据,如果业务已经不需要,我们可以隔断时间迁移备份或者定时删除。

总结

  • 数据同步的时候,我们考虑到各种异常(代码异常、机器故障),出现异常会不会对我们同步数据造成影响。说到底就是保证数据同步的准确性,用定时任务的补偿机制,我们能保证数据最终一致性

  • 在保证数据准确的同时,还要考虑程序效率,给与用户友好的体验。

  • 在使用新的技术,都需要知道其中的坑。就像使用mq:可能会造成数据丢失,数据重复消费的问题。使用多线程,你得保证数据的有序性,存在并发修改同一条数据的时候,你不得不加锁(单机使用cas或者Lock,synchronized),加锁也能把控锁的粒度。

你可能感兴趣的:(项目实战)