跟Redis的数据一致性保证
# 跟Redis的数据一致性保证
# 数据一致性的诞生
Redis是一个内存数据库,它的数据来自mysql,它的目的是为了减轻mysql等数据库的访问压力。它的数据更新也随着mysql的更新而更新。
但,在并发量大,或者网络情况不好的情况下,有可能出现,程序在更新mysql数据库,依然有用户在请求redis中的数据,或者Mysql数据更新完毕,正在请求更新Redis时,消息丢失或者网络延迟,导致用户在Redis访问到的依旧是老数据。
当然,出现数据不一致的情况还有很多,以上只是简单的2种原因。
再比如,高并发的写操作,频繁的数据更新,或者缓存策略设置不当,如缓存过期时间不合理。
这就导致了所谓的数据不一致。
总结下就是,MySQL 和 Redis 结合使用时,数据一致性问题的本质是两者的数据更新存在时间差或操作顺序错误。由于 Redis 是缓存层(Cache),而 MySQL 是持久化层(Database),二者的操作无法保证原子性
。
# 高频高发场景
高频写操作
- 示例:秒杀系统中的库存扣减、社交媒体的点赞数更新。
- 问题:短时间内大量写请求,导致缓存与数据库频繁不一致。
复杂事务操作
- 示例:订单支付流程中,需更新订单状态、扣减库存、发放优惠券等多个操作。
- 问题:事务提交后,缓存未及时更新,其他请求读取到中间状态数据。
# 解决方案
明确了问题,那在更新mysql时,禁止用户访问对应的数据,等对应的redis数据也更新完,再放开限制,不就解决了数据一致性问题了吗?
这种方法理论上可以解决数据一致性问题,但在实际操作中可能会遇到一些挑战和限制。这种方法被称为“锁定”或“阻塞”,即在更新MySQL时暂时禁止用户访问相关数据直到Redis中的数据也完成更新。然而,这种策略在并发量高的情况下,缺点很明显:
如果频繁地锁定资源,用户的请求可能需要等待很长时间才能得到响应,严重影响用户体验。在高并发场景下,可能导致系统性能瓶颈甚至崩溃。
需要额外的机制来实现对特定数据的访问控制(例如分布式锁),增加了系统的复杂性和维护成本。
长时间持有锁会导致其他进程/线程处于等待状态,降低了系统的整体吞吐量。
没有完美的解决方案,你总要进行一些取舍。
在使用多个数据源时,数据同步必然会消耗一定时间,而这段时间,若允许访问,则会出现短暂的数据不一致,若等待其同步完成,则用户访问必然会出现等待,这是无法避免的。
考虑到上述问题,通常会采用以下一种或多种策略来达到更好的平衡,以下是一些方案
- 缓存失效策略
- 双写一致性
- 延迟双删策略
- 基于事务日志的同步
- 强一致性读:加锁 或 加分布式锁
- 使用消息队列,使用消息队列异步处理缓存更新任务,减少对主流程的影响。
- 版本号或时间戳:在读取数据时检查版本号或时间戳,仅当发现本地缓存比源数据陈旧时才进行更新。
如何选择,则是看你选择“最终一致性” 还是“强一致性”。
强一致性: 也被称为线性一致性或原子一致性,是指在任何时刻、从任何节点读取的数据都是最新的,即所有节点在同一时间点看到的数据完全相同。
通常需要通过锁机制或者分布式事务来保证,这可能会影响到系统的性能和扩展性。
适用于对数据准确性有严格要求的应用场景,如金融交易、账户余额查询等。
最终一致性: 是一种较弱的一致性模型,允许一段时间内的数据不一致,但保证随着时间推移,如果没有新的更新发生,所有副本最终会达到一致状态。换句话说,在某个写操作之后,可能会有一段时间内不同的客户端看到的数据版本不同,但最终它们都会收敛到相同的值。
- 允许短暂的数据不一致来换取更高的性能和更好的扩展性,适用于那些能够容忍一定延迟的应用场景。
# 各个方案分析
# 缓存失效策略
思路:以 MySQL 为数据源,Redis 作为缓存。MySQL 数据更新后,主动让 Redis 中的缓存失效。
实现方式:
- 写后失效:MySQL 数据更新(INSERT、UPDATE、DELETE)后,立即删除 Redis 中对应的缓存。下次读取时,Redis 缓存未命中,再从 MySQL 加载最新数据并更新缓存。
- 定时失效:为 Redis 缓存设置过期时间(如 TTL),过期后自动从 MySQL 刷新数据。
优点:简单易实现,适合对一致性要求不极高的场景。
缺点:可能存在短暂的不一致窗口(缓存失效后到刷新前)。
适合场景:并发量不高,且对一致性要求不高
更新redis时,为什么是,删除缓存,而不是更新缓存?
- 在一个高并发环境中,可能会出现这样的情况:当一个线程正在更新缓存时,另一个线程可能正在读取该缓存。如果直接更新缓存,可能导致部分更新的数据被其他线程读取到,从而造成脏读的问题。
- 直接更新缓存可能存在数据不一致的风险,特别是当存在多个服务或实例同时操作缓存时。例如,如果有两个不同的服务同时尝试更新同一个缓存项,可能会导致其中一个服务的更新覆盖另一个服务的更新
- 在某些情况下,更新缓存可能比简单地删除缓存更耗资源。相比之下,删除缓存通常只需要一个简单的操作,这有助于减轻系统的负担。
# 如何设置Redis的TTL
设置较短的TTL可以确保数据在缓存中不会长时间处于过期状态,从而减少数据不一致的时间窗口。然而,这也会增加数据库的负载,因为数据将更频繁地从数据库加载到缓存中。
以下是参照标准:
- TTL 设定:根据业务需求设定合适的TTL值。对于变化频繁的数据,可以设置较短的TTL;而对于相对稳定的数据,则可以设置较长的TTL。
- 监控与调优:定期监控缓存命中率、数据不一致情况等指标,并据此调整缓存策略。例如,如果发现某个缓存项的访问频率非常高且更新较少,可以适当延长其TTL。
# 双写一致性
Write-Through(写透):
- 思路:每次写操作同时更新 MySQL 和 Redis。
- 实现:应用程序在写 MySQL 后,同步更新 Redis。
- 优点:一致性较高。
- 缺点:写操作延迟增加,Redis 的高性能优势减弱。
Write-Behind(写后):
思路:先写 Redis,再异步更新 MySQL。
实现:用消息队列(如 Kafka、RabbitMQ)异步将 Redis 的更新同步到 MySQL。
优点:写性能高,适合高并发场景。
缺点:异步更新失败可能导致数据不一致。
潜在缺陷:
- 如果异步更新MySQL的过程中出现错误(如网络故障、数据库连接失败等),可能导致Redis中的数据与MySQL长期不一致
- 在Redis成功写入但MySQL还未更新的情况下,如果此时有读取请求到达,可能会从MySQL读取到旧的数据(因为:Redis的操作是单线程),导致数据不一致。
如果Redis中的数据,需要复杂计算(如聚合结果),除了耗时更多外,可能需要考虑的更多,如,如果在计算过程中MySQL数据再次被修改,如何处理?
不推荐
# 延时双删策略
思路:更新 MySQL 后,先删除 Redis 缓存,等待一段时间(比如几百毫秒)再删除一次 Redis 缓存。
实现:
- 更新 MySQL 数据。
- 删除 Redis 缓存。
- 延迟一段时间(根据业务读写速度调整),再次删除 Redis 缓存。
优点:解决更新 MySQL 后,Redis 未及时刷新导致的短暂不一致问题。
缺点:需要精确调整延时时间,过于复杂化可能得不偿失。
# 为什么需要删除两次
用户可能会有疑问:为什么需要两次删除 Redis 缓存,而不是只删除一次?答案在于高并发环境下可能出现的一种数据不一致场景。让我们通过一个具体示例来理解:
问题场景:
- 线程 A 更新 MySQL 数据(例如,将用户状态从 "在线" 改为 "离线"),如果要更新的数据很多。
- 线程 A 立即删除 Redis 中该用户的缓存。
- 在线程 A 删除缓存后、MySQL 更新完全生效前,线程 B 尝试读取该用户状态,发现 Redis 缓存未命中,于是从 MySQL 读取旧数据("在线")并将其写入 Redis。
- 线程 A 的 MySQL 更新完成,但这时 Redis 中已经被线程 B 写入了旧数据("在线")。
结果:此时 Redis 中的数据("在线")与 MySQL 中的最新数据("离线")不一致。如果只删除一次,这种不一致可能持续存在,直到下次缓存被手动刷新。
# 第二次删除时间越长越好?
不是的,第二次删除的延时时间并不是越长越好,延时时间过长并不能无限制地提高数据最终一致性,反而可能会带来其他问题。
- 延时时间过短的问题: 如果延时时间太短,可能无法覆盖系统中所有的读写操作。在第二次删除之前,部分旧数据可能还没有被清理或更新,导致第二次删除后,缓存和数据库的数据仍然不一致。
- 延时时间过长的问题: 如果延时时间过长,虽然理论上可以清理更多的旧数据,但这会延长系统处于数据不一致状态的时间窗口。在延时期间,用户可能会读取到旧数据,影响用户体验。此外,过长的延时时间还会增加系统的复杂性和性能开销。
- 理想的延时时间: 理想的延时时间应该能够覆盖系统中读写操作的平均时间,确保在第二次删除时,绝大多数旧数据已经被清理或更新。这样既能保证数据一致性,又能尽量缩短不一致的时间窗口。
延时双删策略适用于以下情况:
- 对一致性要求较高但非实时强一致性:如电商库存、用户状态等场景,允许短暂的不一致但需要最终一致。
- 高并发读写环境:在高并发下,能有效降低缓存不一致的概率。
# 基于事务日志的同步
- 思路:利用 MySQL 的 binlog(二进制日志),通过工具监听数据库变更,实时同步到 Redis。
- 实现:
- 使用工具如 Canal 或 Debezium 解析 MySQL binlog。
- 将变更事件推送到 Redis,更新缓存。
- 优点:接近实时一致性,解耦应用逻辑。
- 缺点:需要额外部署和维护中间件,复杂度较高。
# 强一致性读(Cache-Aside + 锁机制)
- 思路:在更新数据时加锁,保证 MySQL 和 Redis 的操作原子性。
- 实现
- 更新时,先锁住资源(可用分布式锁,如 Redis 锁或 ZooKeeper)。
- 更新 MySQL 和 Redis。
- 释放锁。
- 优点:保证强一致性。
- 缺点:性能开销大,高并发下锁竞争可能成为瓶颈。
# 版本号与时间戳机制的优点
- 确保数据一致性: 通过版本号或时间戳机制,应用程序可以准确判断缓存是否陈旧,从而避免使用过时的数据,保证数据一致性。
- 适用于高并发场景: 作为乐观锁机制,这种方案不需要锁住数据,减少了锁竞争,适用于高并发环境。
- 实现简单: 在数据库层面,只需添加额外的版本号或时间戳字段;在应用程序层面,只需在读取和更新时进行版本或时间戳的检查。
这种方案可能会导致频繁比较缓存中的数据与源数据库(如 MySQL)中的数据,或者频繁请求源数据库来获取最新数据。特别是在高并发或缓存命中率较低的场景下,这种频繁的请求可能会对源数据库造成压力,影响系统性能。
因为保证数据一致性,是通过比较数据来保证的。