1. 问题背景与现象分析
最近在负责一个日活百万级的电商平台项目时,遇到了一个棘手的缓存问题。系统架构采用Spring Boot + Redis + MySQL的经典组合,其中Redis承担了商品库存、秒杀活动等核心业务的高并发读写。但在大促期间,我们陆续收到用户反馈:
- 商品详情页偶尔显示库存为负数
- 下单时系统提示"库存不足",但刷新后又能正常购买
- 后台统计发现Redis缓存命中率从平均98%骤降至75%
作为核心开发人员,我立即组织团队进行问题复现。通过JMeter模拟500并发请求测试库存扣减接口,果然成功复现了数据不一致现象。更诡异的是,检查Redis和MySQL日志均未发现明显错误记录,但业务逻辑确实出现了异常。
这种"幽灵问题"在分布式系统中尤为常见——没有直接报错,但业务结果不符合预期。通常意味着存在并发控制或原子性操作方面的隐患。
2. 技术原理解析与排查思路
2.1 Redis缓存三大经典问题
在高并发场景下,缓存系统需要特别注意以下三类问题:
缓存穿透
恶意请求查询不存在的数据,导致每次请求都穿透到数据库。解决方案包括:
- 布隆过滤器预校验
- 缓存空对象(需设置较短过期时间)
缓存雪崩
大量缓存同时失效,数据库承受瞬时高压。应对策略:
- 差异化过期时间(基础时间+随机偏移)
- 热点数据永不过期+后台更新
- 熔断降级机制
缓存击穿
热点key过期瞬间,大量请求直接冲击数据库。本案的核心问题正是由此引发。
2.2 原子性操作的重要性
库存扣减的典型流程包括:
- 读取当前库存
- 检查是否充足
- 执行扣减
- 更新数据库
在单机环境下可以用synchronized保证原子性,但在分布式系统中必须依赖Redis的原子操作特性。我们原先的实现存在严重缺陷:
java复制// 非原子操作示例
Integer stock = redisTemplate.opsForValue().get(key);
if(stock > 0){
redisTemplate.opsForValue().decrement(key);
// 更新数据库
}
当100个并发请求同时执行到get()时,可能都读到stock=1,然后全部执行decrement,最终导致库存超卖。
2.3 Redis事务的局限性
很多开发者误以为Redis事务能解决原子性问题。实际上Redis事务的特点是:
- 命令按顺序执行且不会被其他命令打断
- 但不支持回滚(遇到错误会继续执行后续命令)
- 无法保证多命令的原子性(其他客户端命令可能穿插执行)
因此单纯使用MULTI/EXEC并不能解决我们的库存并发问题。
3. 解决方案设计与实现
3.1 Lua脚本原子操作
最终我们采用Lua脚本方案,将多个操作封装成原子指令:
lua复制-- KEYS[1]: 库存key
-- ARGV[1]: 扣减数量
local stock = tonumber(redis.call('GET', KEYS[1]))
if not stock or stock < tonumber(ARGV[1]) then
return 0
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
Spring Boot中集成示例:
java复制@Bean
public DefaultRedisScript<Long> stockScript() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(luaScript);
script.setResultType(Long.class);
return script;
}
public boolean deductStock(String key, int num) {
Long result = redisTemplate.execute(
stockScript(),
Collections.singletonList(key),
String.valueOf(num));
return result == 1;
}
3.2 双重校验锁优化
对于极端热点商品,我们在Lua脚本外层增加了本地锁+Redis分布式锁双重校验:
java复制public boolean safeDeduct(String key, int num) {
// 本地锁减少Redis压力
synchronized (this) {
// Redisson分布式锁
RLock lock = redissonClient.getLock("lock:" + key);
try {
lock.lock();
return deductStock(key, num);
} finally {
lock.unlock();
}
}
}
3.3 序列化统一配置
排查过程中还发现不同服务使用的序列化方式不一致:
java复制@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
// Key使用String序列化
template.setKeySerializer(new StringRedisSerializer());
// Value使用JSON序列化
template.setValueSerializer(
new GenericJackson2JsonRedisSerializer());
template.setConnectionFactory(factory);
return template;
}
}
4. 生产环境验证与调优
方案上线后,我们进行了为期一周的监控:
-
性能指标:
- 平均响应时间从230ms降至150ms
- 99线从1.2s降至800ms
- Redis QPS峰值从15k降至8k(因减少了重复查询)
-
业务指标:
- 库存不一致投诉降为0
- 秒杀活动成功率从92%提升至99.8%
-
参数调优:
- Lua脚本超时时间设置为500ms
- 分布式锁最长持有时间设置为3s
- 增加脚本执行监控告警
5. 经验总结与面试要点
5.1 踩坑心得
-
测试要模拟真实场景
单纯的功能测试无法发现并发问题,必须用JMeter等工具模拟高并发 -
监控指标要全面
除了常规的CPU/内存,还需关注:- Redis命令执行时间
- Lua脚本执行耗时
- 分布式锁等待时间
-
文档记录很重要
所有线上问题的排查过程都要形成文档,方便后续复盘
5.2 面试高频问题
当面试官问到Redis相关问题时,可以这样组织回答:
问题:如何保证Redis和数据库的数据一致性?
回答框架:
- 先说明CAP理论下无法做到实时一致
- 介绍常用方案:
- 延迟双删策略
- 基于binlog的异步同步
- 分布式事务(不推荐)
- 结合业务场景选择方案:
- 对一致性要求高的用延迟双删
- 对性能要求高的用最终一致
问题:Redis为什么快?
技术点清单:
- 内存存储
- IO多路复用
- 单线程避免锁竞争
- 高效数据结构
- 协议简单
建议在回答时结合项目经历,比如:"在我们电商项目中,通过Pipeline批量操作将QPS从1万提升到3万..."