数据同步问题是应用层非常值得研究的一个设计问题,也是为数不多值得研究的数据问题。虽然在实践中各种方案会运行的很好,但是这些方案背后有哪些假设,以及问题的模型是什么,这个点并没有得到充分理解,所以本文以经验之谈分析了下数据同步的设计问题,案例分析以及结论。
数据同步的设计问题
数据同步问题是将一个地方数据,经过计算/不计算挪动到另外一个地方,在这个过程中,会面临三个问题:
- 如何同步?机器是否跨网络,是否单机,策略是双写或者是异步写?同步的是指令还是数据?双写中并发写问题如何解决?
- 数据形态是什么?副本,缓存(缓存是一种特殊的副本),副本形态可能高写入,缓存形态一般高读取。
- 谁来解决这个问题?应用层,还是中间件层。发起同步的进程还是后台进程?
这些问题抽象为:多节点(同构或异构)数据复制带来的一致性问题。而这样问题也会受到CAP或者Jepsen中一致性的等级所制约。
我们来依次看看这些问题。如何同步的问题抽象是写入保证一致性,而跨网络抽象成复制问题,单机会抽象成单进程/线程读写问题,一个地方移动到另外一个地方,复制有主动/被动,有异步和同步,地点可以是内存到磁盘,可以是Redis到MySQL,可以是内存到Redis,数据的形态有副本,有缓存,不同的形态数据使用场景不同,带来的复制更新问题也不同,如缓存会有一系列缓存淘汰算法或者缓存更新策略(通读,通写,后写),这个过程相对主动,谁来解决这个问题,影响了系统的实现。
如果在单机情况下,那么要保证操作同时成功,或者同时失败,数据能保证一致性,那么单机系统中最完美的抽象是事务。事务的ACID特性完美解决了一系列问题,如并发控制和恢复系统。
在分布式场景下,由于CAP/PACELC的限制,会导致数据同步(复制策略)不同,导致了不同的一致性级别。
但是面对异构系统,且跨网络的模型,原子广播协议或者分布式事务可以解决此类问题,但是谁来解决跨系统分布式事务呢?据我所知,暂时没有这样机制。即使XA模型很早提出了,Java领域也有JTA模型,但是实际系统中很少使用,因为性能或者易用性的问题。所以双写问题带来的固然局限性,因为处理异常情况非常复杂,业内也没有非常成熟的实践。
案例分析
案例有同构和异构系统的一致性总结。以及谁来解决这个问题来进行总结。
系统 | 如何同步 | 数据形态 | 解决层 | CAP |
---|---|---|---|---|
Redis 主从 | 异步复制 | 副本 | Redis | AP |
Redis和应用内存 | 双写,异步写 | 缓存 | 应用层 | 可选 |
操作系统的内存和磁盘 | 双写 | 缓存 | 操作系统层 | CA |
Redis和MySQL | 双写,异步写 | 缓存 | 应用层 | 可选 |
MySQL主从 | 异步复制 | 副本 | MySQL | 异步AP 同步CP |
Zookeeper | ZAB协议 | 副本 | Zookeeper | CP |
Etcd | Raft协议 | 副本 | Etcd | CP |
MySQL 内存和磁盘 | 事务 | 缓存 | MySQL | CA |
Redis和MySQL同步分析
同步为什么很困难?1 网络的引入导致成功,失败,不可知状态。 2 谁来解决问题,还是返回给操作方?如果没有操作方是进程呢? 3 在同构系统同步已经很复杂但成熟,异构系统更加复杂且不成熟。4 更新操作的顺序考虑。
如何简化问题?忽略网络,操作方解决。
虽然问题分析比较有意思,但是方案来说有:
- 同步:cache-aside(缓存驻留),read through(通读),write through(通写)等。
- 异步:单机内存队列(Sticky Available),消息中间件(请求定序),write behind(后写),Refresh ahead(预加载)等。
这里重点看看cache-aside模式(方案1是缓存驻留模式,其他都是对比方案)的写入方案,也是理解同步问题的起点,下面有四种模式(假设没有发生网络异常):
1 | //方案1 |
方案1,先更新数据库,后删除缓存。这个方案是常用的方案。但是会有极小概率出现这种情况:读操作没有命中缓存,会去数据库取数据,此时写请求进入,写完数据库,然后缓存失效,之前的读请求再把老数据放进去,会导致脏数据问题。这个情况理论上会出现,但是概率非常小,需要满足条件有:发生在读缓存时失效,且有并发写入,数据库写比读慢很多,所以读操作必须在写操作前进去数据库,而晚于写操作更新缓存。
方案2,先删除缓存,后更新数据库。可能很短时间内,新的请求会读数据,然后发现缓存没有数据,就会设置缓存,导致缓存有脏数据。缓存宁愿没数据,也不要脏数据。这个概率发生要比1高很多。因为缓存模式就是读多血少。
方案3,先更新数据库,后更新缓存。由于缓存构建可能很慢,不是简单从数据库中读取出来,更新缓存可能会非常慢,不如直接删除缓存,在下次加载时候重新计算,更是因为并发写会导致脏数据问题。因为第二个写入可能会被第一个写入覆盖了。
方案4,先更新缓存,后更新数据库。理由同方案3。从效果上看该方案和通写一样,数据更新没有命中缓存,那么直接更新数据,如果命中缓存,那么更更新缓存,然后Cache自己更新数据库。
以上四种方案本质是同步方案,而没有考虑网络异常,如果网络异常导致某一个节点不可用,就会导致同步失败(为了一致性而牺牲了可用性),此时应用层有感知,虽然处理这种失败可以重试,但是如果节点是宕机呢?数据可以暂时存在本地内存队列中,等节点启动后进行同步,而要保证下次请求读到上次写入的数据,那么需要满足Jepsen中Read Your Writes的一致性,但是这种一致性下可用性是Sticky Available,也就是会话一致性,需要将请求定位到同一台机器,但是如果该机器宕机了,那么内存数据会丢失。完美的方案是引入事务模型来解决同时成功或者同时失败从而使得开发者忽略一切底层细节,但是代价很高且缺少分布式事务方案可以直接来用,所以引入异步方案也是一种方式,异步更新时效性没有同步那么及时,但是需要解决的问题较少,可以进行后台定时同步(类似于反熵过程),或者消息队列进行CDC或者定序(分布式提交日志)等操作进行同步,较为复杂场景是MySQL主从和Redis主从进行同步,如果是读MySQL从,而后写入Redis主,那么数据也会不一样,但是需要理解Redis对于MySQL数据形态是什么?如果Redis当做缓存,那么会有一些列缓存更新问题出现,但是缓存使用必然会带来不一致性,只是能够接受多久的不一致性时间窗口。
每一个种选择都会带来对应的问题,而需要仔细衡量解决问题的成本。没有完美的方案,只有削足适履的选择!
结论
本文将数据同步问题抽象成多节点数据复制带来的一致性问题。
所以上表可以得出结论:
- 同构的系统的同步,采用共识协议或者健壮的复制,且由系统自己解决的方案占据大多数,
- 异构系统的同步,同步或者异步写都有可能,但是需要解决各自方案带来的问题,如同步需要保证同时成功,同时失败问题,而异步写需要解决系统间一致性延时问题。双写会在不同数据形态会展示不同的策略,如果数据形态是缓存,那么双写策略有通写,或者后写等缓存更新策略,还会有缓存淘汰算法等策略。一般由应用层解决较多。
- 实际工程中:同步的cache-aside,通读,异步Kafka方案,或者广播消息方式,定时任务刷新是解决这类问题的有效方案。
参考
- 耗子叔 极客时间-性能设计篇之-缓存
- 微软:云计算架构设计模式 Cache aside
- https://docs.aws.amazon.com/whitepapers/latest/database-caching-strategies-using-redis/caching-patterns.html
- Ehcache:https://www.ehcache.org/documentation/3.3/caching-patterns.html
- 耗子叔-缓存更新的套路 https://coolshell.cn/articles/17416.html
- DDIA第十一章:流处理 https://github.com/Vonng/ddia/blob/master/ch11.md
- Hazelcast Caching Patterns:https://hazelcast.com/blog/a-hitchhikers-guide-to-caching-patterns/
- Cache:https://en.wikipedia.org/wiki/Cache_(computing)
- Cache stampede:https://en.wikipedia.org/wiki/Cache_stampede