分布式系统中如何保证数据的一致性
在分布式环境下,无法做到绝对的、实时的强一致性,只能追求最终一致性。 我们的目标是设计一个可靠、高效的方案,将不一致的时间窗口尽可能缩短,并对业务的影响降到最低。
博主博客
一、强一致性与最终一致性的区别
1. 核心比喻:银行转账 vs. 社交媒体点赞
-
强一致性:像银行转账。
- 你从A账户转100元到B账户。
- 操作完成后,任何人(包括你、收款人、银行柜员)在任何地方(ATM、手机App、网上银行)查询A和B的余额,看到的结果都必须是转账后的状态。
- 特点:数据变更是“原子”的,所有观察者看到的是同一时刻的数据快照。
-
最终一致性:像社交媒体点赞。
- 你给一条微博点了个赞,你的手机上立刻显示了“已赞”。
- 但你的朋友可能过了一两秒钟,甚至更长时间,才在他的手机上看到点赞数更新。
- 特点:数据变更后,系统会存在一个不一致的窗口期,但最终(几毫秒或几秒后)所有观察者都会看到一致的结果。
2. 详细对比
| 特性 | 强一致性 | 最终一致性 |
|---|---|---|
| 核心思想 | 数据更新后,任何后续的访问都会返回最新的值。 | 数据更新后,系统保证在没有新更新的情况下,最终所有访问都会返回最新的值。 |
| 一致性强度 | 最强。读写操作是线性化的,感觉像在操作一个单点数据副本。 | 弱。存在一个不一致窗口。 |
| 性能与延迟 | 低。因为需要同步阻塞,等待所有副本更新完成,才能返回结果给客户端。 | 高。写入操作完成后立即返回,副本在后台异步同步,响应速度快。 |
| 可用性 | 低。如果某个数据副本不可用(如网络分区),写操作可能会失败,以保证一致性。 | 高。即使部分副本不可用,只要有一个副本可用,写操作通常也能成功,系统继续服务。 |
| 适用场景 | 金融系统(转账、扣款)、库存管理(超卖问题)、关键配置信息等。 | 社交媒体(点赞、评论计数)、DNS系统、网页缓存、用户会话信息等。 |
| 对开发者的影响 | 对应用开发者友好,可以像编写单机程序一样思考,无需处理中间状态。 | 对应用开发者有挑战,必须意识到并处理数据可能暂时不一致的情况(例如,读己之写问题)。 |
3. 背后的理论:CAP定理
这个区别源于著名的CAP定理,它指出一个分布式系统不可能同时满足以下三点:
- Consistency(一致性):等同于这里的强一致性。
- Availability(可用性):每次请求都能获得响应。
- Partition tolerance(分区容错性):系统能容忍网络分区(脑裂)的发生。
在分布式系统中,P(网络分区)是必然要面对的,所以我们通常需要在 C(强一致性)和 A(高可用性)之间做出权衡。
- 选择 CP:要保证强一致性,当网络出现问题时,就需要停止服务(牺牲可用性)。例如:ZooKeeper, etcd。
- 选择 AP:要保证高可用性,当网络出现问题时,允许数据暂时不一致(牺牲强一致性)。例如:Cassandra, DynamoDB。
最终一致性是 AP 系统追求的一种松弛的一致性模型。
4. 最终一致性的变种
最终一致性本身是一个大类,为了应对不同的业务场景,衍生出了一些更强的一致性保证:
-
读写一致性
- 保证:用户总能读到他自己刚提交的更新。
- 例子:发完微博后,刷新自己的页面,肯定能看到刚发的微博。但其他用户可能稍后才能看到。
-
单调读一致性
- 保证:用户不会看到数据“时光倒流”。如果他一次读到了较新的数据,后续的读取不会回到旧数据。
- 例子:用户第一次刷新看到了新评论,第二次刷新时绝不会看不到这条评论。
-
因果一致性
- 保证:有因果关系的写操作,所有节点都必须以相同的顺序看到。
- 例子:在论坛中,回复帖子(果)必须发生在看到原帖(因)之后,所有用户看到的顺序都必须是先原帖,后回复。
- 强一致性是关于 “现在” 的保证,它牺牲了性能和可用性,换来了数据的即时准确。
- 最终一致性是关于 “最终” 的保证,它用数据的短暂延迟和可能的不一致,换来了极高的性能和可用性。
以下是几种常见的方案,从简单到复杂,各有其适用场景。
二、缓存读写策略:Cache Aside Pattern (旁路缓存策略)
这是最常用、最成熟的策略,其他策略都是在此基础上衍生。它非常直观,分为读操作和写操作。
读操作 (Read)
- 应用程序读取 Redis。
- 如果 Redis 中有数据(缓存命中),则直接返回。
- 如果 Redis 中没有数据(缓存未命中),则从 MySQL 中读取。
- 从 MySQL 读取到数据后,将数据写入 Redis,然后返回数据。
写操作 (Update/Write)
- 应用程序先更新 MySQL 数据库。
- 然后删除 (Delete) Redis 中对应的缓存数据。
为什么是删除(Delete)而不是更新(Update)缓存?
- 避免不必要的更新: 写操作可能很频繁,但数据之后不一定被立即读取。删除操作是幂等的,而多次更新缓存可能是浪费资源的。
- 解决并发问题:
- 场景: 两个并发写操作,写操作A和写操作B。
- 错误时序(先更新DB再更新Cache): A更新DB -> B更新DB -> B更新Cache -> A更新Cache。导致缓存中是A的旧数据,数据库中是B的新数据,数据不一致。
- 正确时序(先更新DB再删除Cache): 即使A更新DB后删除Cache,B更新DB后删除Cache,最终缓存会被删除。下次读请求会从DB加载最新数据。虽然中间可能有短暂不一致,但最终会一致。
Cache Aside 的缺点与潜在问题
- 首次请求数据肯定不在缓存中。 解决方案:可以提前预热热点数据。
- 缓存失效时的高并发问题 (缓存击穿):
- 场景: 某个热点数据缓存失效,瞬间有大量请求发现缓存不存在,同时去访问数据库,造成数据库压力激增。
- 解决方案: 使用互斥锁(Mutex Lock)或分布式锁。只允许一个请求去数据库查询并重建缓存,其他请求等待锁释放后重新读取缓存。
- 读写并发导致的不一致 (极端情况):
- 场景: 读请求A未命中缓存,去数据库读数据;此时一个写请求B完成:更新了数据库并删除了缓存;然后读请求A将读到的旧数据写入了缓存。
- 结果: 缓存中变成了旧数据,数据库是新数据。
- 概率: 这个时序非常苛刻,因为数据库写操作通常比读操作慢(需要加锁等),所以读请求A在写请求B之后才设置缓存的概率较低。
- 解决方案:
- 设置较短的缓存过期时间: 即使发生不一致,数据也会很快过期,实现最终一致。这是最常用的方案。
- 使用异步延迟删除: 写操作删除缓存后,延迟一小段时间(比如几百毫秒)再次删除缓存,以清理可能由并发读请求设置的脏数据。
三、写策略的变种
Write Through (穿透写)
- 应用先写缓存(Redis),然后由缓存组件自己去同步更新数据库(MySQL)。
- 应用不直接操作数据库。
- 优点: 缓存和数据库的更新由缓存层保证原子性,对应用透明。
- 缺点: 实现复杂,需要缓存组件支持。且每次写操作都要更新缓存和数据库,性能开销大,除非写操作很少。
Write Behind (异步回写)
- 应用只更新缓存(Redis),然后就返回成功。
- 缓存组件异步地、批量地将数据更新到数据库(MySQL)。
- 优点: 写性能极高(I/O非常快)。
- 缺点: 有数据丢失风险(如果缓存宕机,未持久化的数据就丢了)。只能保证最终一致性,延迟可能很高。实现非常复杂。
注意: Write Through 和 Write Behind 通常需要特定的缓存组件支持(如 Ehcache),在 Redis + MySQL 的自主架构中不常用。
四、通过中间件保证最终一致性
对于一致性要求非常高、业务复杂的场景,可以采用更重但更可靠的方案。
基于 Binlog 的异步同步 (推荐)
这是目前最主流、最可靠的方案。让MySQL的增量日志Binlog来驱动缓存的更新。
- 组件: 使用 Canal、Debezium 等中间件,伪装成 MySQL 的从库(Slave)。
- 过程:
- 中间件读取 MySQL 的 Binlog。
- 解析 Binlog,获取哪些数据发生了变更。
- 然后向 Redis 发送命令(删除或更新对应的缓存)。
- 优点:
- 彻底解耦: 应用程序不再需要关心缓存删除的逻辑,只需要写数据库即可。
- 高性能: 对应用性能零影响。
- 高可靠: MySQL Binlog 是主从复制的基础,保证了数据读取的可靠性。
- 实时性: 异步延迟很低,通常在毫秒级别。
- 缺点:
- 系统架构变得更复杂,需要维护 Canal 等中间件。
五、总结与选择建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Cache Aside | 成熟、简单、高效 | 存在极端的并发问题,需处理缓存击穿 | 绝大多数场景的首选。配合设置缓存过期时间和互斥锁使用。 |
| Write Through | 应用层逻辑简单,缓存与DB强一致 | 实现复杂,写性能差 | 写操作极少,对一致性要求极高的场景。 |
| Write Behind | 写性能极高 | 实现复杂,有数据丢失风险 | 适用于大量写入、能容忍数据丢失的场景(如点赞数、浏览量)。 |
| Binlog 同步 | 可靠、解耦、对应用无侵入 | 架构复杂,需要维护中间件 | 大型、高并发项目,对一致性要求高,且有能力维护复杂架构的团队。 |
给你的实践建议:
-
起步阶段: 直接使用 Cache Aside Pattern。
- 写操作:先更新数据库,再删除缓存。
- 读操作:先读缓存, miss 后读数据库再回填缓存。
- 一定要为缓存设置合理的过期时间(比如 30 分钟),这是保证最终一致性的最后防线。
- 对热点数据,使用互斥锁防止缓存击穿。
-
成长阶段: 当系统变得庞大,团队有能力时,引入 Canal 监听 Binlog 的方案来异步失效缓存,将应用业务逻辑和缓存维护逻辑彻底解耦。
-
始终牢记: 目标是最终一致性,而不是强一致性。根据你的业务容忍度(例如,用户信息可以接受几秒内不一致吗?)来选择和调整你的技术方案。没有完美的方案,只有最适合当前业务和团队的技术选型。
评论