一、引言
在分布式系统开发中,缓存问题一直是开发人员的"痛点":如何保证数据一致性?Redis宕机怎么办?缓存穿透、缓存击穿、缓存雪崩等问题怎么处理?每个项目都要重复编写类似的缓存处理代码,既浪费时间又容易出错。
1.1 核心理念
为了让开发人员告别重复的缓存代码,专注于业务逻辑,把缓存问题交给框架处理,基于RocksCache的思想实现了一个统一的缓存一致性解决方案:Easy-cache。方案通过Spring AOP提供简单易用的注解式缓存操作,还支持 Redis 集群缓存和本地二级缓存,具备多级缓存动态升降级、容错机制、弹性过期、最终一致性保障等高级特性。开发人员在开发需求时不需要额外编写代码保证一致性、宕机、穿透等问题,只需要在注解设置对应策略即可。
二、核心实现
2.1 实现目标:简单易用的缓存工具
我们的目标是设计一个简单易用、代码侵入性小的缓存工具。Spring AOP就是一个非常好的实现方式,在切面中编写好缓存逻辑,开发者只需要在查询方法上添加指定注解,就能获得缓存能力,无需编写任何缓存逻辑代码。
@Cacheable(clusterId = "cluster1", prefix = "user", keys = {"#userId"})
public User getUserById(Long userId) {
return userRepository.findById(userId);
}
@UpdateCache(clusterId = "cluster1", prefix = "user", keys = {"#userId"})
public User update(User user) {
return userRepository.update(user);
}
基于常见的缓存问题和场景,切面应该实现以下功能:
.实现缓存的查询与更新逻辑
.保证数据一致性
.容错处理(防穿透、多级缓存、自动升降级)
接下来,我将详细介绍缓存切面的具体设计实现。
2.2 设计思路
工具的入口为AOP拦截到指定注解,通过中央调度器依次进行容错处理、查询缓存、处理结果、返回结果。具体流程如图所示:

注解驱动:通过Spring AOP拦截 @Cacheable 和 @CacheUpdate注解触发查询和更新缓存
统一调度:调度器处理所有查询/更新缓存逻辑
容错机制:装饰器模式增加容错功能,防止缓存穿透等问题
多级缓存:Redis + 本地缓存,保证高可用;监控和维护集群健康度,缓存自动升降级,保证服务稳定性。
弹性数据一致性保障:执行Lua脚本保证一组缓存操作的原子性。支持设置数据库缓存不一致时间,默认为1.5s,框架在1.5s后保证最终一致性。当用户设置不一致时间为0s时,框架保证实时一致性。
2.3 缓存决策:多级缓存动态升降级
框架的默认多级缓存策略为:优先查询并更新Redis集群,当Redis集群不可用时,查询并更新本地缓存。为此需要一个决策器,当Redis宕机时请求能够直接请求本地缓存,在Redis恢复后请求会重新优先请求Redis。决策流程如图所示:

