业务场景问题
场景问题
1.假设你们的商铺信息包含基础信息(名称 / 地址)、动态信息(评分 / 库存)、热数据(最近浏览记录),如何设计 Redis 缓存策略?
1. 基础信息(名称/地址)
- 特点:低频变更、高读取、强一致性要求低
- 数据结构:
Hash
- Key:
shop:base:{shopId}
- Field-Value:
name
-“ShopA”,address
-“Street 123”
- Key:
- 缓存策略:
- 读写流程:
- 读:优先读缓存,未命中时查DB并写入Redis(设置TTL)。
- 写:更新DB后 删除缓存(Cache-Aside模式),下次读取自动重建。
- 过期策略:设置TTL(如1天),避免冷数据长期占用内存。
- 读写流程:
2. 动态信息(评分/库存)
- 特点:高频变更、实时性要求高(尤其库存)
- 数据结构:
- 评分:
Sorted Set
(全局排序) +String
(单个店铺)- Key(全局):
shops:rating
- Member:
{shopId}
,Score:评分值(用于TOP-N查询)
- Member:
- Key(单店):
shop:rating:{shopId}
(存储当前评分)
- Key(全局):
- 库存:
Hash
(按商品SKU隔离)- Key:
shop:stock:{shopId}
- Field-Value:
sku_1001
-“50”,sku_1002
-“30”
- Field-Value:
- Key:
- 评分:
- 缓存策略:
- 评分:
- 更新:DB更新后,同步更新
String
和Sorted Set
(ZADD
)。 - 读取:直接读缓存,设置较短TTL(如5分钟)兜底。
- 更新:DB更新后,同步更新
- 库存:
- 原子性:扣减库存时用
HINCRBY
(避免超卖),DB操作成功后异步补偿(最终一致)。 - 兜底机制:设置TTL(如30秒),避免缓存故障导致长期不一致。
- 原子性:扣减库存时用
- 熔断设计:缓存失效时直接读DB,并限制并发重建请求(避免缓存击穿)。
- 评分:
3. 热数据(最近浏览记录)
- 特点:高频写入、按时间排序、无需持久化
- 数据结构:
List
或Sorted Set
- Key:
shop:views:{shopId}
- 方案选择:
List
(更省内存):- 操作:
LPUSH
新增浏览记录 +LTRIM 0 99
保留最近100条。
- 操作:
Sorted Set
(需精确时间):- Member:
userId
,Score:时间戳 - 操作:
ZADD
+ZREMRANGEBYRANK 0 -101
(保留Top 100)。
- Member:
- Key:
- 缓存策略:
- 只写缓存:浏览记录不落库,依赖Redis持久化(AOF+RDB)。
- 过期策略:设置TTL(如7天)自动清理,或依赖
List
长度控制。
4. 整体优化措施
- 内存管理:
- 分离冷热数据:基础信息与动态信息拆分Key,独立设置TTL。
- 高可用:
- 集群部署:分片存储(
cluster
模式),避免单点故障。 - 持久化策略:AOF+ 定时RDB,平衡性能与数据安全(在写时先利用RDB快照进行恢复,剩余缺失的利用AOF恢复)。
- 集群部署:分片存储(
- 一致性保障:
- 最终一致:动态数据采用异步更新(消息队列+消费者更新DB)。
- 兜底查询:缓存失效时用
Redisson
分布式锁控制单线程重建。
5. 异常场景处理
场景 | 解决方案 |
---|---|
缓存穿透 | 空值缓存(NULL +短TTL)+ 布隆过滤器 |
缓存雪崩 | TTL添加随机值(如+5min随机扰动) |
库存超卖 | Redis扣减库存后发MQ异步落库(最终一致) |
架构图
关键流程:
- 读请求优先访问Redis,未命中时查DB并回填。
- 写请求先更新DB,再操作缓存(删/更新)。
- 库存扣减:Redis原子操作 → MQ → 异步更新DB库存。
2.你们用布隆过滤器防止缓存穿透,当布隆过滤器误判(将不存在的 key 判定为存在)时,如何处理?
-
第一种把存在的判断为不存在的,我们在布隆过滤器下面加入redis的空值缓存,能够作为二次保证。
-
第二种把不存在的判断为存在,此时我们要进行错误判断,如果错误率高,我们要及时熔断避免错误扩大
3.我听你说设置逻辑过期处理缓存击穿,那么如何处理在清理逻辑获取数据时的性能问题
系统状态 | 清理策略 |
---|---|
CPU < 40% | 全速清理(1000 QPS) |
CPU 40%~70% | 限流清理(500 QPS) |
CPU > 70% | 仅处理超时>1小时的Key(100 QPS) |
4.问题:在秒杀场景中,你们用 Redisson 实现分布式锁,如何避免以下问题:锁持有时间过长导致死锁;客户端 A 获取锁后崩溃,锁无法释放;主从模式下 Redis 主节点宕机导致锁丢失(脑裂问题)。
redission的看门狗机制解决了业务时间内锁提前释放,业务结束或者宕机,自动释放锁。
主从脑裂导致锁丢失:Redlock 算法 + 故障转移
问题本质
Redis 主节点写入锁后未同步到从节点即宕机,从节点升级为主后锁状态丢失。
解决方案:Redisson RedLock(多主集群模式)
// 构建三个独立Redis实例
RLock lock1 = redissonInstance1.getLock("lock");
RLock lock2 = redissonInstance2.getLock("lock");
RLock lock3 = redissonInstance3.getLock("lock");// 创建联锁
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {// 多数节点加锁成功才算成功redLock.lock();seckillService.process(productId);
} finally {redLock.unlock();
}
RedLock 核心原理:
- 多节点独立部署:
- 3/5 个奇数 Redis 主节点(跨机房部署)
- 多数派确认:
- 需 N/2+1 个节点加锁成功(如 3 节点需 2 个成功)
- 时钟同步:
- 所有节点使用 NTP 时间同步(误差 < 10ms)
- 锁有效性计算:
- 实际有效时间 = 初始租约时间 - 获取锁耗时 - 时钟误差
脑裂场景处理:
生产部署:5 节点集群(2 个可用区),可容忍 2 个节点同时故障
四、秒杀场景增强方案
1. 锁粒度优化
-
避免全局锁:
lock:seckill:{productId}_{slot}
// 按库存分桶加锁(1000库存分10桶) int slot = userId.hashCode() % 10; RLock lock = redisson.getLock("lock:"+productId+":"+slot);
2. 锁等待熔断
if (!lock.tryLock(50, 0, TimeUnit.MILLISECONDS)) {// 快速失败返回"秒杀失败"throw new SeckillException("System busy");
}
3. 监控体系
监控指标 | 阈值 | 动作 |
---|---|---|
锁平均持有时间 | >100ms | 优化业务逻辑 |
锁等待线程数 | >100 | 扩容Redis节点 |
Watchdog续期失败率 | >5% | 检查网络/Redis负载 |
5.秒杀流程中用 Kafka 异步处理非数据库操作(如积分发放),如果 Kafka 消息丢失或重复消费,如何保证最终一致性?
一、 防御消息丢失(确保消息至少被消费一次)
-
生产者端可靠性保障
-
同步发送 + 重试机制:
ProducerRecord<String, String> record = new ProducerRecord<>("award-points", userId, orderId); try {// 同步发送,阻塞等待结果RecordMetadata metadata = producer.send(record).get();logger.info("Message sent to partition {}, offset {}", metadata.partition(), metadata.offset()); } catch (InterruptedException | ExecutionException e) {// 重试逻辑(如3次)int retries = 0;while (retries < MAX_RETRIES) {try {producer.send(record).get();break;} catch (Exception ex) {retries++;}}// 最终失败:落本地数据库待补偿saveToCompensationTable(userId, orderId); }
-
配置强化:
acks=all // 所有ISR副本确认 retries=3 // 生产者重试 enable.idempotence=true // 生产者幂等(防重复)
-
-
Broker端持久化
- 副本数设置:
replication.factor=3
(至少2个副本) - 刷盘策略:
flush.messages=1
(每条消息刷盘)
- 副本数设置:
-
消费者端防丢失
-
手动提交Offset:在业务逻辑成功执行后提交
while (true) {ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));for (ConsumerRecord<String, String> record : records) {try {awardPoints(record.key(), record.value()); // 业务处理consumer.commitSync(); // 同步提交Offset} catch (Exception e) {// 记录异常,进入重试队列sendToRetryQueue(record);}} }
-
二、 解决重复消费(保证幂等性)
核心:消费端实现业务幂等
public void awardPoints(String userId, String orderId) {// 幂等键:orderId(全局唯一)if (pointLogDao.existsByOrderId(orderId)) {log.warn("重复订单,跳过处理: {}", orderId);return;}// 业务操作(扣减库存/发积分)boolean success = pointService.addPoints(userId, 100, orderId);// 记录幂等日志if (success) {pointLogDao.insert(new PointLog(orderId, userId));}
}
幂等设计要点:
-
业务唯一标识:使用订单ID、秒杀记录ID等全局唯一键
-
幂等表/Redis:处理前检查操作记录
CREATE TABLE point_idempotent (id BIGINT AUTO_INCREMENT,order_id VARCHAR(64) UNIQUE, -- 唯一约束user_id VARCHAR(32),created_at DATETIME );
-
分布式锁:对关键操作加锁(如Redis锁)
String lockKey = "lock:point:" + orderId; if (redisLock.tryLock(lockKey, 3)) {try {awardPoints(userId, orderId);} finally {redisLock.unlock(lockKey);} }
三、 最终一致性兜底方案
- 对账系统(核心兜底)
- 定时任务:每小时扫描未发放订单
- 补偿策略:重试3次 → 人工报警
-
TTL+死信队列
// Kafka消息设置TTL headers.add("TTL", System.currentTimeMillis() + 3600000); // 1小时过期// 过期消息转入死信队列 if (System.currentTimeMillis() > parseTTL(record.headers())) {deadLetterProducer.send(record);return; }
四、 架构优化建议
-
消费端优化
-
批量消费:提高吞吐量
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 100);
-
顺序消费:相同用户ID路由到同一分区
new ProducerRecord<>("points", userId, message); // Key=userId
-
-
监控告警
-
关键指标监控:
- 消息积压量(Consumer Lag)
- 消费失败率
- 对账差异数
-
报警规则:
# 示例PromQL kafka_consumer_lag > 10000 # 积压超1万报警
-
五、 方案对比
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
幂等表+重试 | 实现简单,可靠性高 | 增加DB写入压力 | 中小流量系统 |
对账补偿 | 彻底解决数据一致 | 延迟高(小时级) | 所有金融级系统 |
事务消息 | 强一致 | Kafka不支持,需RocketMQ | 可用RocketMQ的场景 |
总结:在秒杀等高并发场景中,我推荐采用 “生产者重试 + 消费端幂等 + 定时对账” 的三层防御体系。通过这个方案,我们曾在实际业务中达到:
- 消息可靠性:
99.999%
(百万级消息/天,丢失<5条) - 处理延迟:
90%请求<200ms
- 人力成本:对账系统减少
80%
人工干预
尤其注意:幂等设计是基石,而对账系统是终极防线。在金融相关场景中,无论Kafka如何配置都必须有对账兜底。