如何设计一个分布式缓存系统?

如何设计一个分布式缓存系统?

一、缓存概述
对于缓存,相信大家已经十分熟悉了,在日常开发中,我们经常会使用到缓存。但对于缓存,这个看似简单的东西,你真的了解它吗?

1.1 什么是缓存?
缓存是一种用于存储临时数据的高速数据存储层,通过将频繁访问的数据存储在内存中,来提高数据访问性能。在计算机系统中,缓存无处不在,从 CPU 的多级缓存,到操作系统的页缓存,到浏览器的页面缓存,再到分布式系统中的数据缓存,都在发挥着重要作用。

1.2 为什么需要缓存?
• 提升性能:通过将热点数据存储在内存中,避免频繁访问数据库或磁盘,显著提高数据访问速度。
• 减轻数据库压力:降低数据库的访问负载,提高系统的并发处理能力。
• 提高系统可用性:当数据库出现故障时,缓存可以作为临时的数据源,保证系统的基本可用性。

根本原因还是 CPU、内存、磁盘的读取速度差异巨大:
• CPU L1 缓存: ~1ns
• 内存: ~100ns
• SSD: ~0.1ms
• HDD: ~10ms

1.3 适合使用缓存的场景
既然缓存能够提升系统性能,那么我们是否就可以无脑地使用缓存呢?肯定不是的。

以下这些场景才适合使用缓存:

• 读多写少的数据:如商品信息、用户信息等相对稳定的数据。
• 计算复杂的数据:如统计数据、排行榜等需要大量计算的数据。
• 高并发访问的数据:如热点新闻、热门商品等。
• 基础数据:如字典数据、配置信息等变更频率低的数据。

对于数据实时性要求不高的场景可以使用缓存,但如果要求实时性很高,建议直接使用数据库。因为现代数据库性能已经足够强大,而引入缓存会增加系统复杂度和维护成本。

1.4 Java中的缓存方案
1.本地缓存

• Map/ConcurrentHashMap:最简单的缓存实现,适合小规模数据。
• Guava Cache:Google 开源的本地缓存实现。
• EhCache:老牌的 Java 缓存框架。
• Caffeine:高性能的本地缓存库。

2.分布式缓存

• Redis:最流行的分布式缓存系统。
• Memcached:高性能的分布式内存缓存系统。

二、Caffeine 原理解析
Caffeine 是目前 Java 中性能最好的本地缓存方案,它在 Guava Cache 的基础上做了大量优化。

2.1 Caffeine 的设计思想
Caffeine 的设计目标是在保证高并发读写 性能的同时,能够最大限度地利用内存空间 。为了达到这个目标,Caffeine 在以下几个方面都做了精心的设计:

1、缓存淘汰算法的创新
• 传统的 LRU(最近最少使用)算法无法很好地处理扫描污染问题。
• 传统的 LFU(最不经常使用)算法对突发性的热点数据支持不好。
• Caffeine 创新性地采用了 Window TinyLFU 算法,结合了 LRU 和 LFU 的优点。

2、并发编程的精妙设计
• 采用了分段锁技术,降低锁粒度。
• 使用无锁编程技术,减少线程竞争。
• 优化的异步处理机制。

下面来详解下 Caffeine的Window TinyLFU算法。

2.2 Window TinyLFU 算法详解
Window TinyLFU 是 Caffeine 性能优化的关键所在,它主要包含以下几个部分:

2.2.1. 准入窗口(Admission Window)

准入窗口 的本质是一个小型的 LRU 缓存,用于过滤突发性的访问,避免对主缓存造成污染。

工作流程:
在这里插入图片描述

2.2.2.频率统计(Frequency Sketch)

Frequency Sketch 使用Count-Min Sketch 数据结构来记录访问频率,这是一个概率型数据结构,用较小的空间实现频率统计。

在这里插入图片描述
Count-Min Sketch 的工作原理:
在这里插入图片描述

2.2.3. 优化的淘汰策略

在这里插入图片描述

示例实现代码:

