一、背景
面对高qps场景,单纯mysql的技术架构容易出现瓶颈,常见的解决方案是引入分布式缓存挡住大部分读流量,但增加了依赖项则意味着需要考虑的异常场景就更多,最典型的莫过于如果保证缓存和数据库一致性。
二、缓存一致性问题原因
- 并发的场景下,导致读取老的 DB 数据,更新到缓存中。
- 缓存和 DB 的操作,不在一个事务中,可能只有一个操作成功,而另一个操作失败,导致不一致。
三、业内常用方案 (Cache Aside Pattern)
- 应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
- 命中:应用程序从cache中取数据,取到后返回。
- 更新:先把数据存到数据库中,成功后,失效缓存(业务不复杂的场景,可以直接异步更新缓存,而不是失效)
并发执行分析
只读
需要解决并发读时,缓存失效,查询DB,然后更新至Cache的问题,有两个数据需要更新Cache(所以需要加锁解决)
读写并发
场景一 先写后读
- 写流程:更新DB
- 写流程:删除缓存
- 读流程:查询缓存
场景二 写读无序(出现不一致)
- 写流程:更新DB
- 读流程:查询DB(缓存失效时的回查)
- 写流程:删除缓存
- 读流程:更新缓存
场景三 先读后写
- 读流程:查询缓存
- 写流程:更新DB
- 写流程:删除缓存
写与写并发
场景一
- 写流程1:更新DB
- 写流程2:更新DB
- 写流程2:删除缓存
- 写流程1:删除缓存
场景二
- 写流程1:更新DB
- 写流程2:更新DB
- 写流程1:删除缓存
- 写流程2:删除缓存
四、落地方案
详细技术方案
对于强一致性场景,要严格控制DB与缓存的数据一致性,如果不一致,就直接走DB,所以采取Cache Aside Pattern + DB与缓存版本控制的方式,其中Cache Aside Pattern在使用时,走删除缓存,版本控制基于数据库乐观锁字段,以下为本次技术方案的核心点
- 写操作必须要包含更新redis,本质目的就是让缓存的数据是无效的,实现方式为保证DB_Version与Cache_Version不一致,达到数据不一致的标记设置
- 写操作不更新数据,让读操作去更新数据,
- redis记录包含数据表记录、DB_Version、Cache_Version
- 读操作会判断redis中的数据是否是可用的,通过对比DB_Version等于Cache_Version实现
- 只有DB_Version等于Cache_Version时数据才是一致的
处理策略
虽然是强依赖redis,为了应对redis可能出现的抖动、不可用等系统异常问题,需要有保证系统数据稳定的能力
- DB策略,DB处理策略,用在redis存在问题时,通过限流方式直接在DB上读写,待redis稳定后,切换为redis
- redis策略,正常情况下都是为redis策略;为了在redis出现问题,切换为DB策略,后又切换为redis策略时,切换过程中的数据更新与redis数据不一致的问题,重新切换为redis策略后,需要更新redis的key前缀为新前缀,具体实现方式可以将redis的前缀进行配置
缓存数据模型
DB_Version:用于记录DB中的版本号
Cache_Version:用户记录缓存中的版本号
Data:业务数据
为了保证业务数据与DB_Version、Cache_Version的更新是一致的,需要在更新redis时将业务数据、DB_Version、Cache_Version放在一条记录中更新
总体更新流程
- 写操作需要获取锁,
- 获取锁成功,(优化后不需要写时获取锁)
- 更新redis
- redis中有记录,DB_Version=乐观锁版本+1,Cache_Version =乐观锁版本
- redis中无记录,DB_Version=乐观锁版本+1,Cache_Version=-2(默认值)
- 更新DB
- 获取锁失败,重试,回到第2步
问题
- DB_Version更新有问题时(不管是超时、真正的失败还是什么其他原因,总之不是成功的),怎么处理?
- redis更新失败,尝试重试,如果重试失败,直接抛出异常,基于的处理原则为,数据的一致性比弱依赖redis更重要(引入redis就会有些依赖)
- 更新是否需要加锁
- 可加可不加,原因在于已经使用版本号控制,即使在更新redis与更新DB之间进来的查询,因为redis中DB_V与Cache_V不一样,也不会导致数据出现不一致
总体读流程
- 查询缓存,获取缓存数据,未命中,查询DB,返回
- 命中,NullObjec直接返回
- 命中,非NullObject,此时redis记录中包含了Cache_Version与DB_Version;对比Cache_Version与DB_Version,只需要比对两者的值,判断DB_Version是否等于Cache_Version
- 如果一致,则返回redis中的数据
- 如果不一致,首先要获取分布式锁,获取锁成功后,查询DB数据,然后准备DB数据更新至redis记录,其中Cache_Version=DB_Version=乐观锁版本
- 未命中,首先要获取分布式锁,获取锁成功后,查询DB数据,然后准备DB数据更新至redis记录,其中Cache_Version=DB_Version=乐观锁版本
问题
- 获取锁的方式有点重,有替代方案么?是否获取分布式锁,可以通过开关控制,在双十一这种大促以及日常时,对比开关开启、关闭时两者的RT、DB读写QPS、缓存命中率情况,分析分数锁的具体影响