MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

缓存层与业务层的解耦与协同

2022-02-224.6k 阅读

缓存层与业务层解耦的必要性

在后端开发中,随着业务规模的扩大和复杂度的提升,缓存层与业务层紧密耦合会带来诸多问题。

可维护性降低

当业务逻辑发生变化时,如果缓存层与业务层紧密耦合,开发人员不仅需要修改业务代码,还可能需要在多处缓存相关代码中进行调整。例如,在一个电商系统中,商品详情页面的展示逻辑进行了优化,从单纯展示基本信息变为同时展示促销信息。若缓存逻辑与业务逻辑紧密捆绑,不仅业务代码中的获取商品详情逻辑要修改,缓存中读取和更新商品详情的逻辑也得跟着调整。这使得代码维护成本大幅增加,一处修改可能引发连锁反应,导致其他功能出现问题。

扩展性受限

随着业务的发展,系统对缓存的需求可能会发生变化。如果缓存层与业务层耦合,在引入新的缓存策略(如从本地缓存升级为分布式缓存)或更换缓存技术(如从 Memcached 切换到 Redis)时,整个业务代码都需要进行大规模重构。以一个社交平台为例,最初为了快速实现功能,采用了简单的本地缓存来存储用户关系数据。随着用户量的急剧增长,本地缓存无法满足高并发需求,需要切换到分布式缓存。若缓存与业务耦合,业务代码中涉及用户关系数据读取和更新的地方都得重写,严重影响系统的扩展性。

性能瓶颈

紧密耦合可能导致缓存更新策略不合理,影响系统性能。例如,在一些耦合设计中,业务数据更新时会立即更新缓存,在高并发场景下,频繁的缓存更新操作可能成为性能瓶颈。以一个在线订单系统为例,订单状态频繁变更(如从下单到支付成功、发货等),如果每次状态变更都即时更新缓存,可能会使缓存服务器压力过大,从而影响整个系统的响应速度。

缓存层与业务层解耦的实现方式

引入缓存代理层

缓存代理层位于业务层和缓存层之间,起到隔离和协调的作用。业务层通过调用缓存代理层的接口来操作缓存,而不直接与缓存交互。这样,业务层只关注业务逻辑,无需关心缓存的具体实现细节。

以下是一个简单的Java代码示例,展示如何通过缓存代理层实现解耦:

// 缓存代理接口
public interface CacheProxy {
    Object get(String key);
    void set(String key, Object value);
    void delete(String key);
}

// 基于Redis的缓存代理实现
public class RedisCacheProxy implements CacheProxy {
    private Jedis jedis;

    public RedisCacheProxy() {
        jedis = new Jedis("localhost", 6379);
    }

    @Override
    public Object get(String key) {
        return jedis.get(key);
    }

    @Override
    public void set(String key, Object value) {
        jedis.set(key, value.toString());
    }

    @Override
    public void delete(String key) {
        jedis.del(key);
    }
}

// 业务层代码
public class UserService {
    private CacheProxy cacheProxy;

    public UserService(CacheProxy cacheProxy) {
        this.cacheProxy = cacheProxy;
    }

    public User getUserById(String userId) {
        User user = (User) cacheProxy.get(userId);
        if (user == null) {
            // 从数据库获取用户信息
            user = getUserFromDatabase(userId);
            cacheProxy.set(userId, user);
        }
        return user;
    }

    private User getUserFromDatabase(String userId) {
        // 模拟从数据库获取用户信息
        return new User(userId, "John Doe");
    }
}

在上述代码中,UserService 业务类通过 CacheProxy 接口与缓存交互,具体的缓存实现(这里是 RedisCacheProxy)对业务层透明。业务层只负责调用缓存代理的方法,而无需关心缓存是如何存储和读取数据的。

缓存策略抽象

将缓存策略从业务逻辑中分离出来,形成独立的模块。不同的业务场景可以根据需求选择不同的缓存策略。常见的缓存策略包括:

  1. 读写穿透策略:在读取数据时,先从缓存中获取,如果缓存中不存在,则从数据库读取,并将数据写入缓存;在写入数据时,同时更新数据库和缓存。这种策略适用于读多写少,且对数据一致性要求较高的场景。

  2. 写后更新策略:在写入数据时,先更新数据库,然后异步更新缓存。这种策略可以提高写入性能,但可能会导致短时间内缓存与数据库数据不一致。适用于对数据一致性要求不是特别高,写入频繁的场景。

  3. 失效策略:设置缓存数据的过期时间,当缓存数据过期后,再次读取时从数据库获取并更新缓存。这种策略简单易行,但可能会在缓存过期瞬间引发大量数据库查询。