public classFrequencySketch {
    privatefinallong[] table;
    privatefinalint[] seeds;
    privatefinalint width;
    privatefinalint rows;
    
    publicFrequencySketch(int width, int rows) {
        this.table = newlong[width * rows];
        this.seeds = newint[rows];
        this.width = width;
        this.rows = rows;
        // 初始化随机种子
        Randomrandom=newRandom(1234);
        for (inti=0; i < rows; i++) {
            seeds[i] = random.nextInt();
        }
    }
    
    publicvoidincrement(long item) {
        // 更新所有行的计数器
        for (inti=0; i < rows; i++) {
            intindex= indexOf(item, i);
            if (table[index] < Long.MAX_VALUE) {
                table[index]++;
            }
        }
    }
    
    publiclongfrequency(long item) {
        // 获取最小计数值作为估计频率
        longmin= Long.MAX_VALUE;
        for (inti=0; i < rows; i++) {
            min = Math.min(min, table[indexOf(item, i)]);
        }
        return min;
    }
    
    privateintindexOf(long item, int row) {
        // 使用不同的哈希函数计算索引
        longhash= item * seeds[row];
        hash += hash >>> 32;
        return ((int) hash & Integer.MAX_VALUE) % width + (row * width);
    }
}

2.3 高并发设计
Caffeine 在并发处理上也做了大量优化。

2.3.1. 分段锁设计

// 简化的分段锁示意
classStripedBuffer<K, V> {
    privatefinal Object[] locks;
    privatefinal Buffer<K, V>[] buffers;
    
    publicvoidwrite(K key, V value) {
        intindex= hash(key) % locks.length;
        synchronized (locks[index]) {
            buffers[index].put(key, value);
        }
    }
}

• 降低锁粒度,提高并发性
• 减少线程等待时间
• 优化写入性能

2.3.2.异步处理机制

// Caffeine 异步加载示例
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    // 设置缓存过期时间  
    .expireAfterWrite(Duration.ofMinutes(5))
    // 设置缓存刷新时间  
    .refreshAfterWrite(Duration.ofMinutes(1))
    .build(key -> createExpensiveGraph(key));

• 支持异步加载和刷新
• 非阻塞的缓存操作
• 提高系统吞吐量

2.3.3.写入缓冲区优化

• 使用 BufferWriter 减少锁竞争
• 批量处理写入请求
• 提高写入效率

三、缓存面临的问题
虽说缓存能够提升系统性能,但与此同时,使用缓存也同样会带来很多问题。

3.1 缓存三大问题
下面是缓存的几个经典问题,面试中也经常会被问到,也确实是我们在使用缓存时需要思考和解决的问题。

1、缓存击穿
• 问题:热点 key 过期导致大量请求直击数据库
• 解决:使用分布式锁 + 二级缓存
2、缓存雪崩
• 问题:大量缓存同时过期
• 解决:过期时间随机化、多级缓存、熔断降级
3、缓存穿透
• 问题:查询不存在的数据导致请求直击数据库
• 解决:布隆过滤器、空值缓存

发现没有,上述问题,其实都可以尝试在数据库和本地缓存之间加一层中间层来解决。这也是软件架构设计中的一个常见模式 - 通过分层来化解复杂性。如果一层中间层解决不了,那就再加一层。

3.2 缓存一致性问题
其实,使用缓存面临的最核心的问题,其实是 缓存一致性问题。 这里的一致性,指的是缓存和数据库间的数据一致性,以及多个实例之间本地缓存的一致性。

有哪些场景可能会导致缓存一致性问题?

常见的一致性问题

• 更新数据库后缓存未及时更新。
• 并发更新导致的数据不一致。
• 分布式环境下的数据同步问题,例如多实例的本地缓存不一致。

解决方案

• 先更新数据库,再删除缓存
• 延时双删
• 消息队列实现最终一致性
• 分布式锁保证并发安全
• 版本号机制

四、分布式缓存架构设计
接下来我们来探讨在一个分布式系统中,应该如何来设计我们的缓存系统,以及如何解决上面提到的各种问题。

4.1 多级缓存架构
应用层

本地缓存(Caffeine)

分布式缓存(Redis)

数据库

4.2 缓存更新策略
常见的缓存更新策略有:

• Cache Aside Pattern
• 先更新数据库,再删除缓存
• Read/Write Through Pattern
• 先更新缓存,再更新数据源
• Write Behind Pattern
• 先更新缓存,异步更新数据源

