周末无聊,来一篇服务之间数据同步的博客吧(主要讲注意的问题)。具体什么业务场景就不举例了。
ps:纯属个人瞎说,有错误、不足请大侠指出。嗯,开始说正事了。
主要业务流程如下:
这里我们就讨论服务A->服务B数据同步的问题,我们得保证以下两点:
1.首先是数据的准确性
(你到银行存1万,你余额只增加了100你干不干。反之,你存了100,余额多了1万银行干不干?)
2.其次是数据同步效率
(【下边话很长,可以不看】你找在外打工的隔壁老卢借了10万急用,老卢二话不说到某某银行app转你。过去几个小时了还没到账,你会咋想,老卢TM逗我了,是不是不想借我。但是这钱你急用,必须得借,你再放下面子,掏出手机给老卢了Q了一个微信电话:“老卢,可不可以借我10万,我真的急用…“。但是老卢说我真的几个小时前就转了你,并把转帐记录截图发给你。你一看时间,老卢没骗你,结果看到“转账中”这几个字,顿时就火了,TM什么银行,几个小时了还不到账…一大堆吐槽,许久钱终于到了。后边你有空了,二话不说,把自己某行账号注销了,导致某行损失了一个用户)
画图太浪费时间,最终版再画图说明吧,这里用文字说明。
服务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还不去解决“!(可见事情的严重性)
技术经理把一群开发喊到办公室,讨论了许久,决定用某某中间件,不仅能解决流量削峰
的问题,代码还解耦
,将各种细节、注意点问题讨论的可谓是一清二楚。整个团队连夜加班几天,将类似的几个业务点采用某某消息中间件逐一实现。代码也就变成了大概这样:
服务A伪代码:
开启事务{
//数据校验,耗时20ms
//保存业务到DB,耗时10ms
//数据同步到消息中间件,20ms
if(发送不成功){
//抛出异常
}
}保存成功提交事务,操作失败回滚事务
服务B的伪代码:
从消息中间件取数据{
执行save方法
}
save方法{
//业务逻辑
}保存成功提交事务,操作失败回滚事务
一些消费端异常处理和到mq和其他的具体细节在最后的版本说。
上线头两天,完美!回到了最初的100ms,全团队心里美滋滋,今年的年终奖是跑不掉了。
但是,突然有一天,又收到了很多的投诉,说xxx数据对不上。负责这个业务的小王一脸懵逼啊,技术经理一上rabbitMq的控制台一看,这个队列此时平均生产1000/s,消费500/s,已经堆积了好多万的数据了。陆陆续续,其他的几条主业务先也有这样的问题,而且都是大量需要同步的堆积在mq。
加服务器?生产:消费=2:1,这开销老板能答应吗?而且又不是每天流量高峰,用户再增加,按照这个比例,钱可不是小数目。技术经理不亏是技术经理,立马又想到了使用多线程处理消息,又把负责的开发喊到一起开会。最终版就来了。。。。
这里的中间件以rabbitMq为例,保存数据的数据库以mysql(innodb引擎)为例。
使用了消息中间件,采用了生产-消费的模式让程序代码解耦,解决流量削峰问题,但是也会增加我们程序的难度和数据一致性的问题,我们不得不考虑下边的问题。
如果mq的跌机,数据丢失了怎么办,这个时候我们需要考虑到配置队列数据的持久化。
并不是我们调用了一个rabbitMq的客户端发送方法,数据就到了队列。我们调用客户端的api只能保证数据发送到Broker上,并不能保证数据到交换器(注意交换机并没有持久化的能力)、交换器到队列、到了队列数据已经持久化。中间某个时刻mq挂了,都可能出现消息丢失。
还有一种情况,我们一般会在消费端用@RabbitListener注解自动创建队列,给队列绑定交换机(或者用@Bean的方式)。最开始项目初始化阶段(刚上线),如果消费端没有启动,生产者生产消息,调用api是程序是不会报错的。这个时候队列什么都没有,你send的消息就丢失了。===>这点我已经尝试,当然我们可以手动创建。
这两种情况都有可能导致数据丢失。
这是极端中的极端吧,例如服务器的磁盘损坏(遇到了就是运气爆表,但是也有公司发生过)。或者那个程序员、运维啥的不小心将持久化的数据给误删了。
这种情况,可能造成大量数据丢失了。一般而言,我们考虑在生产端在数据同步给mq的时候,先将这些数据备份一份。
如果我们采用了定时任务补偿机制,可以用补偿机制解决。
rabbitMq默认的情况下,是自动签收的。也就是你从消费端取了数据,这条数据就从对应的队列中删了。如果没有定时任务的补偿机制,就一定要加(你不能保证你的服务不挂,你的数据不挂,或者你的代码出现其他的异常),出现一点问题,数据就丢失了。
每个版本的客户值可能不同,我们可以自己在消费端设置这个值
每个服务器的机器配置和性能是不同的,rabbitMQ默认是采用轮询模式
。假设两个消费者A,B,100条消息,就会各50条。
各50条,公平呀,你五十,我五十,对半分(你是一个初级程序员,让你和高级程序员任务对半分,你心里舒服吗,你累死累活还没做一半,人家做完玩了好几天了)。这个时候就要按劳分配了,谁先处理完谁就去领任务,能者多劳。
有一段下面的伪代码:
//步骤一:插入数据
//步骤二:记录日志
//步骤三:更新状态
//没有报错,手动签收
我们来考虑下面几种情况:
1.开启异常重试。
假设我们在代码中突然执行到3,更新数据的时候,更新状态的数据库突然出现问题了。后边异常重试的时候,数据库又好了,插入操作就执行两次,我们应该采取措施,避免保证同一个消息,多次消费,造成重复操作(例如:增加一个消息的标识,消费了就不再插入了)
2.手动签收
当步骤3报错,这个消息就没有手动签收,这条消息依然会存在消息队列中,重新投递(当然这里我们指定报错后的操作,例如:重回队列让其他消费者消费、将消息移除队列、消息确认等,报错了怎么处理需按钮业务处理)
3.定时任务补偿
服务A的同步状态是false,那么就会隔一段时间,再次将消息投递给消息中间件。步骤三的操作的数据库头次失败,后边这个同一条数据又会来。
首先我们应该将线程池一些主要参数设置合理,比如核心线程数量、总线程数量、队列大小(默认int的最大值)等,这些参数并不是一次就能设置好,我们的结合生产环境,观察线程池的拒绝率、线程利用率、堆积任务数量等设置合理的参数大小(每个机器的配置可能不一样,要具体机器具体分析),尽可能的扩大机器的利用率,也不导致服务跌机(例如不设置队列大小,一下来了过多任务,导致泄露)
其次必须知道一个知识点:线程池的拒绝策略
( 默认的情况下,是拒绝抛出异常的策略)(可以看java.util.concurrent.RejectedExecutionHandler的实现类)。当任务超过了线程池的限定,就会对任务拒绝。拒绝了,我们是交给调用的线程处理(这种不错一般哟,不会丢弃线程任务),还是丢弃旧的任务,还是丢弃新来的任务,要不要抛出拒绝异常?我们都得考虑清楚。
假设用户保存数据,同步到服务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(){....}
锁的粒度太大,锁住了无关的线程,影响了程序的效率。
首先,我们得知道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等都存在这个问题,我们都应考虑到无序的聚集索引树重构给我们带来的问题。
如果你真的把数据删了,又会出现树重构的问题。不管是对数据库,还是我们程序效率都不友好。我们可以加一个删除状态,删除只更新状态。数据如果真的要删除,我们可以半夜空闲的时候用定时任务删。
在服务A中,用户每次保存的时候都会将同步状态设置false。我们同步成功后,就去改变状态值。这种操作用到索引的情况下一般更新很快,我们可以直接远程调用更新,如果使用中间件又增加程序的难度。
备份需要同步的数据可以解决两点:
1.mq磁盘数据丢失数据的问题
2.其他各种异常,导致数据没有到服务B,定时服务定时调用服务A同步数据,让服务A有数据可以同步的问题。
对于这部分已经同步成功的数据,如果业务已经不需要,我们可以隔断时间迁移备份或者定时删除。
数据同步的时候,我们考虑到各种异常(代码异常、机器故障),出现异常会不会对我们同步数据造成影响。说到底就是保证数据同步的准确性,用定时任务的补偿机制,我们能保证数据最终一致性
。
在保证数据准确的同时,还要考虑程序效率,给与用户友好的体验。
在使用新的技术,都需要知道其中的坑。就像使用mq:可能会造成数据丢失,数据重复消费的问题。使用多线程,你得保证数据的有序性,存在并发修改同一条数据的时候,你不得不加锁(单机使用cas或者Lock,synchronized),加锁也能把控锁的粒度。