1. 先说设计思路:缓存和分布式锁为什么总是成对出现
做SpringBoot项目,尤其是涉及高并发、集群部署的业务,Redis几乎成了标配。很多同学一上来就加依赖、写配置、用注解,一套操作猛如虎,结果线上数据错了、缓存失效了、请求打崩数据库了,才知道问题一堆。
我先说一个核心认知:缓存解决的是读多写少的性能问题,分布式锁解决的是多实例并发下的资源互斥问题,两者本来不是一个东西,但在很多场景里必须配合使用。
举一个最常见的例子:商品详情页。100万个请求同时打过来,数据库撑不住,于是你加了Redis缓存,第一次查库,之后读缓存,性能立竿见影。这个场景里核心是缓存。但换个场景:秒杀扣减库存,多个微服务实例同时扣减同一个商品的库存,如果只是读缓存、写缓存,就出大事了——两个请求同时读到库存为1,各自加一减一,最后库存变成0或者负数,数据直接错乱。这个场景里,你不仅需要缓存,还需要一把“跨进程的锁”,保证同一时刻只有一个实例在执行“查询-判断-修改”这个复合操作。
再有,缓存本身也有“并发重建”的问题。比如缓存的key过期了,一瞬间来了100个请求同时落库查询,数据库瞬间被打爆。这时候分布式锁的作用就是让100个请求里只有1个能真正去查库、回填缓存,其余99个要么等待,要么直接拿到旧值。这也就是大家常说的缓存击穿防护。
所以我的建议是:在一个SpringBoot项目里,把Redis缓存和分布式锁当成一对组合拳来设计,先想清楚业务里哪些数据需要缓存、哪些操作需要互斥,再动手写代码。本文会从依赖引入、序列化配置、缓存注解、锁的选型与实现,到常见的坑和排查手段,完整走一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存接入:依赖、配置、序列化器选择这些细节别图省事
2.1 依赖引入与基础配置
SpringBoot整合Redis的起步很简单,核心就两个依赖:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
第二个依赖是连接池用的,生产环境必须加。没有连接池的话,高并发下Redis连接会被反复创建销毁,性能损耗非常明显。配置方面,我习惯把连接参数单独拆一个redis:前缀的配置块:
yaml复制spring:
redis:
host: 127.0.0.1
port: 6379
password:
database: 0
timeout: 3000ms
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
max-wait: 3000ms
注意max-wait这个参数,取不到连接时的最大等待时间。默认值是-1,也就是无限等待。我踩过一次坑:流量高峰期连接池被占满,所有线程都卡在等待连接上,接口超时堆积,最后把整个应用拖死。建议线上别用默认值,设置成1到3秒,宁可快速失败,也不要无限阻塞。
还有一个细节:SpringBoot 2.x以后默认用的是Lettuce连接器,不是Jedis。Lettuce基于Netty,线程安全,性能更好,我个人建议直接用默认实现,没必要换回Jedis。如果遇到连接不稳定、间歇性报错的问题,优先检查Redis服务端的timeout配置和Lettuce的shareNativeConnection参数碰撞,而不是急着换客户端。
2.2 序列化方案:这一步决定你缓存里存的是人能看懂的JSON还是乱码
很多新手直接用默认配置跑缓存,然后发现Redis Desktop Manager里存的全是\xAC\xED\x00\x05t...这种乱码。原因很简单:Spring默认用JdkSerializationRedisSerializer做序列化,对象经过Java原生序列化之后存进Redis,读出来也是一堆二进制。它的问题不只是看起来乱,还有三个实际影响:
- 存进去的内容人类不可读,排查问题全靠猜。
- 序列化后的体积比JSON大很多,浪费内存。
- 如果实体类结构变更,反序列化容易直接报错,兼容性差。
我强烈建议用GenericJackson2JsonRedisSerializer来做value的序列化,key用StringRedisSerializer。原因很直接:key本来是字符串,没必要转成二进制;value序列化成JSON之后,可读、可调、可兼容,RedisDesktopManager里一眼就能看到内容。
对应的配置类大概是这样:
java复制@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// key使用String序列化
StringRedisSerializer keySerializer = new StringRedisSerializer();
// value使用GenericJackson2Json序列化
GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(keySerializer);
template.setHashKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
}
这里有个重要的“为什么”:为什么要用GenericJackson2JsonRedisSerializer而不是普通的Jackson2JsonRedisSerializer?因为前者会在JSON里额外保存@class类型信息,反序列化时能还原成对应的Java对象,不需要你手工指定类型;后者则需要你自己传入目标类型,用起来限制多。代价是JSON里多一个@class字段,稍微占几个字节,但换来的通用性完全值得。
但是!这里有一个很隐蔽的坑:GenericJackson2JsonRedisSerializer反序列化时,如果目标对象里有LocalDateTime这类Java 8时间类型,会报错。因为它内部默认没有注册对应的反序列化模块。我遇到过两次,一次是缓存订单对象,一次是缓存用户信息,都是因为时间字段导致读缓存直接抛异常。解决方案有两种:一是实体里的时间字段统一用Long时间戳或String格式化值,别用LocalDateTime;二是自己基于ObjectMapper定制序列化器,在内部注册JavaTimeModule。我实践下来更推荐第二种,因为业务实体里用LocalDateTime太常见了。
2.3 缓存注解怎么用、Key怎么设计,直接影响命中率
Spring提供了一个特别好用的缓存抽象,核心是四个注解:@Cacheable、@CachePut、@CacheEvict、@Caching。很多教程只会教“在方法上加注解”,但实战里真正重要的是三件事:Key的设计、TTL的设定、失效策略的选择。
Key设计我总结出一个原则:业务前缀+业务维度+唯一标识,同时要塞一个版本号进去。比如商品详情:
java复制@Cacheable(value = "product:detail", key = "#skuId", unless = "#result == null")
public ProductDetail getProductDetail(String skuId) {
// 查库逻辑
}
这样生成的key就是product:detail::SKU123。注意Spring默认会用::分隔value和key。线上通过Redis Desktop Manager搜索时,用product:detail::*就能模糊查询到所有商品缓存,排查问题非常方便。
版本号怎么塞?我见过最稳的做法是常量的方式,比如product:detail:v1:作为value前缀。为什么需要版本号?因为你的数据结构一旦变了,旧缓存就是脏数据。比如原来缓存的字段叫price,后来改名成amount,如果直接用旧的key继续读,反序列化直接失败或读到旧语义的数据。加版本号之后,升级代码的同时换一个版本号,旧缓存自然作废,比一个个删除key靠谱一万倍。
TTL设置这块,我建议遵循业务容忍度决定TTL长度的原则。能容忍5分钟延迟的数据,TTL就设5分钟;只能容忍几秒的,TTL就设短一点。这里要重点提醒:TTL不能设死,最好随机偏移。比如所有商品缓存都是3600秒,那么整点过期时,所有商品缓存同时失效,大量请求同时打到数据库,这就是缓存雪崩。正确做法是TTL加随机数,比如3600 + RandomUtil.randomInt(0, 300),把过期时间打散。
@CacheEvict也有讲究。更新数据库后必须删除缓存,但要注意是删除还是更新。我个人的习惯是先更新数据库,再删除缓存,理由后面讲缓存一致性的时候详细说。注解上一般用beforeInvocation = false(默认值),确保数据库操作成功之后再删缓存,避免数据库更新失败但缓存已经被删掉的场景。
3. 分布式锁:三种实现方式、选型逻辑与实战代码
3.1 从SETNX的正确姿势说起
分布式锁最基础的实现就是Redis的SETNX命令,全称是SET if Not eXists。本质就是一条原子命令:如果key不存在,就设置成功;如果key已经存在,就设置失败。基于这个特性,你可以在所有需要互斥的代码路径里,先尝试获取锁,只有拿到锁的线程才能继续执行。
但这个命令的用法有非常多的细节,网上90%的教程写的都是错的。我先给一个标准的正确写法:
java复制public boolean tryLock(String lockKey, String requestId, long expireSeconds) {
// 注意:必须使用SET命令的扩展参数,不能拆成两步
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireSeconds));
return Boolean.TRUE.equals(result);
}
public boolean releaseLock(String lockKey, String requestId) {
// 释放锁必须校验持有者,不能直接删除
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of(lockKey),
requestId
);
return Long.valueOf(1).equals(result);
}
这里有两个关键点,每一个都是血的教训:
第一,加锁必须原子性。早年间很多人先SETNX再EXPIRE,两条命令中间线程挂了,锁永远不释放,所有人都拿不到锁,业务直接瘫痪。所以必须用一条命令同时完成“设值+过期时间”,这也是为什么Spring的setIfAbsent支持传入Duration。
第二,释放锁必须校验持有者。假设线程A拿到锁,执行时间超过过期时间,锁自动失效了。线程B随后拿到同一把锁开始执行。这时候线程A执行完了,它如果直接DEL删除锁,等于把线程B的锁给删了,线程C又能进来,锁的保护彻底失效。所以释放锁之前,必须确认当前锁的value是自己的requestId,这是经典的“只能解自己的锁”问题。上面的Lua脚本就是干这个的:先GET校验,再DEL删除,两个操作放在Lua脚本里由Redis保证原子执行。
requestId怎么生成?最简单的是UUID.randomUUID().toString(),每个线程都不同,作为锁的持有者身份标识。
3.2 Redisson:有现成的轮子,就别自己造
SETNX实现虽然能跑,但有几个硬伤:
- 锁没有可重入性,同一个线程如果递归调用或者嵌套加锁,第二次加锁会失败。
- 没有自动续期机制,如果业务执行时间超过锁的过期时间,锁就提前失效了。
- 主从模式下,如果master节点宕机,锁还没同步到slave,新master上没有锁数据,其他线程就能拿到锁,出现两个线程同时持有锁。
这些问题不用自己解决,业内已经有非常成熟的方案,就是Redisson。Redisson是Redis官方推荐的Java客户端,封装了完整的分布式锁功能。加锁核心就一行:
java复制RLock lock = redissonClient.getLock("inventory:lock:" + skuId);
boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS);
if (locked) {
try {
// 业务代码
} finally {
lock.unlock();
}
}
tryLock三个参数分别是:等待时间、锁自动过期时间、时间单位。这里最重要的是理解Redisson的看门狗机制:如果你不传锁的过期时间(或传-1),Redisson会启用看门狗,给锁默认30秒的过期时间,然后每10秒检查一次,如果业务线程还活着,就自动把过期时间重置为30秒。也就是说,只要你的业务不结束,锁就不会因为超时被释放,彻底解决了“业务没执行完锁先没了”的问题。
还有一个大杀器:tryLock里的等待时间。拿不到锁的线程会在本地阻塞等待,直到等待超时或者拿到锁。这比SETNX那种“拿不到就立即返回失败”的体验好得多,特别适合那些需要严格执行顺序的任务。比如库存扣减,如果100个请求同时来,第1个拿到了锁,剩下99个可以等,等前面的处理完再依次进入,而不是直接失败,这样处理的成功率就高很多。
Redisson里我还经常用到RLock的两个扩展:可重入锁和公平锁。
可重入锁解决的是同一个线程多次加锁问题。比如A方法加了锁,方法里又调用了B方法,B也要加同一把锁,那就必须能重入,否则同一线程自己把自己锁死了。Redis的SETNX做不到这个,但Redisson的RLock天然支持重入,内部用计数的方式维护。
公平锁解决的是线程排队问题。默认的锁是非公平的,后到的线程可能抢在前面等待的线程之前拿到锁(因为Redis没有排队概念)。公平锁会让所有等待的线程按照顺序依次获取锁。在电商秒杀、订单对账这类业务场景里,公平锁能避免“插队”导致的业务单据顺序错乱。
3.3 锁粒度怎么选:越细越好还是一把锁到底?
分布式锁的设计里,锁的粒度直接决定并发度。很多人一上来就给整个操作加一把全局锁,比如lock:order,结果所有订单创建全部串行化,性能还不如不加锁。但锁太细也有问题,比如lock:order:userId_001,如果两个用户在同一个订单上做操作,这个锁管不住。
我的经验是:锁的粒度应该对齐到业务冲突的最小范围。扣库存锁商品skuId,下单锁用户userId,转账锁账户accountId。核心原则是“两个并发操作是否真的会互相影响”,如果不会,就不应该共享同一把锁。
从Redisson的锁命名上也能看出粒度设计痕迹:
java复制// 库存锁,粒度为单个SKU
RLock lock = redissonClient.getLock("stock:deduct:" + request.getSkuId());
// 账号锁,粒度为账号
RLock lock = redissonClient.getLock("account:transfer:" + request.getAccountId());
这种方法在系统设计上尤其重要:如果锁粒度过粗,即使你用了Redis缓存加速,系统的吞吐也会被锁的排队严重制约;粒度过细,又可能在某些业务路径上出现漏锁问题。所以每个锁在命名之前,都要先问一句:这个锁到底在保护什么资源?只有明确了资源边界,锁的粒度才是合理的。
4. 缓存穿透、击穿、雪崩、一致性:分布式锁用在哪一步是关键
4.1 缓存穿透:用缓存解决不了的请求,才需要锁?
先说穿透。穿透是指一个请求查询的数据在数据库里也不存在,缓存里更不存在,所以请求直接打到数据库。如果这种请求量很大,数据库很容易被打垮。常见的防护手段有两个:参数校验和空值缓存。
参数校验最简单:非法的数据范围(比如负数ID)直接拦截,不让它进入缓存和数据库查询逻辑。空值缓存是我更推荐的:如果查到数据库结果为空,也把这个“空”缓存起来,TTL设置短一些,比如30秒到2分钟。这样同一个不存在的key,后续的查询直接命中缓存,不会穿透到数据库。
但这里有一个很有意思的问题:空值缓存也需要防并发吗?答案是需要的。设想一个不存在的商品ID,第一次查询100个请求同时进来,缓存里没有空值,结果100个请求全部打到数据库。数据库每一个查询都要走一遍完整流程,长时间下去肯定会出问题。所以防穿透的核心不只是缓存空值,还要在缓存空值的构建过程上做并发防护。
4.2 缓存击穿:分布式锁在这里大显身手
击穿和穿透的区别是:击穿是指缓存key过期的一瞬间,大量请求同时访问这个key,由于缓存里没有数据,这些请求一起落到数据库。最典型的就是热点新闻、秒杀商品的详情数据。
面对击穿,分布式锁是最常用的解决手段。思路是:只允许一个请求去重建缓存,其他请求等待或者返回旧值。这里给出一个我实际项目中常用的双检锁(Double-Check + Lock)写法:
java复制public ProductDetail getProductDetail(String skuId) {
// 第一次检查缓存
ProductDetail detail = getFromCache("product:detail:" + skuId);
if (detail != null) {
return detail;
}
// 尝试获取分布式锁
RLock lock = redissonClient.getLock("product:detail:lock:" + skuId);
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
// 拿不到锁,先返回一个降级数据或者重试
return ProductDetail.getDegradedDetail(skuId);
}
try {
// 拿到锁之后,再次检查缓存(双检)
detail = getFromCache("product:detail:" + skuId);
if (detail != null) {
return detail;
}
// 这里才真正去查数据库
detail = loadFromDatabase(skuId);
if (detail == null) {
// 空值缓存,防止穿透
setToCache("product:detail:" + skuId, ProductDetail.getEmptyDetail(), 60, TimeUnit.SECONDS);
} else {
setToCache("product:detail:" + skuId, detail, 3600, TimeUnit.SECONDS);
}
return detail;
} finally {
lock.unlock();
}
}
为什么拿到锁之后还要再次检查缓存?这就是经典的“双检锁”思想。原因很简单:线程A拿到锁去查数据库,还没回填缓存,线程B已经等到了锁。如果B不检查缓存,又会重复查一遍数据库。虽然B能拿到锁,但查库的操作完全没有必要。有了双检,只有第一个拿到锁的线程真正查库,后边等锁的线程拿到锁之后直接命中缓存返回,数据库压力就会小很多。
4.3 缓存一致性:更新数据库和缓存顺序,90%的团队都纠结过
这是缓存领域最经典的话题。目前业界最权威的方案是“Cache Aside(旁路缓存)”模式,核心就两条:
- 读:先读缓存,缓存没有就读数据库,然后回填缓存。
- 写:先更新数据库,然后删除缓存(而不是更新缓存)。
为什么是删除而不是更新?因为更新缓存非常容易产生脏数据。比如线程A读旧值,线程B更新数据库成新值,线程A再回填缓存,此时缓存里就是旧值。如果只是删除缓存,下次读请求自然触发回填,问题就不存在了。
但“先更新数据库,再删除缓存”也有并发窗口:线程A更新数据库成功,还没来得及删缓存,线程B读到了旧缓存。这个问题理论上是存在的,实际中概率取决于“更新数据库”和“删除缓存”之间的时间差。怎么进一步降低这个概率?我见过两个有效做法:
- 延迟双删:更新数据库之后,先删除一次缓存,然后延迟几百毫秒,再删除一次缓存。这个延迟时间要大于“一个读请求的回填时间”,确保读请求已经把旧数据读走、回填完成,第二次删除把旧值清掉。
- 异步删除:不直接在业务代码里删除缓存,而是把删除动作发给消息队列,消费者异步去删除。这样即便删除失败,也可以重试。
还有一个很多人容易忽略的点:缓存过期时间本身就是一致性的“兜底机制”。无论你用了多完美的删缓存策略,只要TTL存在,最坏情况下旧的缓存数据撑死存活一个TTL周期,系统最终是一致。所以TTL不应该设得太长,从一致性的角度看,热点缓存TTL设5到15分钟是比较合理的平衡点。
5. 实战:一个完整的库存扣减场景(带缓存与分布式锁)
前面理论聊得不少,这里我完整走一遍库存扣减的实战代码,把上面提到的缓存、分布式锁、异常处理全部串起来。
5.1 场景定义与数据流
场景:电商系统扣减商品库存。表结构是product_stock,字段有sku_id、stock、version。业务要求:
- 库存数据需要支持高并发读取,所以有热点缓存。
- 扣减库存必须原子,不能超卖。
- 同一个商品的一次扣减操作,多个实例同时发起时只能有一个成功。
数据流如下:
- 请求进来,先查缓存里的库存快照,如果缓存有直接返回(但是扣减不能基于快照)。
- 真正扣减时,走分布式锁,锁的粒度是
skuId。 - 拿到锁之后,先读一次数据库的当前库存,校验库存是否充足。
- 执行数据库update,乐观锁version做兜底。
- 扣减成功之后,删除库存缓存,下次读取自然回填新库存。
- 释放锁。
5.2 核心代码实现
依赖建议直接用Redisson,代码可读性和稳定性会好很多。配置Redisson:
java复制@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setConnectionPoolSize(16)
.setConnectionMinimumIdleSize(4);
return Redisson.create(config);
}
}
库存服务的核心方法:
java复制@Service
public class StockService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
private static final String STOCK_CACHE_KEY = "stock:snapshot:";
private static final String STOCK_LOCK_KEY = "stock:lock:";
public boolean deductStock(String skuId, int count) {
RLock lock = redissonClient.getLock(STOCK_LOCK_KEY + skuId);
boolean locked = lock.tryLock(3, 15, TimeUnit.SECONDS);
if (!locked) {
throw new BizException("系统繁忙,请稍后重试");
}
try {
// 查数据库当前库存
Integer currentStock = getStockFromDb(skuId);
if (currentStock == null || currentStock < count) {
throw new BizException("库存不足");
}
// 乐观锁扣减
int rows = deductStockInDb(skuId, count, currentStock);
if (rows == 0) {
throw new BizException("扣减失败,请重试");
}
// 扣减成功,删除缓存
stringRedisTemplate.delete(STOCK_CACHE_KEY + skuId);
return true;
} finally {
lock.unlock();
}
}
private Integer getStockFromDb(String skuId) {
// 模拟数据库查询
return 100;
}
private int deductStockInDb(String skuId, int count, int expectVersion) {
// 模拟数据库 UPDATE ... SET stock = stock - #{count}, version = version + 1
// WHERE sku_id = #{skuId} AND version = #{expectVersion}
return 1;
}
}
这段代码里有两个容易被忽略的设计点,值得单独说。
第一个是业务异常在finally里释放锁。任何业务代码抛异常,finally都会执行unlock(),不会死锁。但要注意:Redisson的unlock()如果当前线程没有持有锁,会抛出IllegalMonitorStateException。所以在tryLock成功之后,if分支内部才能包try-finally,不能把没拿到锁的情况也放进try块。
第二个是缓存与数据库的一致性。扣减库存成功之后,我直接删除缓存,而不是更新缓存。为什么?因为扣减操作是高频操作,如果每一个扣减都去更新一次缓存,缓存与数据库的写操作就会高度耦合,而且一旦并发扣减顺序和数据库执行顺序不一致,缓存就是一个错乱的值。删除缓存之后,下一次读请求自然把最新库存查出来回填,简单可靠。
5.3 缓存加载的并发控制
上面扣减逻辑处理完了,但还有一个配合点:缓存如何加载?在商品详情页高并发场景,库存快照的第一次加载可能触发击穿。所以,我在加载库存缓存时,同样用分布式锁控制:
java复制public Integer getStockSnapshot(String skuId) {
String cacheKey = STOCK_CACHE_KEY + skuId;
String value = stringRedisTemplate.opsForValue().get(cacheKey);
if (value != null) {
return Integer.valueOf(value);
}
RLock lock = redissonClient.getLock(STOCK_LOCK_KEY + skuId);
lock.lock(5, TimeUnit.SECONDS);
try {
// 双检
value = stringRedisTemplate.opsForValue().get(cacheKey);
if (value != null) {
return Integer.valueOf(value);
}
Integer stock = getStockFromDb(skuId);
// 随机TTL,防雪崩
int ttl = 600 + ThreadLocalRandom.current().nextInt(300);
stringRedisTemplate.opsForValue().set(cacheKey, String.valueOf(stock), Duration.ofSeconds(ttl));
return stock;
} finally {
lock.unlock();
}
}
注意这里加锁用的是lock.lock(5, TimeUnit.SECONDS),表示锁的租约时间是5秒。库存查询本身很快,5秒足够。如果你用默认的看门狗模式,理论上也行,但考虑到这里持锁时间极短,指定租约时间更明确,不再依赖看门狗。
这个过程中有个细节值得注意:我加锁的key跟业务锁有些重叠,都用stock:lock:前缀。这无所谓,因为锁本身的粒度是一致的——同一时刻同一sku的扣减和加载都会竞争这同一把锁,这反而保证了整个流程的串行性。
6. 常见问题与排查技巧实录
聊了这么多原理和代码,最后把我在实际项目里遇到过的、频繁踩坑的问题整理成一张速查表。这里的每一个问题都来自真实线上事故,不是教科书里推导出来的。
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 缓存里全是乱码 | 默认使用了JDK序列化 | 自定义RedisTemplate,key用String,value用GenericJackson2Json |
| 缓存读出来反序列化报错 | 对象包含LocalDateTime,Jackson无法处理 | 定制ObjectMapper并注册JavaTimeModule |
| 加了锁还是超卖 | 锁的粒度不对,或者多个操作路径没有统一用锁 | 统一锁前缀和粒度,确保所有写路径都走同一把锁 |
| 释放锁把别人的锁删了 | 没校验持有者身份就DEL | 用Lua脚本先GET再DEL,或者直接用Redisson |
| 业务执行时间长,锁提前失效 | 锁过期时间设置太短 | 用Redisson的看门狗自动续期,或者设置足够的租约时间 |
| 连接池占满,接口堆积 | max-wait为-1无限阻塞 |
设置max-wait为1~3秒,快速失败 |
| 缓存雪崩,零点大量请求打崩数据库 | 所有key的TTL相同,同一时刻过期 | TTL加随机偏移,比如基础值+0~300秒随机值 |
| 缓存穿透,恶意请求打崩数据库 | 查询不存在的key,缓存也无法命中 | 参数校验+空值缓存(短TTL) |
| 缓存和数据库长期不一致 | 更新数据库后更新缓存而非删除缓存 | 改成“先更新数据库,再删除缓存”,必要时延迟双删 |
接着挑几个最典型的排查过程详细说一下。
第一个是连接池耗尽问题。一次线上压测,我负责的服务在大流量下接口耗时从50ms涨到10秒,最后直接堆积。排查发现Redis连接池满了,所有线程都在max-wait默认的-1上无限等待。处理办法是:压测前就把连接池参数设置好,并且对Redis调用加超时熔断,超过500ms直接抛错降级。这个教训说明一个问题:任何外部依赖都不能在调用方无限等待,快速失败比无限阻塞对系统更友好。
第二个是锁提前失效导致的数据错乱。我们的第一个分布式锁版本用的是setIfAbsent + expire组合命令,锁过期时间设的20秒,但有一次业务因为调外部接口耗时30秒,锁提前释放了,另一个线程进入后抢到了锁,两个线程同时操作同一笔订单,订单状态错乱。排查的时候发现最早的问题根因是把“加锁”和“设过期时间”拆成了两步,后来改成单条原子命令并引入Redisson看门狗,问题彻底解决。
第三个是缓存写入与数据库更新的顺序问题。早期我们做“更新数据库之前删缓存”的方案,结果发现高并发下经常出现缓存旧值。后来梳理了完整流程,统一成“先更新数据库,再删除缓存,并在极热点场景延迟双删”,缓存不一致的概率降到了很低的水平。
这块还可以怎么扩展
这套缓存加锁的组合方案做完了,再往后走,有几个方向值得深入。
一是本地缓存加Redis的三级缓存架构。对于顶流热点数据,Redis网络IO也是一笔开销,可以在应用内加一层Caffeine本地缓存,读的顺序是本地缓存、Redis、数据库,但要注意本地缓存的一致性更难控制,只能靠短TTL和主动失效解决。
二是Redis主从架构下的锁安全。如果Redis本身是主从部署,master宕机自动切换到slave,此时锁数据可能还没同步,分布式锁就失效了。Redisson官方提供了RedLock多节点锁方案,但它对性能影响不小,需要评估业务场景再决定。如果不能接受锁失效,也可以引入ZooKeeper等强一致协调组件替代Redis锁,代价是引入新的中间件和运维成本。
三是缓存治理和预警。线上缓存不像数据库那样有人天天盯着,出问题不容易发现。建议在缓存读写链路上加上监控埋点:命中率、回源率、平均耗时、锁等待时间。设置告警阈值,比如回源率超过30%就预警,锁等待超过1秒就告警。这些数据能帮你提前发现缓存雪崩或击穿的苗头,比事后复盘有意义得多。
我在实际项目里最深的一个体会是:缓存和分布式锁看似是两个独立的技术点,但在真实业务里它们总是互相纠缠的。一个功能如果同时涉及缓存和锁,设计的时候绝不能把两者分开思考。库存扣减是最典型的例子:缓存解决高并发读取的问题,锁解决高并发写入的互斥问题,二者缺一不可。而且一旦引入了二者,就必须认真对待序列化、TTL、锁粒度、异常释放这些细节,否则上线就是事故的开始。把这些基础问题想清楚了,不管以后换什么中间件、什么架构,这套解决问题的思路都不会过时。