以下是一个Python示例,展示如何实现读写穿透策略:

import redis
import pymysql

class ReadWriteThroughCache:
    def __init__(self):
        self.redis_client = redis.StrictRedis(host='localhost', port=6379, db=0)
        self.db_connection = pymysql.connect(
            host='localhost',
            user='root',
            password='password',
            db='test',
            charset='utf8mb4'
        )

    def get(self, key):
        value = self.redis_client.get(key)
        if value is None:
            cursor = self.db_connection.cursor()
            cursor.execute("SELECT value FROM data_table WHERE key = %s", (key,))
            result = cursor.fetchone()
            if result:
                value = result[0]
                self.redis_client.set(key, value)
            cursor.close()
        return value

    def set(self, key, value):
        cursor = self.db_connection.cursor()
        cursor.execute("REPLACE INTO data_table (key, value) VALUES (%s, %s)", (key, value))
        self.db_connection.commit()
        self.redis_client.set(key, value)
        cursor.close()

通过这种方式,缓存策略与业务逻辑解耦,业务层只需要调用 ReadWriteThroughCachegetset 方法,而不需要关心具体的缓存和数据库操作细节。

缓存层与业务层的协同

尽管缓存层与业务层需要解耦,但它们之间仍需协同工作,以确保系统的高效运行和数据的一致性。

缓存更新时机的协同

  1. 实时更新:对于一些对数据一致性要求极高的业务场景,如金融交易系统中的账户余额信息,在业务数据发生变更后,应立即更新缓存。以银行转账业务为例,当一笔转账成功后,账户余额在数据库更新的同时,缓存中的余额信息也必须实时更新,以保证后续查询的准确性。

  2. 异步更新:在一些对一致性要求相对较低,但对写入性能要求较高的场景,可以采用异步更新缓存的方式。例如,在一个内容管理系统中,文章发布后,数据库中的文章数据立即更新,而缓存中的文章信息可以通过消息队列异步更新。这样可以避免因缓存更新导致的写入性能下降。

以下是一个基于Java和RabbitMQ实现异步缓存更新的示例:

// 消息生产者
public class CacheUpdateProducer {
    private ConnectionFactory factory;
    private Connection connection;
    private Channel channel;

    public CacheUpdateProducer() throws Exception {
        factory = new ConnectionFactory();
        factory.setHost("localhost");
        connection = factory.newConnection();
        channel = connection.createChannel();
        channel.queueDeclare("cache_update_queue", false, false, false, null);
    }

    public void sendCacheUpdateMessage(String key, String value) throws Exception {
        String message = key + ":" + value;
        channel.basicPublish("", "cache_update_queue", null, message.getBytes("UTF-8"));
    }

    public void close() throws Exception {
        channel.close();
        connection.close();
    }
}

// 消息消费者
public class CacheUpdateConsumer {
    private ConnectionFactory factory;
    private Connection connection;
    private Channel channel;
    private Jedis jedis;

    public CacheUpdateConsumer() throws Exception {
        factory = new ConnectionFactory();
        factory.setHost("localhost");
        connection = factory.newConnection();
        channel = connection.createChannel();
        channel.queueDeclare("cache_update_queue", false, false, false, null);
        jedis = new Jedis("localhost", 6379);

        Consumer consumer = new DefaultConsumer(channel) {
            @Override
            public void handleDelivery(String consumerTag,
                                       Envelope envelope,
                                       AMQP.BasicProperties properties,
                                       byte[] body) throws IOException {
                String message = new String(body, "UTF-8");
                String[] parts = message.split(":");
                String key = parts[0];
                String value = parts[1];
                jedis.set(key, value);
            }
        };

        channel.basicConsume("cache_update_queue", true, consumer);
    }

    public void close() throws Exception {
        channel.close();
        connection.close();
        jedis.close();
    }
}

在上述示例中,业务层在数据更新后,通过 CacheUpdateProducer 发送缓存更新消息到 RabbitMQ 队列,CacheUpdateConsumer 从队列中获取消息并更新 Redis 缓存。