1.查询请求A首先经过决策器,当前时刻故障信息类集群异常事件未达到阈值,仍然优先请求Redis。
2.此时查询Redis异常,发送异常事件,故障动态管理类监听到异常事件后通知异常事件+1。
3.故障动态管理类内部定时任务查询发现集群异常事件已到达阈值:
3.1标记集群不可用。
3.2启动集群探活定时任务。
4.查询请求B经过决策器,发现集群不可用,直接与本地缓存交互,实现缓存降级。
5.当集群探活成功后,会标识集群可用,此时探活定时任务关闭,后续查询请求会优先请求Redis,实现缓存升级。
2.4 数据一致性保证机制
基于RocksCache思想,通过Redis-Hash结构和Lua脚本原子操作,确保缓存数据的最终一致性。
缓存中的数据是具有以下字段的哈希结构:
value:数据本身
lockInfo:锁定状态信息('locked' 或 'unLock')
unlockTime:数据锁过期时间,当一个进程查询缓存没有数据时,则锁定缓存一小段时间,然后查询DB、更新缓存
owner:数据锁唯一ID,标识当前锁的持有者
其中,owner、lockInfo、unlockTime基于Lua脚本执行的原子性实现了一个分布式锁。
查询缓存时,Lua脚本会执行以下逻辑
.如果数据为空且锁已过期: 则锁定缓存,返回 NEED_QUERY,同步执行"取数据"并返回结果
.如果数据为空且被锁定: 则返回 NEED_WAIT,休眠100ms并再次查询
.如果数据不为空且被锁定: 则立即返回SUCCESS_NEED_QUERY和缓存数据,异步执行"取数据"
.如果数据不为空且未锁定: 则立即返回SUCCESS和缓存数据
private staticfinal String GET_SH =
"local key = KEYS[1]\n"
+ "local newUnlockTime = ARGV[1]\n"
+ "local owner = ARGV[2]\n"
+ "local currentTime = tonumber(ARGV[3])\n"
+ "local value = redis.call('HGET', key, '" + VALUE + "')\n"
+ "local unlockTime = redis.call('HGET', key, '" + UNLOCK_TIME + "')\n"
+ "local lockOwner = redis.call('HGET', key, '" + OWNER + "')\n"
+ "local lockInfo = redis.call('HGET', key, '" + LOCK_INFO + "')\n"
+ "if unlockTime and currentTime > tonumber(unlockTime) then\n"
+ " redis.call('HMSET', key, '" + LOCK_INFO + "', 'locked', '" + UNLOCK_TIME + "', 'newUnlockTime', '" + OWNER + "', owner)\n"
+ " return {value, '" + NEED_QUERY + "'}\n"
+ "end\n"
+ "if not value or value == '' then\n"
+ " if lockOwner and lockOwner ~= owner then\n"
+ " return {value, '" + NEED_WAIT + "'}\n"
+ " end\n"
+ " redis.call('HMSET', key, '" + LOCK_INFO + "', 'locked', '" + UNLOCK_TIME + "', newUnlockTime, '" + OWNER + "', owner)\n"
+ " return {value, '" + NEED_QUERY + "'}\n"
+ "end\n"
+ "if lockInfo and lockInfo == 'locked' then \n"
+ " return {value, '" + SUCCESS_NEED_QUERY + "'}\n"
+ "end\n"
+ "return {value , '" + SUCCESS + "'}";
"取数据"操作定义:查询数据库并更新缓存。如果满足以下两个条件之一,则需要更新缓存:
1.数据为空且未锁定
2.数据锁定已过期
更新缓存时,Lua脚本会执行以下逻辑
1.无论key是否被锁定,强制标识锁过期,并删除锁持有者。锁的过期时间默认为1.5s
private staticfinal String INVALID_SH =
"local key = KEYS[1]\n"
+ "local newUnlockTime = tonumber(ARGV[1])\n"
+ "redis.call('HDEL', key, '" + OWNER + "')\n"
+ "local value = redis.call('HGET', key, '" + VALUE + "')\n"
+ "redis.call('HSET', key, '" + LOCK_INFO + "', 'locked')\n"
+ "if not value or value == '' then\n"
+ " return {true, '" + EMPTY_VALUE_SUCCESS + "'}\n"
+ "end\n"
+ "if newUnlockTime > 0 then\n"
+ " redis.call('HSET', key, '" + UNLOCK_TIME + "', newUnlockTime)\n"
+ "end\n"
+ "return {'', '" + SUCCESS + "'}";
2.4.1 数据一致性
1)读读并发的数据一致性

假设当前缓存没有数据或数据锁已过期
线程A查询缓存,发现没有数据或数据锁已过期,会对当前key加锁,标识锁持有者为当前线程,锁时长为1s。
线程B查询缓存:发现key已经被锁定且锁未过期,会sleep 100ms再次尝试查询
线程A查询数据库数据后更新缓存,并释放锁
线程B查询缓存,返回缓存数据。
执行第2步时线程B若发现key被锁定但锁已过期,会将锁持有者更新为线程B,查询数据库并更新缓存、释放锁,这样可以保证锁不会被同一线程一直占有。线程A更新缓存时发现锁持有者不是自己,不会更新缓存。 读读并发场景下,通过分布式锁确保只有一个线程查询数据库并更新缓存,保证了数据一致性。
2)读写并发的数据一致性

