一、缓存这杯奶茶到底该什么时候续杯

当代互联网服务就像24小时营业的奶茶店,顾客(客户端)随时要喝新鲜奶茶(数据),店员(数据库)既要保证制作速度(响应时间)又要维持口感(数据一致性)。这时候后厨的备料台(缓存)就成了关键角色。当有人点单「波霸奶茶」时,你首先要考虑的是:

  1. 直接从备料台取波霸?(缓存命中)
  2. 波霸不够了马上现煮?(缓存穿透)
  3. 新煮好的波霸要不要立即补满备料台?(缓存更新)

这就是缓存策略的核心命题。今天我们将聚焦MySQL场景下最典型的两种缓存更新策略,就像分析奶茶店的中央厨房管理系统一样,看看Cache-Aside和Write-Through各自的绝活和软肋。

二、Cache-Aside策略:精明掌柜的生意经

1. 基本原理

这个策略的中文名叫「缓存旁路」,就像茶馆里跑堂的小二,永远遵循客人吩咐:

  • 读数据时:「客官稍等,我先看看柜台上有没有备好的茶点」(查缓存)
  • 写数据时:「掌柜的,后厨的西湖龙井到货了,需要现在摆上柜台吗?」(只更新数据库)

2. 标准操作流程

// 技术栈:Java + Spring Boot + MyBatis + Caffeine缓存
// 用户服务类
@Service
public class UserService {
    @Autowired
    private UserMapper userMapper;
    
    @Cacheable(value = "users", key = "#userId")
    public User getUserById(Long userId) {
        // 如果缓存未命中,执行数据库查询
        System.out.println("==> 执行数据库查询:" + userId);
        return userMapper.selectById(userId);
    }

    @CacheEvict(value = "users", key = "#user.id")
    public void updateUser(User user) {
        // 先更新数据库
        userMapper.updateById(user);
        // 缓存交由注解自动清除
    }
}

// 在启动类添加缓存配置
@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
                .expireAfterWrite(30, TimeUnit.MINUTES)
                .initialCapacity(100)
                .maximumSize(1000));
        return manager;
    }
}

这段代码展示了典型的Cache-Aside模式:

  • 查询时自动优先查缓存
  • 更新时先改数据库再删缓存
  • 下次查询未命中时自动回源

3. 策略亮点

  • 灵活经济:就像只在旺季多雇临时工,资源使用弹性大
  • 天然防雪崩:每个缓存项独立失效,不会集体崩溃
  • 版本控制简单:缓存与源数据解耦,更新像换菜单一样方便

4. 三大致命陷阱

  1. 缓存击穿连环案(某个热点数据失效瞬间被万人捶)
// 缓存空值解决方案
@Cacheable(value = "users", key = "#userId", unless = "#result == null")
public User getUserWithNullCache(Long userId) {
    User user = userMapper.selectById(userId);
    if(user == null){
        // 创建占位对象
        return new NullUser();
    }
    return user;
}
  1. 双写一致性迷局(数据库更新成功但缓存删除失败)
  2. 冷启动雪崩(系统重启后缓存集体罢工)

三、Write-Through策略:强迫症管家的完美主义

1. 运行原理

这种策略像严谨的英国管家,坚持所有操作必须同步完成:

  • 写数据时:必须同时更新缓存和数据库,否则宁可不做
  • 读数据时:永远信任缓存里的数据是新鲜的

2. 示范性实现

// 技术栈:Java + MyBatis + Redis
// 自定义缓存拦截器
@Component
@Intercepts({
    @Signature(type= Executor.class, method="update", args={MappedStatement.class,Object.class})
})
public class WriteThroughInterceptor implements Interceptor {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 获取更新参数
        MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
        Object parameter = invocation.getArgs()[1];
        
        // 执行原始更新操作
        Object result = invocation.proceed();
        
        // 如果更新成功,同步更新缓存
        if (result instanceof Integer && (Integer)result > 0) {
            if (parameter instanceof User) {
                User user = (User) parameter;
                String cacheKey = "user:" + user.getId();
                redisTemplate.opsForValue().set(cacheKey, user);
                System.out.println("==> 同步更新缓存:" + cacheKey);
            }
        }
        return result;
    }
}

// 用户数据访问接口
public interface UserMapper {
    @Update("UPDATE users SET name=#{name} WHERE id=#{id}")
    int updateUser(User user);
}

这个实现特点:

  • 通过MyBatis拦截器实现AOP
  • 自动捕获所有update操作
  • 确保数据库与缓存原子更新

3. 先天优势

  • 数据一致性VIP:像银行金库般严格
  • 读写性能平稳:避免剧烈波动
  • 架构简洁:减少逻辑分支

4. 三大现实挑战

  1. 写放大效应:简单修改用户昵称也要全量更新用户对象
  2. 冷数据拖累:半年没人看的用户信息霸占缓存
  3. 事务复杂度:双写操作需要考虑分布式事务

四、双雄会武:六大维度终极PK

对比维度 Cache-Aside Write-Through
一致性保障 最终一致性(可能有暂态不一致) 强一致性
系统复杂度 中(需要处理各种异常情况) 高(需保证双写原子性)
适用场景 读多写少 写密集型
性能波动 查询延迟不稳定 读写延迟可预测
数据新鲜度 可能存在旧数据 总是最新
实现成本 低(大多数框架原生支持) 高(需要自定义实现)

五、选择困难症的救命指南

1. 何时该选Cache-Aside?

  • 社交平台动态流:允许短暂的不一致(用户晚几秒看到新评论无所谓)
  • 商品详情页:可接受缓存击穿时短暂回源
  • 运营活动页面:突发流量下的弹性伸缩

2. Write-Through的黄金搭档

  • 金融账户系统:余额变动必须立即可见
  • 医疗诊疗系统:检查报告更新必须实时同步
  • 物联网控制台:设备状态需要精确同步

3. 混合模式案例

// 关键业务订单状态更新
public class OrderService {
    @Transactional
    public void payOrder(Long orderId) {
        // 1. 数据库操作
        orderMapper.updateStatus(orderId, PAID);
        
        // 2. 同步更新缓存
        redisTemplate.opsForValue().set("order:"+orderId, getLatestOrder(orderId));
        
        // 3. 异步刷新周边缓存
        mqSender.sendCacheRefreshEvent("order_detail:"+orderId);
    }
}

这种组合拳:

  • 核心状态强一致(Write-Through)
  • 周边数据最终一致(Cache-Aside)
  • 用消息队列异步更新

六、避坑者的自我修养

1. 通用生存法则

  • 缓存TTL设置建议:
    // 采用随机抖动避免集体失效
    Duration ttl = Duration.ofMinutes(30).plusSeconds(new Random().nextInt(300));
    
  • 监控缓存命中率(建议维持80%以上)
  • 分级缓存策略(本地缓存+分布式缓存)

2. 特别警示

  • 禁止魔法值删除:缓存key必须有完整命名空间
  • 防御性编程:缓存操作必须包裹熔断器
  • 容量规划:缓存使用率不超过70%

七、技术栈选型核心参考

  • Redis派:适合分布式场景,但要注意网络延迟
  • Memcached派:超高频写入场景首选
  • 本地缓存派(Caffeine/Guava):微服务内部缓存最优选

八、未来战场前瞻

  1. 新一代缓存中间件支持自动策略切换
  2. 机器学习预测缓存失效时间
  3. 持久化内存带来的革新