缓存失效处理的协同

  1. 主动失效:业务层在某些关键业务操作后,可以主动使相关缓存失效。例如,在电商系统中,当商品价格发生变更时,商品详情页的缓存应该立即失效,以确保用户获取到最新的价格信息。这可以通过调用缓存代理层的 delete 方法来实现。

  2. 被动失效:利用缓存自身的过期机制实现被动失效。对于一些不经常变化的数据,如网站的静态配置信息,可以设置较长的缓存过期时间;而对于变化较频繁的数据,如实时新闻资讯,应设置较短的过期时间。业务层在设计缓存时,需要根据数据的特性合理设置过期时间,以平衡缓存命中率和数据一致性。

缓存层与业务层解耦与协同的最佳实践

缓存粒度设计

在设计缓存时,需要合理确定缓存粒度。如果缓存粒度过大,可能会导致缓存更新不及时,影响数据一致性;如果缓存粒度过小,又会增加缓存管理的开销。

以一个博客系统为例,如果将整个博客文章列表作为一个缓存单元,当有一篇新文章发布时,整个列表缓存都需要更新,这可能导致不必要的缓存更新操作。相反,如果以每篇文章为缓存粒度,虽然可以更精确地控制缓存更新,但在获取文章列表时,可能需要多次读取缓存,增加了缓存读取的开销。因此,在实际应用中,可以根据业务场景,选择合适的缓存粒度。例如,可以将多篇文章按分类或时间范围进行分组缓存,这样既能保证缓存更新的及时性,又能降低缓存管理的复杂度。

缓存预热

在系统启动时,对一些热点数据进行缓存预热,可以提高系统的初始响应速度。例如,在一个视频网站中,热门视频的信息在系统启动时就加载到缓存中,用户访问热门视频页面时,无需等待从数据库中查询数据,直接从缓存中获取,大大提升了用户体验。

以下是一个使用Python和Redis实现缓存预热的示例:

import redis
import pymysql

def cache_warmup():
    redis_client = redis.StrictRedis(host='localhost', port=6379, db=0)
    db_connection = pymysql.connect(
        host='localhost',
        user='root',
        password='password',
        db='test',
        charset='utf8mb4'
    )

    cursor = db_connection.cursor()
    cursor.execute("SELECT id, title, description FROM popular_videos")
    results = cursor.fetchall()

    for row in results:
        video_id = str(row[0])
        video_title = row[1]
        video_description = row[2]
        video_info = f"{video_title}: {video_description}"
        redis_client.set(f"video:{video_id}", video_info)

    cursor.close()
    db_connection.close()
    redis_client.close()

通过在系统启动脚本中调用 cache_warmup 函数,可以实现缓存预热。

缓存监控与优化

建立缓存监控机制,实时监测缓存的命中率、内存使用情况、读写性能等指标。根据监控数据,对缓存进行优化。例如,如果发现某个缓存分区的命中率过低,可能需要调整缓存策略或优化缓存数据结构;如果缓存内存使用率过高,可能需要清理过期数据或增加缓存服务器。

常见的缓存监控工具包括 Redis 自带的 INFO 命令、Prometheus + Grafana 等。通过 Prometheus 采集 Redis 的各项指标数据,然后在 Grafana 中进行可视化展示,开发人员可以直观地了解缓存的运行状态,及时发现并解决问题。

不同业务场景下的缓存设计

高并发读场景

在高并发读场景下,如电商的商品详情页、社交平台的用户资料页等,缓存的命中率至关重要。为了提高命中率,可以采用多级缓存策略。

  1. 本地缓存 + 分布式缓存:在应用服务器本地设置一级缓存(如 Guava Cache),用于存储最近访问过的数据。当请求到达时,首先从本地缓存中查找,如果未命中,则从分布式缓存(如 Redis)中获取。如果分布式缓存也未命中,再从数据库读取,并依次更新分布式缓存和本地缓存。这样可以减少对分布式缓存的访问压力,提高响应速度。

以下是一个Java示例,展示如何结合Guava Cache和Redis实现多级缓存:

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import redis.clients.jedis.Jedis;

import java.util.concurrent.TimeUnit;

public class MultiLevelCache {
    private Cache<String, Object> localCache;
    private Jedis jedis;

    public MultiLevelCache() {
        localCache = CacheBuilder.newBuilder()
               .maximumSize(1000)
               .expireAfterWrite(10, TimeUnit.MINUTES)
               .build();
        jedis = new Jedis("localhost", 6379);
    }

