※ 好歹赶上了20世纪20年代的第一天发第一篇文章~


文章目录

  • 一、大前提
  • 1-1、场景
  • 1-2、是否强一致
  • 1-3、套路永不过时
  • 二、数据强一致的应对
  • 三、套路一:Cache Aside
  • 3-1、Cache Aside
  • 3-2、CPU缓存的 Write Through
  • 四、套路二:Write back
  • 4-1、CPU缓存的 Write back
  • 4-2、Redis 的 Write back
  • 5、套路三:主从复制



关于如何做Redis与MySQL的数据同步,网上有非常多的解决方案。

下面主要说一下我关于这件事的一点想法,以及简要的实现思路。

一、大前提

1-1、场景

出技术方案,必须要有对应的业务场景。

方案具体到业务才有意义,有的业务在秒级别上存在脏数据都没有影响,而有的涉及到钱的业务必须数据强一致,不容丝毫妥协,那么该上锁就上锁不要犹豫。

网上有太多的抛开业务场景,给一个大一统的完美方案。有一个算一个,都是耍流氓。

1-2、是否强一致

继续上一节的话题,抛开业务执意要求数据强一致的就是自己给自己挖坑。

但是总归有强一致的需要发生的时候。

那么这时心里必须清楚,强一致——换句话说就是一致性,与高可用性、性能是永远不可兼得的。

Redis与MySQL是两个独立的存储系统,我们姑且可以将其看做是一套分布式系统。

那么运用分布式系统的CAP理论来理解这件事就是顺理成章的。

  • C:Consistency,一致性, 数据一致更新,所有数据变动都是同步的。
  • A:Availability,可用性, 好的响应性能,每次请求都能获取到非错的响应——但是不保证获取的数据为最新数据。
  • P:Partition tolerance,分区容错性,就是可靠性。以实际效果而言,相当于对通信的时限要求。

对于一个分布式系统来说,P是前提条件,系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。

最经典的就是用于服务治理的ZooKeeper和Eureka,ZooKeeper保证CP,而Eureka选择了AP。

同理,在设计数据同步方案时,必须有这个意识,适当做出取舍。

1-3、套路永不过时

网上有人提出“删缓存 → 更新数据库 → 读没有命中时会读库写缓存”的方案。然而这种方案没有考虑更新和读的并发性问题,发生下面这种并发操作时会出现脏数据。

  1. 更新操作删缓存
  2. 读操作没有命中
  3. 读操作从库中读到了旧数据,放入缓存
  4. 更新操作更新了数据库

由于写操作通常比读要慢,所以出现这种问题的概率还是挺大的。

仔细想一下,我们要解决的问题,只是因为有了缓存而带来的问题。

这种事情,有必要费尽脑汁自己造轮子吗?

现代计算机系统中,哪儿没有缓存啊?

内存里有缓存,各类文件系统都有缓存。那么它们也必然面临数据同步的问题。那就把人家的解决方案拿过来用呗。

许多基础设计和思想都是想通的,而且这些基础架构经历了长期的历练,遵从就好了。

我们完全可以把已有的各种现成的套路拿过来直接用在我们的工程实践中。最多就是根据自己的业务需求稍加变化。

二、数据强一致的应对

这个基本没啥好说的了,上分布式锁吧,写数据的时候直接阻塞读操作,就像是SQL的“select xxx for update”一样。

难点在于根据实际情况权衡锁的失效时间。失效时间太长,万一写操作出了什么意外,大量的读操作被阻塞,弄不好连线程池也崩掉;失效时间太短,写操作还没完成,读操作就被开门放了进来,取到的是旧数据。

缺点当然很明显,高并发的时候一定会出现大量的读超时,严重降低了吞吐量。

同时还要考虑分布式事务问题,比如使用“两阶段提交协议”(prepare, commit/rollback)。还需要注意Redis是不支持事务回滚的,必须手工处理。

三、套路一:Cache Aside

3-1、Cache Aside

在没有数据强一致要求的场景下,我们需要的是一个尽可能同时兼顾一致性和可用性的方案。

优先推荐facebook提出的“Cache Aside”模式。


这种方案的处理逻辑很简单:

  • 前提:缓存中的数据需要设定失效时间。
  • 命中:从缓存取数据,取到返回。
  • 失效:从缓存取数据,没有得到,则从数据库中取数据,成功后,将值写入缓存,同时返回结果。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

这里最核心的思路是更新数据库成功以后,再让缓存失效。

