1. 高并发场景下的缓存一致性问题本质
当系统面临高并发访问时,数据库往往成为性能瓶颈。以电商秒杀场景为例,某商品库存为100件,瞬时数万请求涌入时,直接查询数据库必然导致响应延迟甚至崩溃。此时引入缓存(如Redis)作为数据库前置层,理论上可以将QPS从数据库的2000提升到Redis的50000+。
但缓存与数据库的双写操作会引发经典的一致性问题。假设两个线程同时修改数据:
- 线程A更新数据库库存为99
- 线程B随后更新数据库库存为98
- 但线程B却先更新缓存为98
- 线程A最后更新缓存为99
最终缓存(99)与数据库(98)出现不一致。这种问题在面试中常被用来考察候选人对分布式系统本质矛盾的理解——性能与一致性的权衡。
关键认知:缓存不一致不是BUG而是必然现象,我们要做的是控制不一致的窗口期和影响范围
2. 缓存一致性解决方案深度对比
2.1 先更新数据库还是先删除缓存?
方案A:先删缓存再更新数据库
// 伪代码示例 redis.del(key); // STEP1 db.update(data); // STEP2问题:STEP1后STEP2前,其他线程可能把旧数据重新加载到缓存
方案B:先更新数据库再删缓存
db.update(data); // STEP1 redis.del(key); // STEP2问题:STEP1后STEP2前,其他线程可能读到缓存旧数据
实测数据显示方案B的不一致窗口更小(通常<1ms),因为数据库操作比Redis删除慢,出现并发冲突的概率更低。这是大多数互联网公司的选择。
2.2 延迟双删策略优化
针对方案B的改进版:
db.update(data); redis.del(key); // 异步延迟后再删一次 threadPool.schedule(() -> redis.del(key), 500, TimeUnit.MILLISECONDS);通过二次删除解决极端情况下缓存脏数据残留问题。延迟时间需要根据业务QPS调整,通常设置为业务平均耗时+缓冲时间。
2.3 基于binlog的最终一致性方案
大型系统常用架构:
- 更新数据库(MySQL)
- 数据库binlog被canal等中间件捕获
- 消息队列(如Kafka)异步通知缓存更新
- 消费者服务处理消息更新Redis
# 阿里Canal配置示例 canal.instance.filter.regex=.*\\..* canal.mq.topic=redis_cache_update这种方案将缓存更新与主流程解耦,但需要处理消息堆积、重复消费等问题。适合对实时性要求不高的场景。
3. 面试中的高频考点解析
3.1 缓存穿透应对策略
当查询不存在的数据时,每次都会穿透缓存击穿数据库。解决方案:
- 布隆过滤器预判key是否存在
- 缓存空对象(设置较短TTL)
// Guava布隆过滤器示例 BloomFilter<String> filter = BloomFilter.create( Funnels.stringFunnel(), 1000000, 0.01); filter.put("valid_key_1"); if(!filter.mightContain(key)) return null;3.2 热点key重建优化
高并发下某个key过期时,大量线程会同时重建缓存。解决方案:
- 互斥锁(Redis的SETNX)
- 逻辑过期(缓存永不过期,后台异步更新)
# Redis锁示例 def get_data(key): value = redis.get(key) if not value: if redis.setnx("lock:"+key, 1, 30): # 获取锁 value = db.get(key) redis.set(key, value, 300) redis.delete("lock:"+key) else: time.sleep(0.1) return get_data(key) # 重试 return value3.3 缓存雪崩预防
大量key同时失效导致请求直接打到数据库。解决方案:
- 随机过期时间(基础TTL+随机偏移量)
- 多级缓存(本地缓存+分布式缓存)
// 随机TTL示例 int baseTtl = 3600; int randomTtl = baseTtl + new Random().nextInt(600); // 3600~4200秒 redisTemplate.opsForValue().set(key, value, randomTtl, TimeUnit.SECONDS);4. 真实业务场景下的工程实践
4.1 电商库存扣减方案
秒杀场景需要同时保证库存准确性和系统性能:
- Redis预扣减(原子操作)
- 异步生成订单(MQ)
- 定时任务同步数据库
-- Redis原子扣减Lua脚本 local stock = tonumber(redis.call('GET', KEYS[1])) if stock > 0 then redis.call('DECR', KEYS[1]) return 1 -- 成功 end return 0 -- 失败4.2 金融账户余额处理
资金操作必须强一致:
- 数据库事务保证ACID
- 通过事务消息更新缓存
- 对账系统定期校验
START TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE user_id = 123; INSERT INTO transactions VALUES(123, -100, NOW()); COMMIT; -- 发送MQ消息4.3 社交feed流设计
读多写少场景的优化:
- 写时更新用户个人库
- 读时合并关注列表的feed
- 冷数据归档到HBase
# 伪代码示例 def post_content(user_id, content): # 写入个人库 db.insert(f"user:{user_id}:posts", content) # 异步推送给粉丝 followers = db.get_followers(user_id) for fid in followers[:1000]: # 只推活跃粉丝 redis.lpush(f"feed:{fid}", content)5. 避坑指南与性能调优
5.1 监控指标体系建设
必备监控项:
- 缓存命中率(低于80%需预警)
- 缓存响应时间P99(超过5ms需调查)
- 数据库负载变化(突增20%以上需介入)
Prometheus配置示例:
- name: redis_metrics metrics_path: /metrics static_configs: - targets: ['redis:9121'] metric_relabel_configs: - source_labels: [__name__] regex: 'redis_commands_duration_seconds_percentile_(.*)' target_label: percentile5.2 压测环境搭建建议
使用JMeter模拟高并发:
- 阶梯式增加线程数(100→1000→5000)
- 监控Redis连接数(超过maxclients的80%需扩容)
- 观察数据库CPU使用率(持续>70%需优化)
# Redis-benchmark测试命令 redis-benchmark -h 127.0.0.1 -p 6379 -n 100000 -c 100 -t get,set5.3 典型case分析
Case1:某社区APP点赞数显示异常
- 现象:点赞数偶尔比实际少
- 原因:先更新DB后删缓存,但缓存删除失败
- 解决:增加删除重试机制+本地缓存降级
Case2:订单状态不同步
- 现象:支付成功后页面仍显示待支付
- 原因:CDN缓存未及时刷新
- 解决:关键路径禁用CDN+版本号强制更新
6. 技术选型进阶建议
6.1 Redis vs Memcached
| 特性 | Redis | Memcached |
|---|---|---|
| 数据结构 | 丰富(5种+) | 简单(K-V) |
| 持久化 | 支持 | 不支持 |
| 集群模式 | 原生支持 | 需要客户端分片 |
| 适用场景 | 复杂缓存需求 | 简单缓存 |
6.2 本地缓存方案对比
- Caffeine:高性能Java缓存库,适合单机
LoadingCache<String, Data> cache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(key -> loadFromDB(key));- Ehcache:支持磁盘持久化,适合中型应用
6.3 新兴技术探索
- Redis模块:RedisSearch、RedisJSON等
- 持久内存:Intel Optane PMem
- 向量缓存:Milvus等专业方案
我在实际项目中发现,没有银弹方案,需要根据业务特征选择组合策略。比如交易系统采用"数据库事务+同步缓存更新",而资讯类APP则适合"异步消息队列+最终一致性"。