多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Redis双写一致性:延时双删、分布式锁与异步通知方案全解析

Redis双写一致性:延时双删、分布式锁与异步通知方案全解析 1. 项目概述从缓存与数据库的“数据打架”说起做后端开发尤其是涉及到高并发场景缓存几乎是绕不开的技术选型。Redis以其高性能和丰富的数据结构成为了事实上的缓存标准。但引入缓存一个经典的“副作用”就随之而来缓存与数据库的数据一致性问题也就是我们常说的“双写一致性”。这个问题在面试中高频出现在实际生产环境里更是稍有不慎就会引发线上故障比如用户刚下单成功刷新页面却发现订单消失了或者商品库存明明已经售罄前台却还能下单。这个标题“redis四、双写一致性的原理和解决方案”精准地指向了这个后端工程师的“必修课”和“面试必考题”。它不仅仅是在问“有哪些方案”更深层的是在考察我们对数据一致性、并发控制、系统架构设计的理解深度。所谓“双写”就是指数据更新时我们既要更新数据库DB也要更新缓存Cache。而“一致性”就是要求这两个地方的数据在任意时刻对外呈现的状态应该是相同的当然在分布式系统里我们通常追求的是最终一致性。接下来我会结合自己踩过的坑和项目中的实践把延时双删、分布式锁、异步通知MQ/Canal这几种主流方案的原理、实现细节、适用场景以及面试时如何组织语言掰开揉碎了讲清楚。我们会用Python配合Redis实现一个带Lua脚本的单节点分布式锁方案让你不仅能理解理论更能动手复现。2. 双写一致性问题的根源与核心挑战在深入方案之前我们必须先搞清楚问题是怎么产生的。如果只是读多写少的场景经典的Cache-Aside模式先读缓存没有则读库并回填工作得很好。问题出在“写”操作上。2.1 并发写场景下的经典数据不一致时序假设我们有一个简单的更新用户信息的操作更新数据库然后删除缓存这是最常用的策略之一称为“先更新数据库再删除缓存”或“Cache-Aside写”。理想流程是更新DB - 删除Redis。但在并发环境下两个线程或进程A和B可能这样交错执行线程A更新数据库将某个值从1改为2。线程B更新数据库将同一个值从2改为3。线程B删除缓存。线程A删除缓存。最终结果是数据库里的值是3B的更新但缓存被A最后删除或因为A的删除覆盖了B的删除效果在某些实现下可能导致缓存未被清除。如果此时有一个读请求在步骤4之后、缓存重新被回填之前到来它会从数据库读到旧值比如2如果A的更新还未提交或可见性等问题并重新写入缓存导致缓存中变成了旧数据2而数据库是3不一致就产生了。另一种更常见的不一致是“先删除缓存再更新数据库”策略在并发读写下的问题线程A写删除缓存。线程B读发现缓存缺失去数据库读取旧值。线程B将读到的旧值回填到缓存。线程A更新数据库为新值。结果缓存中是旧值数据库是新值且这个旧值会一直存在直到下一次更新或缓存过期。2.2 一致性级别的权衡强一致与最终一致这是理解所有解决方案的基础。在分布式系统领域CAP理论告诉我们网络分区P下一致性和可用性不可兼得。对于缓存这种明确为了提升性能可用性而引入的组件我们几乎从不追求它与数据库的强一致性即任何时刻读取缓存和数据库都绝对相同因为那意味着每次写操作都需要以同步、阻塞的方式同时成功更新缓存和数据库这完全丧失了缓存的意义。我们追求的是最终一致性在更新操作之后的一段时间内系统可能处于不一致状态但通过一些机制保证在没有新的更新操作的情况下经过一段时间的同步所有副本缓存和数据库的数据最终会达到一致的状态。我们所有方案的设计都是在保证最终一致性的前提下尽可能缩短不一致的时间窗口并保证核心业务的正确性。注意有些特殊业务场景如金融账户余额可能要求强一致。这时通常的做法是直接读写数据库完全绕过缓存或者使用数据库本身的事务和锁机制缓存仅用于非强一致性的加速查询。这不在本文“双写一致性”的主流讨论范畴内。3. 解决方案一延时双删策略解析与实操延时双删是一个试图解决“先更新数据库再删除缓存”策略下并发问题的工程化方案。它的核心思想是在更新数据库前后都执行一次缓存删除并且第二次删除是延迟执行的。3.1 延时双删的基本流程与原理第一次删除在更新数据库之前先删除缓存中的旧数据。目的是消除在更新数据库期间可能被读请求回填的旧缓存。执行更新执行数据库更新操作。延时等待主动等待一小段时间比如几百毫秒。这个等待的目的是让可能在“第一次删除后、数据库更新完成前”这个时间窗口内发生的并发读请求完成。这些读请求会读到旧数据并可能将其回填到缓存我们需要等它们“飞一会儿”。第二次删除延时结束后再次删除缓存。目的是清除掉上述并发读请求可能回填的旧数据。这个流程试图解决的是“先更新数据库再删除缓存”模式下因数据库主从同步延迟或并发读写导致的脏缓存问题。第二次删除的“延时”是关键它需要大于“主从数据库同步延迟时间”与“一次读请求耗时”之和。3.2 Python代码实现与关键参数考量下面是一个简单的Python示例使用redis和pymysql库。这里假设你已经有了可用的MySQL和Redis连接。import redis import pymysql import time import threading class DelayDoubleDelete: def __init__(self, redis_client, mysql_conn): self.redis redis_client self.mysql mysql_conn def update_user(self, user_id, new_name): 更新用户信息采用延时双删策略 cache_key fuser:{user_id} # 1. 第一次删除缓存 self.redis.delete(cache_key) print(f[第一次删除] 删除缓存键: {cache_key}) # 2. 更新数据库 try: with self.mysql.cursor() as cursor: sql UPDATE users SET name %s WHERE id %s cursor.execute(sql, (new_name, user_id)) self.mysql.commit() print(f[更新数据库] 用户 {user_id} 名称更新为 {new_name}) except Exception as e: print(f数据库更新失败: {e}) # 实际生产环境需要更严谨的回滚或重试机制 return False # 3. 延时等待 delay_seconds 0.5 # 延迟500毫秒 print(f[延时等待] 等待 {delay_seconds} 秒...) time.sleep(delay_seconds) # 4. 第二次删除缓存 self.redis.delete(cache_key) print(f[第二次删除] 再次删除缓存键: {cache_key}) return True # 使用示例 if __name__ __main__: # 初始化连接示例实际应从配置或连接池获取 r redis.Redis(hostlocalhost, port6379, db0, decode_responsesTrue) db pymysql.connect(hostlocalhost, userroot, passwordpassword, databasetest_db) ddd DelayDoubleDelete(r, db) ddd.update_user(123, NewName)关键参数——延迟时间如何设定这是一个经验值需要根据你的系统实际情况来定。你需要考虑数据库主从同步延迟如果你的读写分离写主库读从库那么这个延迟必须大于从库同步的延迟。可以通过监控数据库的Seconds_Behind_Master等指标来评估。业务读请求的平均耗时从缓存缺失到查询数据库、组装数据、回填缓存的总时间。网络往返时间。通常这个值会设置在100毫秒到1秒之间。设置得太短可能旧请求还没回填完第二次删除就执行了之后旧数据又被回填导致策略失效。设置得太长则数据不一致的时间窗口被拉长且写请求的响应时间变差。3.3 延时双删的优缺点与适用场景优点实现简单逻辑清晰代码侵入性低不需要引入额外的复杂中间件。在一定程度上缓解了并发问题比单纯的“先更新数据库再删除缓存”更健壮。缺点与注意事项延迟时间难以精确设定这是一个静态的估计值无法动态适应系统负载和延迟的变化。设短了可能无效设长了影响性能。性能损耗写请求需要同步等待一个延迟增加了响应时间。并非银弹在极端高并发下仍然可能出现不一致。例如在第二次删除之后、新的缓存被回填之前又来了一个读请求它可能读到从库的旧数据如果主从延迟仍然存在并回填。删除失败的重试问题两次删除操作都可能失败需要有重试机制。特别是第二次删除如果是异步执行例如放到一个线程池或消息队列还需要考虑可靠性。适用场景对一致性要求不是极度苛刻可以接受秒级短暂不一致的业务。数据库主从延迟相对稳定且可预估的系统。作为快速上线、缓解一致性问题的临时或过渡方案。实操心得在实际项目中我们曾将延时双删用于用户昵称、头像等非核心数据的更新。我们将延迟时间设置为800毫秒并通过日志监控删除操作的成功率。对于核心数据如库存、余额我们则采用了更严格的方案。4. 解决方案二基于分布式锁的强一致性保障如果你需要更强的保证希望在一个写操作执行期间相关的读写操作都被串行化那么分布式锁是一个直接的选择。它的目标是在更新数据时让“更新数据库”和“操作缓存”成为一个原子操作阻止其他并发读写的干扰。4.1 分布式锁的设计要点与Redis实现一个可靠的分布式锁需要满足几个基本条件互斥性在任意时刻只有一个客户端能持有锁。防死锁即使持有锁的客户端崩溃或网络分区锁最终也能被释放避免系统永久阻塞。容错性只要大部分Redis节点存活客户端就能获取和释放锁。身份标识加锁和解锁必须是同一个客户端不能解别人的锁。Redis实现分布式锁最经典的方式是使用SET命令的NX不存在才设置和PX设置毫秒级过期时间选项。SET lock_key unique_value NX PX 30000当lock_key不存在时将其值设置为unique_value并设置30秒的过期时间。这个命令是原子的。4.2 结合Lua脚本实现原子化锁与缓存操作为什么需要Lua脚本考虑以下流程获取锁。执行业务逻辑读库、计算、更新库、删缓存。释放锁。如果在第2步和第3步之间锁因为网络延迟或GC停顿导致实际持有时间超过了过期时间锁会自动释放。此时另一个客户端可能获取到锁。当第一个客户端恢复后它可能误删第二个客户端设置的锁如果只用简单的DEL命令。这就是“误删锁”问题。解决方案是释放锁时需要验证锁的值是否还是自己当初设置的那个unique_value。判断和删除必须是原子的否则在判断之后、删除之前锁可能过期并被其他客户端获取。Lua脚本在Redis中原子执行完美解决这个问题。下面是一个完整的、带Lua脚本的Python实现示例import redis import uuid import time class RedisDistributedLock: def __init__(self, redis_client, lock_key, expire_time30000): self.redis redis_client self.lock_key lock_key self.expire_time expire_time # 毫秒 self.identifier str(uuid.uuid4()) # 唯一标识符 def acquire(self, timeout10): 获取锁timeout为获取超时时间秒 end time.time() timeout while time.time() end: # 使用SET NX PX命令原子性地尝试获取锁 if self.redis.set(self.lock_key, self.identifier, nxTrue, pxself.expire_time): return True time.sleep(0.001) # 短暂休眠避免活锁和CPU空转 return False def release(self): 释放锁使用Lua脚本保证原子性 lua_script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end release_script self.redis.register_script(lua_script) # 执行脚本确保只有锁的持有者才能删除它 result release_script(keys[self.lock_key], args[self.identifier]) return result 1 def update_data_with_lock(redis_client, db_conn, data_id, new_value): 使用分布式锁更新数据和缓存 锁的粒度通常是业务数据ID例如 lock:order:123 lock_key flock:data:{data_id} cache_key fdata:{data_id} lock RedisDistributedLock(redis_client, lock_key, expire_time5000) # 锁5秒 if not lock.acquire(timeout3): # 尝试获取锁最多等3秒 print(获取分布式锁失败可能系统繁忙) # 这里可以返回错误或者进行重试具体看业务需求 raise Exception(System busy, please try again later.) try: print(f[持有锁] 开始处理数据 {data_id}) # ---- 临界区开始 ---- # 1. 更新数据库 # with db_conn.cursor() as cursor: ... # 模拟数据库操作 time.sleep(0.1) print(f 更新数据库: id{data_id}, value{new_value}) # 2. 删除缓存或更新缓存为最新值 # 选择删除遵循Cache-Aside模式让下次读请求回填 redis_client.delete(cache_key) print(f 删除缓存键: {cache_key}) # ---- 临界区结束 ---- finally: # 无论如何最终都要尝试释放锁 if lock.release(): print(f[释放锁] 成功释放锁 {lock_key}) else: # 释放失败可能是锁已过期自动释放日志记录即可 print(f[警告] 释放锁失败或锁已过期 {lock_key}) # 使用示例 if __name__ __main__: r redis.Redis(hostlocalhost, port6379, db0, decode_responsesTrue) # 模拟并发 import threading def worker(thread_id): try: update_data_with_lock(r, None, 555, fval_from_thread_{thread_id}) except Exception as e: print(fThread {thread_id} failed: {e}) threads [] for i in range(5): t threading.Thread(targetworker, args(i,)) threads.append(t) t.start() for t in threads: t.join()4.3 锁的粒度、过期时间与续约问题锁的粒度锁的键lock_key设计至关重要。锁的粒度越细如按数据行ID加锁并发度越高但管理也越复杂。粒度越粗如整个表加锁则并发度低可能成为性能瓶颈。通常我们按业务前缀:实体类型:实体ID来构造锁键例如lock:order:1001。锁的过期时间必须设置这是防止死锁的关键。时间应略大于业务操作在临界区内的最大可能耗时。设置太短业务没执行完锁就释放了会导致并发问题。设置太长如果客户端崩溃其他客户端需要等待更久。通常设置为平均业务耗时的3-5倍。锁的续约Watchdog对于执行时间可能超过锁过期时间的业务需要引入“看门狗”机制即在后台启动一个线程定期比如在过期时间的1/3处检查锁是否仍持有并刷新过期时间。这增加了实现的复杂性。Redisson等成熟的客户端库内置了此功能。优缺点分析优点提供了强一致性的保证逻辑清晰能有效解决并发问题。缺点性能开销加锁、释放锁有网络开销且串行化处理会降低系统的整体吞吐量。复杂性需要妥善处理锁的获取、释放、过期、续约以及可能出现的脑裂等问题。风险如果锁服务Redis不稳定会影响所有相关业务。适用场景对数据一致性要求极高且并发冲突不是特别频繁的业务场景如秒杀系统中核心库存的扣减、账户重要信息的修改等。5. 解决方案三基于异步通知的最终一致性方案无论是延时双删还是分布式锁本质上都是“推”模式——由写请求主动去清理或更新缓存。而异步通知则是一种“拉”或“事件驱动”的模式数据库的变更被捕获并作为事件发布出去由一个独立的消费者来异步处理缓存更新。这实现了业务逻辑与缓存维护逻辑的解耦。5.1 消息队列MQ方案解析这是最通用的异步通知方案。写服务在成功更新数据库后向消息队列发送一条消息例如“用户ID123的信息已更新”然后就可以立即返回响应无需关心缓存。一个独立的缓存更新服务订阅这个消息队列消费消息根据消息内容去删除或更新对应的缓存。流程应用更新数据库主库。应用发送消息到MQ例如RabbitMQ、Kafka、RocketMQ。MQ保证消息的可靠投递。缓存更新服务消费消息。缓存更新服务根据消息内容执行缓存删除/更新操作。优点解耦写服务与缓存维护服务分离各自独立伸缩和演进。异步化性能好写请求响应快缓存更新在后台异步完成。可靠性利用MQ的消息持久化、重试、死信队列等机制可以保证缓存更新操作至少被执行一次At Least Once结合幂等性设计可以达到最终一致。挑战消息顺序对于同一个数据的多次更新需要保证消息被顺序消费否则可能导致缓存最终状态与数据库不符。Kafka在分区内能保证顺序RabbitMQ需要单独设计。消费延迟从数据库更新到缓存更新存在一段延迟期间读请求可能读到旧数据。幂等性由于MQ的At Least Once语义同一条消息可能被消费多次缓存更新操作必须是幂等的多次执行效果与一次相同。例如删除缓存DEL key是幂等的而SET key value则需要判断值是否已是最新。5.2 数据库Binlog订阅方案Canal深度剖析Canal是阿里开源的一个基于MySQL数据库增量日志binlog解析和推送的中间件。它的原理是伪装成MySQL的从库Slave向主库Master发送dump协议主库会将binlog推送给CanalCanal解析binlog后可以将变更事件INSERT/UPDATE/DELETE发送给下游如Redis、MQ、Elasticsearch等。流程应用更新数据库主库。数据库将操作记录到binlog。Canal客户端伪装Slave拉取并解析binlog。Canal将解析出的数据变更事件包含库名、表名、行数据变更前后内容等发送到MQ或直接调用缓存更新服务。缓存更新服务消费事件更新缓存。相比于应用层发MQ的方案Canal方案的优势完全解耦对业务代码零侵入。业务代码完全不用关心缓存只需要正常操作数据库即可。缓存更新成为一个独立的、基于数据日志的运维层任务。保证数据源唯一所有缓存更新的数据源都来自数据库的binlog避免了业务代码多路径更新可能带来的遗漏。支持异构系统同步不仅可以更新Redis还可以同步到ES、HBase等其他数据存储。实操配置要点概念性部署Canal Server配置instance.properties指定要监听的MySQL地址、账号需有REPLICATION权限、binlog位置等。部署Canal Client/AdapterCanal官方提供了多种语言的客户端也有将数据直接同步到Redis的Adapter。你需要编写或配置消费逻辑将RowChange事件转化为对Redis的DEL或HSET等命令。数据过滤与转换通常我们只关心某几个库的某几张表。需要在Canal配置或客户端代码中进行过滤。同时需要将数据库的行数据转换为适合Redis存储的结构如Hash。高可用与监控Canal Server本身需要高可用部署并监控其延迟和消费状态。一个简化的Canal客户端处理伪代码逻辑# 伪代码展示处理流程 def process_binlog_event(event): if event.event_type not in (INSERT, UPDATE, DELETE): return table_name event.table_name database_name event.database_name # 只处理我们关心的表 if database_name my_db and table_name users: for row_data in event.row_changes: if event.event_type DELETE: # 构造缓存键并删除 user_id row_data.before_values[id] cache_key fuser:{user_id} redis_client.delete(cache_key) elif event.event_type in (INSERT, UPDATE): # 通常对于新增和更新我们选择删除缓存让下次读请求回填最新数据 # 也可以选择直接更新缓存但需要处理可能的数据转换和序列化 user_id row_data.after_values[id] cache_key fuser:{user_id} redis_client.delete(cache_key) # 或者直接更新redis_client.hmset(cache_key, row_data.after_values)异步通知方案的共同注意事项最终一致性延迟这是异步方案的本质需要业务能接受。数据转换的复杂性数据库表结构到缓存数据结构的映射需要仔细设计。运维复杂度引入了新的组件Canal、MQ增加了系统架构的复杂度和运维成本。适用场景对业务代码侵入性要求低希望彻底解耦的场景。数据变更频率不是极高可以接受秒级延迟。除了更新缓存还有其它数据同步需求如同步到搜索索引、大数据平台。6. 方案对比与选型决策指南没有一种方案是完美的选择取决于你的业务特性和系统约束。下面这个表格从多个维度进行了对比特性维度延时双删分布式锁异步通知 (MQ/Canal)一致性强度弱最终一致强一致临界区内最终一致性能影响中等写请求有延迟差串行化吞吐量低好写请求快速返回业务代码侵入低需添加删除逻辑高需显式加锁解锁极低Canal/ 低MQ系统复杂度低中需处理锁的细节高引入新组件运维复杂数据延迟短取决于延时设置无在锁内保证中取决于消息消费速度可靠性低依赖删除成功中依赖Redis可用性高依赖MQ/Canal的可靠性适用场景对一致性要求不高可接受短暂不一致的读多写少业务对一致性要求极高并发冲突少的核心业务如扣库存对代码侵入敏感追求高性能写入可接受秒级延迟的复杂业务系统选型建议追求简单快速从延时双删开始配合合理的过期时间设置能满足很多场景。强一致性要求对于库存、余额等核心金融属性数据考虑分布式锁但要做好性能评估和降级方案。架构解耦与高性能对于大型系统特别是已有MQ或ES同步需求的异步通知尤其是Canal是更优雅和可持续的方案。它让业务开发更纯粹将数据同步问题下沉到基础设施层解决。7. 面试回答模板与实战话术面试官问“如何保证Redis与数据库的双写一致性”时他期待的不仅是一个方案名字而是一个有层次、有思考的论述。标准回答结构STAR法则变体阐述问题本质“首先在引入缓存提升读性能的同时写操作需要同时更新缓存和数据库这就带来了数据一致性问题。在并发环境下由于操作时序问题很容易导致缓存中是旧数据而数据库是新数据或者相反。”分析一致性级别“在分布式系统中我们通常不追求强一致因为那会牺牲可用性。我们追求的是最终一致性即保证一段时间后数据是一致的。我们的方案都是围绕缩短不一致时间窗口、保证最终正确性来设计的。”分层介绍方案“常见的解决方案有几种各有适用场景。”方案一延时双删。“这是一个工程上的补偿策略。在更新数据库前后各删除一次缓存第二次删除延迟执行。目的是清除在更新期间可能被回填的旧缓存。优点是实现简单缺点是需要预估一个合理的延迟时间且在高并发下仍可能不一致。适用于对一致性要求不极致的场景。”方案二分布式锁。“通过锁强制串行化读写操作。在更新时获取一个针对该数据的分布式锁在锁内完成‘读库-判断-更新库-删缓存’的整个流程。优点是可以保证强一致性缺点是并发性能下降实现复杂度高。适用于库存扣减等对一致性要求极高的核心场景。” 此时可以补充“我实现过一个基于Redis SET NX PX和Lua脚本的锁保证了加锁、解锁的原子性。”方案三异步通知。“通过消息队列或数据库Binlog订阅如Canal将数据库变更事件异步通知到缓存服务。优点是完全解耦业务代码对写性能无影响缺点是系统架构变复杂存在秒级延迟。适用于大型复杂系统追求架构清晰和高性能写入。”总结与选型“在实际项目中我们往往是组合使用这些方案。例如对用户画像等非核心数据用延时双删对商品库存用分布式锁同时整个系统搭建一个CanalMQ的异步同步平台用于处理大批量数据同步和ES索引更新。选型的核心是根据业务对一致性、性能和复杂度的容忍度来做权衡。”加分项提及进阶思考“此外我们还需要考虑一些边界情况比如缓存删除失败的重试机制、缓存预热、热点Key发现与处理、以及在大促时如何降级比如直接切到读库来保证系统可用性。”话术要点不要只说方案名称要解释为什么用这个方案解决了什么具体问题。提到方案的优缺点展示你的辩证思考。结合实际场景举例说明让回答更生动。最后上升到架构权衡和组合使用的高度体现你的全局观。8. 生产环境中的常见陷阱与排查技巧即使选对了方案在生产环境中依然会遇到各种问题。这里记录几个我踩过的坑和对应的排查思路。8.1 缓存删除失败与重试机制无论是哪种方案最终都可能落到“删除缓存”这个操作上。网络抖动、Redis瞬时故障都可能导致删除失败。解决方案同步重试在删除操作后立即进行有限次数的重试如2-3次。简单但会增加本次请求的延迟。异步重试队列将失败的删除操作放入一个内存队列如Redis List或消息队列由后台任务异步重试。这是更可靠的方式。def delete_cache_with_retry(redis_client, key, max_retries3): for i in range(max_retries): try: if redis_client.delete(key): print(f删除缓存 {key} 成功) return True # 如果key不存在delete返回0这不算失败 print(f缓存键 {key} 不存在) return True except redis.exceptions.ConnectionError as e: print(f删除缓存 {key} 第{i1}次重试失败: {e}) if i max_retries - 1: time.sleep(0.1 * (2 ** i)) # 指数退避 else: # 最终失败写入异步重试队列 async_retry_queue.push({op: del, key: key}) return False设置合理的缓存过期时间这是一个兜底策略。即使删除失败数据最终也会因过期而消失。过期时间不宜过长根据业务容忍度设置如几分钟到几小时。8.2 数据库主从延迟导致的“幽灵数据”在读写分离架构下写主库读从库。当使用“先更新数据库主再删除缓存”策略时如果删除缓存后立即有一个读请求这个请求可能落到一个尚未同步完成的从库上读到了旧数据并回填到缓存导致不一致。排查与解决监控从库延迟持续监控Seconds_Behind_Master。如果延迟持续较高需要优化数据库或考虑读主库。方案适配延时双删其延迟时间必须大于主从延迟。强制读主库对于一致性要求极高的关键查询在写操作后的短时间内如几百毫秒可以让相关读请求强制走主库。可以通过在缓存中设置一个短暂的标记来实现。def write_operation(user_id, new_data): # 1. 更新主库 update_master_db(user_id, new_data) # 2. 设置一个短期标记比如5秒键为 read_master:user:{user_id} redis_client.setex(fread_master:user:{user_id}, 5, 1) # 3. 删除缓存 redis_client.delete(fuser:{user_id}) def read_operation(user_id): # 先检查是否需要读主库 if redis_client.exists(fread_master:user:{user_id}): data read_from_master_db(user_id) else: data read_from_slave_db(user_id) # 或先查缓存 # ... 回填缓存逻辑 return data8.3 热点Key与缓存击穿、雪崩在一致性方案讨论中我们聚焦于“写”。但“读”同样重要且会影响一致性策略的选择。缓存击穿一个热点Key过期瞬间大量请求穿透到数据库。应对使用分布式锁只让一个请求去数据库加载数据其他请求等待。缓存雪崩大量Key在同一时间过期。应对给缓存过期时间加上随机值。热点Key某个Key访问量巨大。应对考虑本地缓存如Guava Cache、拆分Key、或使用Redis集群模式将压力分散。与双写一致性的关联在采用“删除缓存”策略时如果删除的是一个热点Key瞬间会引发缓存击穿。此时你的分布式锁方案如果用了正好可以派上用场防止数据库被压垮。如果没有用锁则需要考虑在删除后立即进行缓存预热或者采用“更新缓存”而非“删除缓存”的策略但后者在并发写时更复杂。8.4 监控与告警体系建设不能等到用户投诉才发现数据不一致。必须建立监控。缓存命中率监控命中率异常下跌可能意味着缓存大量失效或删除频繁。缓存与数据库值对比巡检开发一个低频的离线任务定期抽样对比Redis和数据库中的关键数据发现不一致则告警并尝试修复如重新删除缓存。这是最后一道防线。关键操作日志记录每一次缓存删除、数据库更新的日志包括时间、数据ID、操作结果。这在排查问题时至关重要。消息队列堆积监控如果使用异步方案监控MQ中消息的消费延迟和堆积情况。双写一致性不是一个可以一劳永逸解决的问题而是一个需要在一致性、性能、复杂度之间持续权衡和优化的过程。理解每种方案的原理和边界结合自身业务特性进行选择和组合并在生产环境中配以完善的监控和降级措施才能构建出既快又稳的系统。
返回列表