这里我们选择的是 Cache Aside Pattern-先更新数据库,再删除缓存。为什么呢?

让我们通过一个具体的场景来分析下:

1.为什么是删除而不是更新缓存?

• 删除缓存的操作比更新缓存更简单,不容易出错。
• 删除缓存后,后续请求会重新加载数据,确保数据的正确性。
• 如果更新缓存,在并发情况下可能会出现数据不一致的问题。

2.为什么是先更新数据库,再删除缓存?

在这里插入图片描述

• 如果先删除缓存,再更新数据库,在高并发场景下会出现问题:

1、线程A删除缓存
2、线程B读取缓存未命中,读取旧数据
3、线程A更新数据库
4、线程B将旧数据写入缓存

• 最终缓存中是旧数据,造成数据不一致。

3.Cache Aside Pattern 策略是否存在并发问题?

• 理论上存在并发问题,如下图,但概率极小。
• 需要满足以下条件才会出现问题:

1、缓存刚好失效。
2、线程A更新数据库,尚未提交事务,此时线程B读取旧数据。
3、线程A提交事务,删除缓存。
4、线程B更新缓存。

• 实际上在高并发场景中,这个时间窗口也非常小,因为一般从已经读取到数据到设置到缓存这个间隙其实非常短,除非刚好 因GC导致STW 了等极端情况。
在这里插入图片描述

4.3 如何解决极端情况下的并发问题?
• 延迟双删:在更新数据库后,延迟一段时间再次删除缓存,可以保障经过短暂时间后,缓存中的数据和数据库中的数据一致。

    // 伪代码示例
void updateData() {
    // 1. 更新数据库
    db.update(data);
    // 2. 删除缓存
    cache.delete(key);
    // 3. 延迟一段时间后再次删除缓存
    Thread.sleep(500);
    cache.delete(key);
}

• 使用消息队列:将缓存删除操作通过消息队列异步执行。
// 伪代码示例

void updateData() {
    // 1. 更新数据库
    db.update(data);
    // 2. 发送消息到队列
    messageQueue.send(new CacheDeleteMessage(key));
}

4.4 如何使用消息队列解决极端情况下的并发问题?
延时双删能解决极端情况下的缓存一致性问题,可能很多同学都听说过,但为什么使用消息队列也可以?

1、异步处理和解耦:
• 当数据库完成更新后(事务提交后),将缓存删除 操作作为一条消息发送到消息队列。
• 消息队列保证了删除操作的顺序性(需要使用顺序消费),即使存在多个请求对同一条数据进行操作,最终的缓存状态是一致的。
2、去重和幂等性:
• 由于删除缓存的操作本身就是幂等的,所以即便消息队列重复消费,也不会对结果产生影响。
3、重试机制:
• 如果由于某些异常,删除缓存的操作失败,消息队列可以提供重试机制,确保删除操作最终完成。
4、并发场景的一致性:
• 即使在高并发场景下,多个线程同时操作同一条数据,通过消息队列的异步特性,可以确保数据库和缓存的状态最终一致。

消息队列的实现
• 每次数据库更新完成后发送消息到队列。
• 消息队列可以按照先后顺序处理缓存的删除操作,确保缓存最终的状态一致。

流程图:
在这里插入图片描述

消息队列的实现示例

@Service
publicclassCacheUpdateService {

    @Autowired
    private DatabaseService databaseService;
    @Autowired
    private MessageQueueService messageQueueService;

    publicvoidupdateDataAndNotifyCache(String key, Object value) {
        // 1. 更新数据库
        databaseService.update(key, value);
        // 2. 发送缓存删除消息 注意要在事务提交后发送
        // 这里使用 Spring 的事务管理器来确保在事务提交后发送消息
        TransactionSynchronizationManager.registerSynchronization(newTransactionSynchronization() {
            @Override
            publicvoidafterCommit() {
                messageQueueService.sendMessage("cache_delete_topic", key);
            }
        });
    }
}

消费者示例:

@Component
public class CacheDeleteConsumer {
    @Resource
    private CacheService cacheService;

    @KafkaListener(topics = "cache_delete_topic")
    public void onMessage(String key) {
        // 删除缓存,幂等操作
        cacheService.delete(key);
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小马不敲代码

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值