一、缓存篇 - 重中之重!
1.1 缓存穿透、击穿、雪崩是什么?怎么解决?
这是Redis面试的必考题!也是实际工作中最容易遇到的问题!我详细说明:
一、缓存穿透(Cache Penetration)
1. 什么是缓存穿透?
用户请求的数据,既不在缓存里,也不在数据库里。导致每次请求都会穿透缓存,直接打到数据库。
举例:
用户查询id=-1的商品
→ Redis里没有
→ 去MySQL查,也没有
→ 返回空
如果有黑客不断用不存在的id查询,每次都会打到数据库,数据库可能被拖垮!
2. 怎么判断是穿透?
特征:
- 请求的key在Redis和数据库都不存在
- 大量这种请求会拖垮数据库
- 通常是恶意攻击
3. 解决方案
方案一: 缓存空对象/缺省值
java
public Product getProduct(Long id) {
// 1. 先查Redis
String cacheKey = "product:" + id;
String productJson = redisTemplate.opsForValue().get(cacheKey);
if (productJson != null) {
// 2. 缓存命中
if ("null".equals(productJson)) {
return null; // 之前查过,不存在
}
return JSON.parseObject(productJson, Product.class);
}
// 3. 缓存未命中,查数据库
Product product = productMapper.selectById(id);
if (product == null) {
// 4. 数据库也没有,缓存一个空值,防止穿透
redisTemplate.opsForValue().set(cacheKey, "null", 5, TimeUnit.MINUTES);
return null;
}
// 5. 数据库有,缓存起来
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
return product;
}
优点 : 简单,实现快 缺点:
- 会占用Redis内存(大量不存在的key)
- 如果攻击者每次用不同的id,还是会穿透
方案二: 布隆过滤器(Bloom Filter) - 推荐!
原理 : 布隆过滤器是一个很长的二进制向量,可以快速判断一个元素一定不存在 或者可能存在。
java
@Configuration
public class BloomFilterConfig {
@Bean
public RBloomFilter<String> bloomFilter(RedissonClient redissonClient) {
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("product:bloom");
// 初始化:预计10万个元素,误判率1%
bloomFilter.tryInit(100000L, 0.01);
return bloomFilter;
}
}
@Service
public class ProductService {
@Autowired
private RBloomFilter<String> bloomFilter;
// 启动时把所有商品id加入布隆过滤器
@PostConstruct
public void init() {
List<Long> allProductIds = productMapper.selectAllIds();
for (Long id : allProductIds) {
bloomFilter.add("product:" + id);
}
}
public Product getProduct(Long id) {
String cacheKey = "product:" + id;
// 1. 先用布隆过滤器判断
if (!bloomFilter.contains(cacheKey)) {
// 一定不存在,直接返回null,连Redis都不用查!
return null;
}
// 2. 可能存在,查Redis
String productJson = redisTemplate.opsForValue().get(cacheKey);
if (productJson != null) {
return JSON.parseObject(productJson, Product.class);
}
// 3. Redis没有,查数据库
Product product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
}
}
优点:
- 内存占用极小(10亿key只需要1.2GB,而Set需要几十GB)
- 查询速度快(O(k),k是哈希函数个数,一般是个位数)
缺点:
- 有一定误判率(可以通过调整参数降低到0.01%)
- 不支持删除(有些实现支持计数布隆过滤器)
方案三: 接口校验
java
// 在接口层做参数校验
if (id == null || id <= 0) {
throw new IllegalArgumentException("商品id不合法");
}
4. 实际项目经验
我们的电商项目,曾经被攻击过,大量查询不存在的商品id,QPS瞬间上万,MySQL差点挂了。
解决方案:
- 用Redisson的布隆过滤器,把100万+商品id加载进去
- 在网关层限流,同一IP 1秒内最多10次请求
- 对明显非法的参数(负数、特别大的数)直接拒绝
上线后,攻击流量被布隆过滤器拦下了99%,数据库压力骤降。
二、缓存击穿(Cache Breakdown/Hotspot Invalid)
1. 什么是缓存击穿?
一个热点key突然过期,这时有大量请求访问这个key,都打到了数据库。
和穿透的区别:
- 穿透: key不存在
- 击穿: key存在,但是过期了,而且是热点数据
举例:
双11秒杀活动,iPhone 15 Pro的库存信息缓存在Redis,key是"product:iphone15pro:stock"
设置了过期时间10分钟
10:00:00 key过期
10:00:01 10万用户同时查询库存
→ Redis里没有(刚过期)
→ 10万请求同时打到MySQL查库存
→ MySQL扛不住,挂了!
2. 解决方案
方案一: 热点数据永不过期
java
// 不设置过期时间
redisTemplate.opsForValue().set("product:iphone15pro:stock", "1000");
// 或者设置一个逻辑过期时间
ProductCache cache = new ProductCache();
cache.setData(product);
cache.setExpireTime(System.currentTimeMillis() + 10 * 60 * 1000); // 10分钟后逻辑过期
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cache));
// 查询时判断逻辑过期时间
ProductCache cache = JSON.parseObject(cacheJson, ProductCache.class);
if (cache.getExpireTime() < System.currentTimeMillis()) {
// 过期了,异步刷新
threadPool.submit(() -> refreshCache(id));
// 先返回旧数据
return cache.getData();
}
优点 : 完全避免击穿 缺点:
- 可能返回过期数据
- 内存占用,数据不会自动清理
方案二: 互斥锁(Mutex) - 最常用!
思路: 缓存失效时,不是所有请求都去查数据库,而是只有一个请求去查,其他请求等待。
用SETNX实现:
java
public Product getProductWithMutex(Long id) {
String cacheKey = "product:" + id;
String lockKey = "lock:product:" + id;
// 1. 查缓存
String productJson = redisTemplate.opsForValue().get(cacheKey);
if (productJson != null) {
return JSON.parseObject(productJson, Product.class);
}
// 2. 缓存未命中,尝试获取锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
try {
if (locked) {
// 3. 获取到锁,查数据库
Product product = productMapper.selectById(id);
if (product != null) {
// 4. 写缓存
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
} else {
// 5. 没获取到锁,等一会再查缓存
Thread.sleep(100);
return getProductWithMutex(id); // 递归重试
}
} finally {
// 6. 释放锁
if (locked) {
redisTemplate.delete(lockKey);
}
}
}
用Redisson实现(更优雅):
java
@Autowired
private RedissonClient redissonClient;
public Product getProductWithRedisson(Long id) {
String cacheKey = "product:" + id;
String lockKey = "lock:product:" + id;
// 1. 查缓存
String productJson = redisTemplate.opsForValue().get(cacheKey);
if (productJson != null) {
return JSON.parseObject(productJson, Product.class);
}
// 2. 缓存未命中,加锁
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock(10, TimeUnit.SECONDS); // 10秒自动释放,防止死锁
// 3. 再次查缓存(可能其他线程已经加载了)
productJson = redisTemplate.opsForValue().get(cacheKey);
if (productJson != null) {
return JSON.parseObject(productJson, Product.class);
}
// 4. 查数据库
Product product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
} finally {
lock.unlock();
}
}
优点:
- 完全避免大量请求打到数据库
- 数据一定是最新的
缺点:
- 性能稍差(没拿到锁的请求要等待)
- 实现稍复杂
方案三: 提前刷新
java
// 定时任务,在热点数据快过期时主动刷新
@Scheduled(fixedRate = 60000) // 每分钟执行
public void refreshHotKeys() {
List<String> hotKeys = getHotKeys(); // 获取热点key列表
for (String key : hotKeys) {
Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
if (ttl != null && ttl < 300) { // 剩余时间小于5分钟
// 主动刷新
Product product = productMapper.selectById(getIdFromKey(key));
redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
}
}
3. 实际项目选择
我的项目中:
- 普通热点数据: 用互斥锁(Redisson)
- 超级热点数据: 永不过期 + 异步刷新
- 秒杀商品: 提前预热 + 永不过期
三、缓存雪崩(Cache Avalanche)
1. 什么是缓存雪崩?
大量key在同一时间过期 ,或者Redis服务宕机,导致大量请求打到数据库。
和击穿的区别:
- 击穿: 一个热点key过期
- 雪崩: 大量key同时过期
举例:
电商首页缓存了100个商品分类的数据,都设置了30分钟过期
10:00:00 100个key同时过期
10:00:01 首页请求涌入
→ 100个分类都要查数据库
→ MySQL瞬间几千QPS,直接挂了
→ 所有请求失败,雪崩了!
2. 解决方案
方案一: 过期时间打散(加随机值) - 最常用!
java
// ❌ 错误:所有key都是30分钟
redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
// ✅ 正确:加上0-5分钟的随机值
Random random = new Random();
int randomSeconds = random.nextInt(300); // 0-300秒
redisTemplate.opsForValue().set(key, value, 30 * 60 + randomSeconds, TimeUnit.SECONDS);
原理: 让key的过期时间分散在30-35分钟之间,不会同时过期。
方案二: 永不过期 + 异步刷新
java
// 和击穿的方案一样
redisTemplate.opsForValue().set(key, value); // 不设置过期时间
// 用定时任务或者逻辑过期时间异步刷新
方案三: 多级缓存
本地缓存(Caffeine/Guava Cache)
↓ 未命中
Redis缓存
↓ 未命中
MySQL数据库
@Autowired
private LoadingCache<String, Product> localCache; // Caffeine本地缓存
public Product getProduct(Long id) {
String cacheKey = "product:" + id;
// 1. 先查本地缓存
Product product = localCache.getIfPresent(cacheKey);
if (product != null) {
return product;
}
// 2. 再查Redis
String productJson = redisTemplate.opsForValue().get(cacheKey);
if (productJson != null) {
product = JSON.parseObject(productJson, Product.class);
localCache.put(cacheKey, product); // 写入本地缓存
return product;
}
// 3. 最后查数据库
product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
localCache.put(cacheKey, product);
}
return product;
}
优点 : 即使Redis挂了,本地缓存还能扛一部分流量 缺点: 本地缓存可能有数据不一致问题
方案四: Redis集群 + 哨兵
Master
↓
Slave1 Slave2 Slave3
↓
Sentinel(哨兵,监控)
原理: 即使Master挂了,哨兵会自动把Slave提升为Master,服务不中断。
方案五: 熔断降级
java
// 用Hystrix或Sentinel实现熔断
@SentinelResource(value = "getProduct", fallback = "getProductFallback")
public Product getProduct(Long id) {
// 正常逻辑
}
// 降级方法:返回默认值或从数据库查
public Product getProductFallback(Long id, Throwable e) {
log.error("Redis异常,降级处理", e);
// 返回默认商品信息或者查数据库
return getDefaultProduct();
}
3. 实际项目经验
我们去年双11期间,凌晨0点活动开始,大量缓存同时失效(之前没加随机值),导致MySQL QPS瞬间从1000涨到2万,数据库差点挂了。
紧急处理:
- 立即重启部分应用,减少请求量
- 开启Sentinel限流,QPS限制在5000
- 手动刷新热点数据到Redis
后续优化:
- 所有缓存过期时间都加随机值
- 热点数据提前预热,永不过期
- 加入Caffeine本地缓存
- 加强监控,缓存命中率低于90%就告警
四、三者对比总结表
| 问题 | 原因 | 表现 | 解决方案 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 大量请求打到DB | 布隆过滤器、缓存空值、参数校验 |
| 缓存击穿 | 热点key过期 | 瞬间大量请求打到DB | 互斥锁、永不过期、提前刷新 |
| 缓存雪崩 | 大量key同时过期或Redis宕机 | DB压力剧增,可能崩溃 | 过期时间打散、集群、降级、多级缓存 |
💡 记忆技巧:
- 穿透: 不存在穿过缓存 → 布隆过滤器挡住
- 击穿: 一个热点key被击穿 → 加锁排队
- 雪崩: 大量key崩塌 → 打散过期时间
💡 面试加分项: 一定要结合实际项目说!比如"我们双11期间就遇到过雪崩,后来怎么解决的",这样面试官会觉得你真正处理过生产问题。
1.2 如何保证缓存和数据库的一致性?
✅ 正确回答思路:
这是个非常经典且复杂的问题,我先说结论:很难做到强一致性,通常只能保证最终一致性。
一、常见的四种更新策略
策略一: Cache Aside(旁路缓存) - 最常用!
读操作:
java
public Product getProduct(Long id) {
// 1. 先读缓存
String cacheKey = "product:" + id;
String productJson = redisTemplate.opsForValue().get(cacheKey);
if (productJson != null) {
// 2. 缓存命中,直接返回
return JSON.parseObject(productJson, Product.class);
}
// 3. 缓存未命中,查数据库
Product product = productMapper.selectById(id);
if (product != null) {
// 4. 写入缓存
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
}
写操作(重点!):
java
@Transactional
public void updateProduct(Product product) {
// 1. 先更新数据库
productMapper.updateById(product);
// 2. 再删除缓存(不是更新缓存!)
String cacheKey = "product:" + product.getId();
redisTemplate.delete(cacheKey);
}
为什么是删除缓存而不是更新缓存?
假如是更新缓存:
线程A: UPDATE DB(price=100)
线程B: UPDATE DB(price=200)
线程B: SET Redis(price=200)
线程A: SET Redis(price=100) ← Redis是旧数据!
删除缓存的话:
线程A: UPDATE DB(price=100)
线程A: DEL Redis
线程B: UPDATE DB(price=200)
线程B: DEL Redis
线程C: GET → 缓存未命中 → 查DB → SET Redis(price=200) ✓
还有一个问题:为什么是先更新DB,再删除缓存?
如果先删缓存:
线程A: DEL Redis
线程B: GET → 缓存未命中 → 查DB(旧数据) → SET Redis(旧数据)
线程A: UPDATE DB(新数据)
→ Redis是旧数据,DB是新数据,不一致!
先更新DB:
线程A: UPDATE DB(新数据)
线程A: DEL Redis
线程B: GET → 缓存未命中 → 查DB(新数据) → SET Redis(新数据) ✓
但是!先更新DB也有极端情况:
线程A: 查DB(旧数据)
线程B: UPDATE DB(新数据)
线程B: DEL Redis
线程A: SET Redis(旧数据) ← 又不一致了!
不过这种情况概率极低,因为:
- 写数据库比读数据库慢
- 线程A查完DB还要SET Redis,这段时间足够线程B删除缓存了
解决方案:延时双删
java
@Transactional
public void updateProduct(Product product) {
// 1. 先删一次缓存
redisTemplate.delete(cacheKey);
// 2. 更新数据库
productMapper.updateById(product);
// 3. 延时后再删一次缓存
threadPool.schedule(() -> {
redisTemplate.delete(cacheKey);
}, 500, TimeUnit.MILLISECONDS);
}
策略二: Read/Write Through(读写穿透)
这种策略是应用只和缓存打交道,缓存负责和数据库同步。
java
// 伪代码,一般用现成的框架实现
Cache.get(key) {
if (cache.has(key)) {
return cache.get(key);
} else {
data = db.query(key);
cache.set(key, data);
return data;
}
}
Cache.set(key, value) {
db.update(key, value);
cache.set(key, value);
}
优点 : 应用逻辑简单 缺点: 需要缓存框架支持,实现复杂
策略三: Write Behind(异步写回)
原理: 更新数据时只更新缓存,缓存异步批量写回数据库。
java
// 伪代码
Cache.set(key, value) {
cache.set(key, value);
queue.add(key, value); // 加入异步队列
}
// 后台线程定期批量刷入DB
scheduler.schedule(() -> {
List<KV> batch = queue.poll(100);
db.batchUpdate(batch);
}, 1, TimeUnit.SECONDS);
优点 : 写性能极高 缺点:
- 可能丢数据(还没写DB就挂了)
- 实现复杂
策略四: Refresh Ahead(提前刷新)
原理: 预测哪些数据快过期,提前异步刷新。
适合读多写少的场景。
二、我的项目经验
1. 电商商品缓存
java
// 读:Cache Aside
public Product getProduct(Long id) {
// 先读Redis,未命中查DB并写Redis
}
// 写:先更新DB,再删除缓存
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
redisTemplate.delete("product:" + product.getId());
// 如果是热点商品,延时双删
if (isHotProduct(product.getId())) {
threadPool.schedule(() -> {
redisTemplate.delete("product:" + product.getId());
}, 500, TimeUnit.MILLISECONDS);
}
}
2. 订单状态缓存
订单状态一致性要求高,用了MQ保证:
java
@Transactional
public void updateOrderStatus(Long orderId, Integer status) {
// 1. 更新数据库
orderMapper.updateStatus(orderId, status);
// 2. 发送MQ消息
mqProducer.send("order.update", orderId);
// MQ消费者删除缓存
@RabbitListener(queues = "order.update")
public void handleOrderUpdate(Long orderId) {
redisTemplate.delete("order:" + orderId);
}
}
好处: 即使删除缓存失败,MQ会重试,最终一定会删除成功。
3. 库存缓存
库存扣减要求强一致,用了分布式锁:
java
public boolean deductStock(Long productId, Integer count) {
RLock lock = redissonClient.getLock("lock:stock:" + productId);
lock.lock();
try {
// 1. 查Redis库存
Integer stock = redisTemplate.opsForValue().get("stock:" + productId);
if (stock == null || stock < count) {
return false;
}
// 2. 扣减Redis库存
redisTemplate.opsForValue().decrement("stock:" + productId, count);
// 3. 扣减DB库存
int rows = productMapper.deductStock(productId, count);
if (rows == 0) {
// DB扣减失败,回滚Redis
redisTemplate.opsForValue().increment("stock:" + productId, count);
return false;
}
return true;
} finally {
lock.unlock();
}
}
三、实际生产的选择
| 场景 | 一致性要求 | 方案 |
|---|---|---|
| 商品信息 | 弱,允许短暂不一致 | Cache Aside |
| 用户余额 | 强,不能出错 | 不用缓存,直接查DB + 分布式锁 |
| 订单状态 | 中等 | Cache Aside + MQ保证删除 |
| 库存 | 强 | 分布式锁 + 先扣DB再扣Redis |
| 文章阅读数 | 弱 | 异步写回 |
💡 总结:
- 99%的场景用Cache Aside: 先更新DB,再删除缓存
- 极端情况用延时双删
- 强一致性用分布式锁
- 最终一致性用MQ
💡 面试加分项: 说"我们项目的商品缓存用Cache Aside,但是库存缓存用分布式锁保证强一致"。这样体现你理解不同场景要用不同方案。
📌 二、分布式锁篇
2.1 如何用Redis实现分布式锁?
✅ 正确回答思路:
分布式锁是面试的高频考点,也是实际工作中常用的功能。我从基础实现到高级方案详细说明:
一、最简单的实现(有问题!)
java
// ❌ 错误示范
public boolean lock(String key) {
// SETNX: SET if Not eXists
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "1");
return result != null && result;
}
public void unlock(String key) {
redisTemplate.delete(key);
}
问题1: 没有过期时间,如果程序崩溃,锁永远不会释放,死锁!
二、加上过期时间(还有问题!)
java
public boolean lock(String key) {
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "1");
if (result != null && result) {
// 设置10秒过期
redisTemplate.expire(key, 10, TimeUnit.SECONDS);
return true;
}
return false;
}
问题2: SETNX和EXPIRE不是原子操作,如果SETNX成功后程序崩溃,过期时间没设置上,还是死锁!
三、原子操作(还有问题!)
java
public boolean lock(String key) {
// SET key value EX 10 NX
// EX 10: 10秒过期
// NX: Not eXists才设置
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return result != null && result;
}
public void unlock(String key) {
redisTemplate.delete(key);
}
问题3: 可能删除别人的锁!
线程A: 获取锁,处理业务(耗时12秒)
10秒后: 锁自动过期
线程B: 获取到锁
线程A: 业务处理完,删除锁 ← 删掉了线程B的锁!
四、正确的实现
java
public class RedisLock {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 加锁
* @param key 锁的key
* @param value 锁的value,用UUID保证唯一性
* @param expireTime 过期时间
*/
public boolean lock(String key, String value, long expireTime) {
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, value, expireTime, TimeUnit.SECONDS);
return result != null && result;
}
/**
* 释放锁(Lua脚本保证原子性)
*/
public boolean unlock(String key, String value) {
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),
Collections.singletonList(key),
value
);
return result != null && result == 1;
}
}
// 使用
public void doSomething() {
String lockKey = "lock:order:1001";
String lockValue = UUID.randomUUID().toString(); // 唯一标识
try {
// 1. 加锁
boolean locked = redisLock.lock(lockKey, lockValue, 10);
if (!locked) {
throw new RuntimeException("获取锁失败");
}
// 2. 执行业务
processOrder();
} finally {
// 3. 释放锁(只释放自己的锁)
redisLock.unlock(lockKey, lockValue);
}
}
为什么用Lua脚本?
Lua脚本在Redis中是原子执行的,保证了"判断是否是自己的锁"和"删除锁"这两个操作的原子性。
五、还有问题:锁续期
业务处理时间不确定,可能超过10秒怎么办?
→ 锁自动过期,其他线程获取到锁,产生并发问题!
解决方案:看门狗(Watchdog)机制
Redisson框架实现了自动续期:
java
@Autowired
private RedissonClient redissonClient;
public void doSomething() {
RLock lock = redissonClient.getLock("lock:order:1001");
try {
// 1. 加锁
lock.lock(); // 默认30秒过期
// 或者
lock.lock(10, TimeUnit.SECONDS); // 指定10秒过期
// 2. 业务处理
// Redisson会自动续期!每10秒(leaseTime/3)续期一次
processOrder();
} finally {
// 3. 释放锁
lock.unlock();
}
}
Watchdog原理:
1. 加锁成功,设置30秒过期
2. 启动定时任务,每10秒检查一次
3. 如果线程还持有锁,就续期到30秒
4. 直到unlock或者线程挂了
六、Redisson分布式锁的完整用法
java
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
/**
* 1. 普通锁
*/
public void createOrder() {
RLock lock = redissonClient.getLock("lock:order");
lock.lock(10, TimeUnit.SECONDS);
try {
// 业务逻辑
} finally {
lock.unlock();
}
}
/**
* 2. 尝试加锁(不阻塞)
*/
public void tryLock() {
RLock lock = redissonClient.getLock("lock:order");
try {
// 尝试加锁,最多等待3秒,锁10秒后自动释放
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (locked) {
// 业务逻辑
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 3. 可重入锁
*/
public void reentrantLock() {
RLock lock = redissonClient.getLock("lock:order");
lock.lock();
try {
// 第一次加锁
doSomething(); // 这里面也可以加同一把锁
} finally {
lock.unlock();
}
}
private void doSomething() {
RLock lock = redissonClient.getLock("lock:order");
lock.lock(); // 同一个线程可以重复加锁
try {
// ...
} finally {
lock.unlock();
}
}
/**
* 4. 公平锁(按请求顺序获取锁)
*/
public void fairLock() {
RLock lock = redissonClient.getFairLock("lock:order");
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock();
}
}
/**
* 5. 联锁(MultiLock)
* 同时锁住多个资源
*/
public void multiLock() {
RLock lock1 = redissonClient.getLock("lock:product:1001");
RLock lock2 = redissonClient.getLock("lock:product:1002");
RLock lock3 = redissonClient.getLock("lock:product:1003");
RLock multiLock = redissonClient.getMultiLock(lock1, lock2, lock3);
multiLock.lock();
try {
// 同时锁住3个商品,防止死锁
} finally {
multiLock.unlock();
}
}
/**
* 6. 红锁(RedLock)
* 适用于Redis集群,更安全
*/
public void redLock() {
RLock lock1 = redissonClient.getLock("lock:order");
RLock lock2 = redissonClient2.getLock("lock:order"); // 另一个Redis实例
RLock lock3 = redissonClient3.getLock("lock:order"); // 第三个Redis实例
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
redLock.lock();
try {
// 至少在N/2+1个Redis实例上加锁成功才算成功
} finally {
redLock.unlock();
}
}
}
七、实际项目经验
1. 秒杀扣库存
java
public boolean deductStock(Long productId, Integer count) {
String lockKey = "lock:stock:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,最多等待0秒(不等待),锁5秒
boolean locked = lock.tryLock(0, 5, TimeUnit.SECONDS);
if (!locked) {
return false; // 获取锁失败,直接返回
}
// 扣减库存
return doDeductStock(productId, count);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
2. 防止重复下单
java
@Transactional
public Long createOrder(CreateOrderDTO dto) {
String lockKey = "lock:order:user:" + dto.getUserId();
RLock lock = redissonClient.getLock(lockKey);
lock.lock(3, TimeUnit.SECONDS);
try {
// 检查是否有未支付订单
Order unpaidOrder = orderMapper.selectUnpaidOrder(dto.getUserId());
if (unpaidOrder != null) {
throw new BusinessException("您有未支付的订单");
}
// 创建订单
Order order = new Order();
// ... 设置订单信息
orderMapper.insert(order);
return order.getId();
} finally {
lock.unlock();
}
}
3. 定时任务防止重复执行
java
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void dailyTask() {
String lockKey = "lock:task:daily";
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,不等待,锁2小时
boolean locked = lock.tryLock(0, 120, TimeUnit.MINUTES);
if (!locked) {
log.info("定时任务已在其他节点执行");
return;
}
// 执行任务
doDailyTask();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
八、分布式锁的常见问题
Q1: Redis宕机了怎么办?
A: 用RedLock,在多个独立的Redis实例上加锁,至少N/2+1个成功才算成功。或者用Zookeeper、etcd等强一致性组件。
Q2: 锁的粒度如何设计?
A:
- 粗粒度:
lock:order→ 所有订单操作都串行,性能差 - 细粒度:
lock:order:userId:productId→ 不同用户、不同商品并行,性能好
Q3: 加锁失败怎么处理?
A:
- 直接返回失败(适合秒杀等场景)
- 重试(适合一般业务)
- 加入队列异步处理
💡 总结:
- 生产环境推荐用Redisson,功能强大,久经考验
- 注意锁的粒度设计,太粗影响性能,太细容易死锁
- 一定要在finally里unlock,防止死锁
- 重要业务可以考虑RedLock或Zookeeper