面试题-----延时双删

04day

1.项目-用户注册组件库解决缓存穿透详细说一下

缓存穿透查询一个缓存和数据库都不存在的数据,导致每次请求都到数据库,数据库压力增大

缓存空对象:先查缓存  缓存没有  锁 重建缓存  查询数据库  缓存null   设置过期5分钟 

布隆过滤器:布隆过滤器先做个判断   可能存在 和  一定不存在     数据  多个hash函数运算   对应数组位1    取的时候如果都为1 则可能存在  如果都是0  一定不存在

2.redis和数据库的一致性

考点:Cache-Aside 异常。
标准答:
① 写:先删缓存 → 再写 DB → 延迟双删(异步 500 ms 再删一次);
② 读:缓存未命中查库并 SET 新值 + 随机 TTL 防并发重建;
③ 用 Canal 监听 binlog 做兜底补偿。
背诵:先删后写再延迟删,Canal 兜底

T1:A 写,删缓存
T2:B 读,缓存 miss,读旧 DB
T3:B 把旧值 SET 回缓存
T4:A 写 DB 成功

我们当前阶段只用延迟双删,代码 10 行搞定;
如果以后主从延迟或量级上去,随时加 Canal 做实时补偿,业务无侵入,复杂度可控

@Transactional
public void updateItem(Long id, String newVal) {
    redis.del(key(id));                // ① 先删
    itemMapper.update(id, newVal);     // ② 写 DB
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                CompletableFuture.delayedExecutor(500, MILLISECONDS)
                                 .execute(() -> redis.del(key(id))); // ③ 延迟删
            }
        });
}

第二次删失败?
→ 钥匙失效/Redis 抖动,由 Canal 兜底3 min 对账任务补偿

@Component
@Slf4j
public class CacheReconcileJob {

    @Autowired
    private StringRedisTemplate redis;

    @Autowired
    private ItemMapper itemMapper;   // MyBatis 接口

    private static final long SCAN_MINUTES = 3;          // 最近 3 分钟
    private static final Duration LOCK_TTL = Duration.ofMinutes(5);
    private static final String LOCK_KEY = "job:cache:reconcile";

    @Scheduled(fixedDelayString = "${job.reconcile:180000}") // 3 min 跑一次
    public void run() {
        // 分布式锁(可选):防止多实例重复扫
        Boolean locked = redis.opsForValue()
                              .setIfAbsent(LOCK_KEY, "1", LOCK_TTL);
        if (!Boolean.TRUE.equals(locked)) {
            log.info("对账任务已存在,本次跳过");
            return;
        }

        try {
            // 1. 查最近更新过的记录
            LocalDateTime start = LocalDateTime.now().minusMinutes(SCAN_MINUTES);
            List<Item> list = itemMapper.listUpdatedAfter(start);

            for (Item item : list) {
                String cacheKey = "item:" + item.getId();
                String cacheValue = redis.opsForValue().get(cacheKey);

                // 2. 缓存缺失或值不一致 → 重新 SET
                if (cacheValue == null || !cacheValue.equals(item.getValue())) {
                    long ttl = ThreadLocalRandom.current().nextInt(300, 601);
                    redis.opsForValue().set(cacheKey, item.getValue(), ttl, TimeUnit.SECONDS);
                    log.warn("对账修复 key={}, ttl={}s", cacheKey, ttl);
                    continue;
                }

                // 3. TTL 太短(<30s)也重新 SET,防止瞬间又 miss
                Long ttl = redis.getExpire(cacheKey, TimeUnit.SECONDS);
                if (ttl != null && ttl < 30) {
                    long newTtl = ThreadLocalRandom.current().nextInt(300, 601);
                    redis.opsForValue().set(cacheKey, item.getValue(), newTtl, TimeUnit.SECONDS);
                    log.warn("对账刷新 TTL key={}, newTtl={}s", cacheKey, newTtl);
                }
            }
        } finally {
            redis.delete(LOCK_KEY);
        }
    }
}

异常场景复盘

  1. 第二次删失败?
    → 钥匙失效/Redis 抖动,由 Canal 兜底3 min 对账任务补偿。

  2. 主从延迟 > 500 ms?
    → 把 sleep 改成 1 500 ms;或强制读主库

    java

    复制

    @Select("SELECT * FROM item WHERE id = #{id} FOR SHARE")
    Item getFromMaster(@Param("id") Long id);
  3. 窗内并发重建?
    → 加分布式锁(Redisson)只允许一个线程回源:

    java

    复制

    RLock lock = redisson.getLock("item:rebuild:" + id);
    if (lock.tryLock(0, TimeUnit.SECONDS)) {
        try {
            if (redis.get(key) == null) {   // 双重判定
                Item item = itemMapper.getFromMaster(id);
                redis.opsForValue().set(key, JSON.toJSONString(item),
                                          RandomUtil.nextInt(300, 600), TimeUnit.SECONDS);
            }
        } finally {
            lock.unlock();
        }
    }

六、面试 30 秒口诀

“先删缓存再写库,事务提交后睡五百毫秒再删一次;窗口内允许脏,窗后一定新;延迟改主库或 Canal 兜底,最终一致。”

终极兜底:Canal 监听 binlog

架构:

MySQL 主库 → binlog → Canal Server → MQ → 缓存服务

流程:

  1. Canal 伪装成从库,实时拉 binlog;

  2. 解析到 update items set val = ? where id = ?

  3. 把消息扔到 RocketMQ/Kafka;

  4. 缓存服务消费 → 直接 del cacheKey(id)

  • 窗口不再靠“猜”,真正以主库变更为准

  • 延迟降到毫秒级;

  • 即使延迟双删第二次失败,Canal 也会最终补偿

三、标准解法 1:延迟双删(De-Del)

思路:
给「读脏」留一个时间窗,窗口内允许脏存在,窗口结束再删一次,最终一致

时间窗多长?
T = 主从延迟 + 业务处理耗时 + 网络抖动,一般500 ms 起步,可配置。

代码(Spring 事务模板):

java

复制

@Transactional
public void updateItem(Long id, String newVal) {
    // ① 先删缓存
    redis.del(cacheKey(id));

    // ② 写 DB(主库)
    itemMapper.update(id, newVal);

    // ③ 提交事务后,异步延迟再删一次
    transactionManager.commit(status);
    CompletableFuture.runAsync(() -> {
        try { Thread.sleep(500); } catch (InterruptedException e) { /*ignore*/ }
        redis.del(cacheKey(id));          // 兜底删
    }, delayExecutor);
}

注意:

  • 第二次删必须在事务提交之后,否则删完又立刻被旧值回填;

  • 如果主从延迟>500 ms,就要调大窗口或直接用 Canal。

二、when:窗口到底多长?

窗口 T = 主从同步延迟 + 业务SQL耗时 + 网络抖动
线上经验值:

表格

复制

场景T
单机/主库读写300 ms
一主一从,无大事务500 ms
一主多从+大事务1 000 ms

口诀:先测 Seconds_Behind_Master 峰值,再乘 2 即可

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值