一致性问题

这是一篇关于 一致性 问题的文章。

一致性问题

缓存一致性

缓存一致性指的是数据库缓存数据的一致性。也就是说,我们要保证数据库中的数据和缓存中的数据是一致的。

先更新数据库再更新缓存

假设有两个线程同时进行更新操作(A想把数值修改为1,B想把数值修改为2),线程 A 先更新数据库(此时数据库变为 1),线程 B 再更新数据库(数据库变成 2)。但是由于 A 的延迟,导致 B 先更新缓存(缓存为 2),A 后更新缓存(缓存为 1)。最终的结果就是数据库的值为 2,而缓存的值为 1,二者数据不一致。

先更新缓存再更新数据库

还是假设两个线程同时进行更新操作,线程 A 先更新缓存(缓存变为 1),线程 B 再更新缓存(缓存变为 2)。A 还是慢了一步,导致 B 先更新数据库(数据库变为 2),A 后更新数据库(数据库变为 1)。最终的结果就是数据库的值为 1,而缓存的值为 2,这也会出现数据不一致的问题。

我们由此可以看出,只要是对缓存进行更新(而不是删除),就很容易出现数据不一致的问题。但是好处就是可以保证缓存的命中率,提高系统的响应速度,减小数据库的负担。那么在缓存命中率有要求的场景下,我们该如何处理缓存不一致的问题呢?那么我们可以引入分布式锁,保证同一时间只有一个线程对缓存进行更新。

Cache Aside 策略,又叫旁路缓存策略。在这种策略之下,我们不会选择更新缓存,而是直接删除缓存,这样我们在下次访问数据库的时候,就可以从数据库中获取最新的值,从而加载到缓存中。

先删除缓存再更新数据库

两个线程 A 进行更新操作(假设数值从1变成2),B 进行查询操作。A 先删除缓存,B 再查询缓存,此时缓存未命中为空,B 去数据库查询,得到的是 1,并将 1 写入缓存。之后 A 再更新数据库,数据库变为 2。那么最终结果就是,数据库的值为 2,而缓存的值为 1,二者数据不一致。

先更新数据库再删除缓存

两个线程 A 进行更新操作(假设数值从1变成2),B 进行查询操作。A 先更新数据库,数据库变为 2,然后 B 在此时查询缓存,得到的是 1,不会进一步查询到数据库中。之后 A 再删除缓存,那么之后如果还有查询,就会去数据库得到 2 的结果,并将 2 写入缓存。那么最终结果虽然出现了串行性的问题,但是数据库的值为 2,而缓存的值为 2,二者数据一致。

但是这个策略还是有漏洞的。比如 A 先进行读取操作,假设缓存中是没有数据的,那么 A 会去数据库中查询,得到的是 1,接下来 A 就要更新缓存,但就在此时出现了延迟。因此 B 进行写入操作,先将数据库中的 1 改为 2,然后再删除缓存。在 B 删除完之后,A 才进行了缓存的填入。此时缓存为 1,而数据库为 2,这就出现了数据不一致的问题。

但仔细想想这种策略其实已经最大程度避免了不一致的情况,因为对于缓存的写入一般远快于对数据库的操作。所以,它仍然还是我们最常用的策略。

延时双删

这个是为了处理 “先删除缓存再更新数据库” 的不一致问题而引入的策略。我们在对数据进行更新的时候,我们先删除缓存,再更新数据库,最后延迟一段时间再删除一次缓存。

这样做的好处是为了确保写请求 A 在睡眠的时候,读请求 B 能够在这这一段时间完成从数据库读取数据,再把缺失的缓存写入缓存的操作,然后请求 A 睡眠完,再删除缓存。以此达到缓存和数据库数据的一致性,非常的稳。

当然睡眠或者说延迟的时间也大有讲究,这个时间 tt 要符合 t睡眠t从数据库的读取+t缓存的写入t_{睡眠} ≥ t_{从数据库的读取} + t_{缓存的写入},一般的经验值是在100ms到1000ms左右。

但也由于这个延迟时间的存在,使其不适合在高并发的情况下使用。

保证更新数据库和删除缓存的可用性

如果我们仅仅是更新数据库,但删除缓存的操作失败了,就会导致缓存中的数据为旧值,所以我们在操作的时候,得保证两个操作都执行了。

我们可以利用消息队列的重试机制,将第二个操作加入到消息队列当中。如果操作失败,可以从消息队列当中重新读取数据,再次尝试删除缓存;如果成功删除,就可以将这个请求从消息队列中移除,避免重复操作。

分布式系统一致性问题

CAP 定理

  1. Consistency(一致性):所有节点在同一时间的数据完全相同。即各自节点拥有同样内容的数据副本,达到一致的状态。
  2. Availability(可用性):每一个请求都能在有限的时间内得到响应。
  3. Partition Tolerance(分区容错性):系统能容忍网络分区。

而分布式系统往往需要在一致性和可用性之间做出取舍。

// TODO: 这个可以等之后深入了解分布式系统再补充

事务一致性

事务是用户定义的一个操作序列,它要么全部执行成功,要么全部执行失败,是不可分割的工作单位。

在数据库事务中,也有着 ACID 原则,这个要和分布式中的 ACID 原则区分开来。分布式系统中的一致性是指数据副本的一致性模型,而数据库事务中的一致性是指事务执行过程中数据库本身的一致性状态。

ACID 原则

  1. Atomicity(原子性):事务是一个不可分割的工作单位,事务中包括的诸操作要么都做,要么都不做。
  2. Consistency(一致性):事务必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的。即事务的执行不会破坏数据库预定义的一致性约束。
  3. Isolation(隔离性):一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
  4. Durability(持久性):持续性也称永久性,指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。

异常现象

  • 脏读:一个事务读到了另一个事务未提交的数据。比如事务a写,事务b读到了事务a的写,然后事务a回滚,这时候事务b读到的就是脏数据。因为要保证串行执行的外显要求,事务b应该读到原先的结果,因为事务a的修改并没有生效。

  • 不可重复读:一个事务在同一行记录上读取两次,第二次读取的结果和第一次读取的结果不同。比如事务a读,事务b写,事务a再读,事务a的两次读取结果不同。而按照串行执行的要求,一个事务独立地两次读取应该结果是一致才对。

  • 幻读:一个事务在同一范围内读取到其他事务插入的数据。事务a在范围内查询,事务b在范围内插入新的数据,事务a再次查询时,会发现多了一些新增的数据。比如事务a进行全表的操作,事务b在范围内插入新的数据,事务a再次查询时,会发现多了一些新增的数据。

隔离级别

MySQL默认的隔离级别是可重复读(REPEATABLE READ)。

表中从上到下,隔离等级依次上升,隔离性越强,并发性越低,也就是效率越低。

隔离级别脏读(Dirty Read)不可重复读(Non-Repeatable Read)幻读(Phantom Read)
未提交读(Read Uncommitted)可能可能可能
提交读(Read Committed)不可能可能可能
可重复读(Repeatable Read)不可能不可能可能
串行化(Serializable)不可能不可能不可能

MySQL的Serializable属于纯两阶段锁实现,所有DML都使用当前读,都读最新版本,读写都加锁,使用gap锁,锁都到最后再放。

MySQL的Repeatable Read在Serializable的基础上,放松了对select的限制,select(非for update、非in share mode)使用快照读,其它场景则与Serializable相同。

MVCC

MVCC 多版本并发控制,算是一种乐观锁。一个数据库对象有多个不同的版本,每个版本关联一个时间戳,当事务在访问数据的时候,会使用快照选出合适的版本,使得读写不会相互堵塞。