    public Object get(String key) {
        Object value = localCache.getIfPresent(key);
        if (value == null) {
            value = jedis.get(key);
            if (value != null) {
                localCache.put(key, value);
            } else {
                // 从数据库获取数据
                value = getDataFromDatabase(key);
                if (value != null) {
                    jedis.set(key, value.toString());
                    localCache.put(key, value);
                }
            }
        }
        return value;
    }

    private Object getDataFromDatabase(String key) {
        // 模拟从数据库获取数据
        return "Data for key " + key;
    }
}
  1. 热点数据缓存:对热点数据进行单独缓存,并设置较长的过期时间。可以通过分析用户访问日志,找出访问频率高的热点数据,将其预先加载到缓存中,并采用更可靠的缓存存储方式(如使用Redis的持久化机制),以防止热点数据丢失。

读写均衡场景

在读写均衡场景下,如内容管理系统、论坛等,既要保证读取性能,又要确保写入操作的高效性和数据一致性。

  1. 读写分离缓存策略:采用读写穿透策略结合异步缓存更新。在读取数据时,优先从缓存中获取;在写入数据时,先更新数据库,然后通过消息队列异步更新缓存。这样可以在保证数据一致性的前提下,提高写入性能。

  2. 缓存版本控制:为缓存数据设置版本号,每次数据更新时,版本号递增。在读取缓存数据时,同时获取版本号,并与数据库中的版本号进行比较。如果不一致,则从数据库重新读取数据并更新缓存。这种方式可以有效解决缓存与数据库数据不一致的问题。

高并发写场景

在高并发写场景下,如实时数据统计系统、物联网数据采集系统等,缓存的主要作用是减轻数据库的写入压力。

  1. 缓存合并写入:将多个写操作合并为一个批量操作。例如,在物联网数据采集系统中,传感器会频繁上传数据,这些数据可以先缓存到内存队列中,当队列达到一定长度或经过一定时间间隔后,将队列中的数据批量写入数据库。这样可以减少数据库的写入次数,提高写入性能。

  2. 使用写后更新策略:先将数据写入数据库,然后异步更新缓存。由于在高并发写场景下,对数据一致性要求相对较低,可以容忍短时间内缓存与数据库数据不一致的情况。通过异步更新缓存,可以避免因缓存更新导致的写入性能瓶颈。

缓存层与业务层解耦与协同中的常见问题及解决方法

缓存雪崩

缓存雪崩是指在某一时刻,大量的缓存数据同时过期,导致大量请求直接访问数据库,使数据库压力骤增,甚至可能导致数据库崩溃。

  1. 随机过期时间:在设置缓存过期时间时,采用随机的过期时间,避免大量缓存同时过期。例如,原本设置缓存过期时间为1小时,可以改为在30分钟到1个半小时之间随机取值。

  2. 热点数据永不过期:对于一些热点数据,如电商平台的热门商品信息,可以设置为永不过期,但需要在数据发生变更时及时更新缓存。

缓存穿透

缓存穿透是指查询一个不存在的数据,由于缓存中没有,每次都会查询数据库,若恶意用户频繁发起这种查询,可能导致数据库压力过大。

  1. 布隆过滤器:在缓存之前使用布隆过滤器,布隆过滤器可以快速判断一个数据是否存在。如果布隆过滤器判断数据不存在,则直接返回,不再查询数据库。布隆过滤器有一定的误判率,但可以通过合理设置参数来降低误判率。

  2. 空值缓存:当查询数据库发现数据不存在时,将空值也缓存起来,并设置较短的过期时间,这样下次查询同样的数据时,直接从缓存中获取空值,避免再次查询数据库。

缓存击穿

缓存击穿是指一个热点数据在缓存过期的瞬间,大量请求同时访问该数据,导致这些请求全部落到数据库上。

  1. 互斥锁:在缓存过期时,使用互斥锁(如Redis的SETNX命令)保证只有一个请求可以查询数据库并更新缓存,其他请求等待。当第一个请求更新完缓存后,其他请求再从缓存中获取数据。

  2. 热点数据不过期:与缓存雪崩中的热点数据永不过期策略类似,对热点数据设置不过期,通过单独的更新机制保证数据的一致性。

通过合理地实现缓存层与业务层的解耦与协同,以及妥善处理上述常见问题,可以构建出高效、稳定且可扩展的后端系统,满足不同业务场景的需求。在实际开发中,需要根据具体的业务特点和系统架构,灵活选择和应用这些技术和策略。