之所以是让缓存失效,而不是直接更新缓存,是为了防止两个并发的写操作带来脏数据(先发起的写操作最后完成,覆盖了后发起的写操作写入的最新数据)。

这个方案并不是100%保险的。

发生下面这种并发操作时同样会出现脏数据。

  1. 读操作没有命中
  2. 读操作从库中读到了旧数据
  3. 更新操作更新了数据库
  4. 更新操作让缓存失效
  5. 读操作将旧数据写入缓存

这种情况理论上存在,但是请注意其发生的概率极低,低到可以忽略不计。

为什么呢?因为通常情况下写操作慢呀(要锁表的),上面的第3和第4步几乎不可能插入到一个读操作中间并且先行完成。而且还要加上一个缓存刚好失效的时机点。

退一步说,就算你人品衰到极点,真发生了这种情况,我们不是还设置了数据的失效时间么。这一轮的失效时间一到,新数据还是会被刷到缓存里,最终一致性是一定可以保证的。

3-2、CPU缓存的 Write Through

我们来顺便看一下CPU的缓存是怎么实现与主内存的同步的。

其中的一种回写策略叫做“Write through”(写通),wiki中对于写通是这样解释的:

写通是指,每当缓存接收到写数据指令,都直接将数据写回到内存。如果此数据地址也在缓存中,则必须同时更新缓存。

这里写的是“同时更新缓存”,但实际上的操作是进行了类似标记的操作,让其他CPU核所属的缓存失效。

所以,facebook的这个“Cache Aside”模式,与“Write through”没有什么本质区别。套路就是套路,一种套路大家都在用,那就必定是真理啦。

四、套路二:Write back

4-1、CPU缓存的 Write back

还是准备用CPU缓存来类比。CPU缓存的数据同步,除了上面的Write through,另一种模式就是“Write back”(写回)。

写回是指,仅当一个缓存块需要被替换回内存时,才将其内容写入内存。如果缓存命中,则总是不用更新内存。为了减少内存写操作,缓存块通常还设有一个脏位(dirty bit),用以标识该块在被载入之后是否发生过更新。如果一个缓存块在被置换回内存之前从未被写入过,则可以免去回写操作。

用人话来解释一下,就是应用方直接读写缓存,不理会内存。至于缓存的数据何时以及如何落地到内存中,由另外的机制来保证。

4-2、Redis 的 Write back

回到 Redis 与 MySQL 上来。

其思路是,应用方眼里只有Redis,读写都只操作Redis,把Redis当做唯一的存储层。

写到Redis上的数据,由额外的线程以各种异步方式写到数据库上进行持久化。

可以模仿缓存,给每一个数据增加类似dirty bit的标识,减少写库次数;同时这样的写库方法,还可以天然合并对同一个数据的多次写操作。

这种方案的一个比较明显的问题,是数据同步延迟可能比较大,而且Redis崩掉的话,即使Redis本身有持久化机制,也有可能会丢失少量数据。

但是该方案有一个很合适的应用场景,就是秒杀。

秒杀时,OLTP系统只操作内存而不操作数据库,那磁盘IO的瓶颈就不复存在了,快到飞起。

此时在OLAP系统上,对于数据的统计和分析延迟个几分钟在大多数情况下也不是什么大事,完全可以接受。

OLTP = On-Line Transaction Processing

OLAP = On-Line Analytical Processing

当然了,坏处同样是显而易见的,实现逻辑过于复杂,OLTP要怎么自己手动做事务回滚,持久化线程怎么做优化,高并发下要考虑的各种问题等等。

另外,在这种秒杀的场景下,我们可以认为需要缓存的业务数据都是OLTP自己写入的,OLTP不需要从MySQL中获取信息;那么我们就完全可以不给Redis中的数据设置失效时间,使其永远生效,不会发生从MySQL中读数据再写入Redis的操作。

万一(啥事应该都有这个万一吧),真的需要从数据库拿数据,那继续沿用最基本的套路,设置失效时间,未命中时从数据库中直接拿数据也完全没问题。

5、套路三:主从复制

另外一种套路呢,是模拟MySQL的主从同步。

不设置失效时间,搞一个监控MySQL的binlog的中间件,一旦监测到有数据变化,判断一下是否需要写入Redis,然后进行写操作。

这种方式增加了系统复杂度(追加中间件),从实现逻辑上来说其实也没简化太多,个人感觉费效比不是太高。

不过也是一种挺有意思的思路,记录一下。