线程A查询缓存,发现没有数据,于是对当前key加锁,标识锁持有者为当前线程,锁时长为1s。
在线程A查询数据库的过程中,线程B更新了数据库,同时更新缓存。此时更新线程不会关注锁信息,会强制删除锁持有者,并标识key被锁定。
线程A更新缓存,发现锁持有者不是当前线程(此时锁持有者为空),不会更新缓存
线程C查询缓存,发现没有数据,于是对当前key加锁,标识锁持有者为当前线程,锁时长为1s。
线程C查询数据库成功,更新缓存并释放锁
读写并发场景下,框架保证了更新线程将key标记删除后,进行中的查询线程不会再将旧值写入缓存,保证了数据一致性。
2.4.2 标记删除:弹性过期时间
通常情况下为了防止在key过期或主动删除的瞬间有大量请求击穿缓存打到数据库,我们会让所有请求抢同一把分布式锁。但是这样做可能出现一个场景:抢到锁的线程访问数据库时间较长,大量等待线程响应时间过慢,导致当前服务响应上游服务请求超时。为此我在框架中增加了弹性过期机制:更新线程不会真正的删除缓存,而是标记当前key为过期,过期时间默认1.5s。在这1.5s内,所有的查询请求会返回旧值(即1.5s内可能出现数据库缓存不一致),同时尝试异步查库并更新缓存(异步操作需要抢分布式锁),此时可能出现两种情况:
1.5s内有一个线程查库成功并更新了缓存,那么就完成了一次平滑更新,实现数据的最终一致,后续查询线程会从缓存拿到新值。
1.5s内没有线程更新成功,1.5s后锁过期,所有查询线程会变成“读读并发”场景,保证了1.5s后的数据一致性。
如果业务场景无法容忍最终一致,必须保证实时一致,可以设置弹性过期时间为0s,此时如果缓存被更新,会立刻变成“读读并发”场景,保证实时一致性。
2.5 Lua脚本预加载:解决开销问题
2.5.1 设计带来的性能开销
在数据一致性保证机制中,为了保证Redis操作的原子性,使用提交Lua脚本的方式操作Redis缓存。这种设计虽然保证了功能的正确性,但也带来了明显的性能开销:
1.内存开销:缓存锁信息的存储
为了支持分布式锁机制,Redis中存储的不仅仅是数据本身,还需要额外的锁相关信息。这种设计确实增加了Redis的内存开销:每个key最多增加50 bytes(非更新和缓存过期场景不会增加额外的内存开销),但相比数据不一致带来的业务风险,这个内存开销是可以接受的。
2.网络IO开销:Lua脚本传输
更大的性能开销来自于网络IO。以获取缓存值的操作为例,每次都需要传输完整的Lua脚本,脚本大小约为500 bytes。在高并发场景下,这个网络开销会迅速累积,成为性能瓶颈。
在解决网络IO开销问题之前,我们需要简单了解一下,常用的Redis执行Lua脚本命令方式有以下两种:
特性\命令方式
|
EVAL
|
EVALSHA
|
脚本传输
|
每次传输完整脚本
|
仅传输脚本对应SHA1哈希值
|
性能
|
较低(网络开销大)
|
较高(适合频繁调用)
|
适用场景
|
一次性脚本或调试
|
生产环境高频调用的脚本
|
本文采用EVALSHA命令执行Lua脚本,相比于EVAL方式,从每次传输500字节的脚本内容,减少到只需要传输40字节的哈希值,网络开销减少了约92%。
2.5.2 Lua脚本预加载

在服务启动时触发Lua脚本的预加载机制。具体流程如下:
启动检测:服务启动时,LuaShPublisher组件会自动初始化
脚本收集:组件会收集所有预定义的Lua脚本,包括获取缓存、设置缓存、解锁缓存、失效缓存等操作
脚本上传:对每个集群,通过scriptLoad命令上传所有Lua脚本
哈希值记录:将Redis返回的SHA1哈希值记录到本地缓存中
考虑到网络不稳定或Redis服务器临时不可用的情况,还需要考虑重试机制:
异常捕获:当脚本上传失败时,系统会捕获异常信息
重试判断:系统会判断是否需要重试,避免无限重试导致服务启动失败
延迟重试:采用指数退避策略,每次重试的间隔逐渐增加
成功退出:当所有脚本都成功上传后,重试任务会自动退出
3、核心特性
3.1 分布式锁保证一致性
原子性操作:Lua脚本保证Redis缓存操作的原子性
最终一致性:通过Lua脚本实现分布式锁,保证数据一致性
性能优化:服务启动时会自动将需要执行的Lua脚本同步到Redis服务器,减少网络传输开销
3.2 多级缓存架构
高可用性:实时监控集群健康状态,Redis宕机时自动切换到本地缓存
智能升级:集群恢复后自动升级
3.3 弹性过期机制
标记删除:通过标记机制实现软删除
弹性过期:支持动态调整过期时间,默认为1.5s,框架保证最终一致性。当用户设置不一致时间为0s时,框架保证实时一致性。
一致性保证:解决缓存与数据库不一致问题
3.4 注解驱动的简化设计
开发效率提升:一行注解替代缓存代码
降低学习成本:开发者只需了解注解参数
统一规范:所有缓存操作遵循相同模式
四、总结
Easy-cache通过统一的设计解决了开发人员在缓存使用中的痛点,实现了以下核心价值:
重复代码问题:通过注解驱动,让开发者告别重复的缓存处理代码
缓存穿透问题:通过空值缓存和智能防护机制,有效防止恶意请求穿透到数据库
缓存击穿问题:通过分布式锁机制和标记删除方式,防止热点数据失效导致的数据库崩溃
数据不一致问题:通过Redis-Hash结构+Lua脚本实现分布式锁,确保缓存与数据库的数据同步
Redis宕机问题:通过自动降级和探活机制,保证服务的高可用性
以上就是Easy-cache的核心内容,希望能为分布式系统的缓存使用提供一些参考和思路。