关于二级缓存之间同步问题的思考

前言

近两篇博客写的都是与数据缓存相关的,这篇还是继续缓存相关的话题,主要是二级缓存间的数据同步问题。

缓存可以分为本地缓存(进程内)和分布式缓存(进程外),单独用其中一种是比较常见的。

组合起来用的,或许也有不少企业在用!本文要讨论的内容是属于这种组合起来用的情形。

先简单啰嗦一下什么是二级缓存?

何为二级缓存?

二级,可以理解成有两个不同的级别。二级缓存,可以理解成有两个不同级别的缓存。

甚至三级,四级也是同样的概念。这里可以看看CPU的多级缓存概念,很相似。

第一级的缓存一般指的就是进程内的缓存,也就是常说的本地缓存!

在传统的ASP.NET网站中,我们用的最多的可能就是HttpRuntime.Cache

在ASP.NET Core中,常用的就是MemoryCache这些。当然也可以自己用ConcurrentDictionary去实现一个定制版的。

第二级的缓存指的是进程外的缓存,这一级往往是我们说的分布式缓存,也就是常用的Redis、Memcached这些。

二级缓存的用法还是比较常规的,先从本地缓存中取,没有命中就去分布式缓存中取,要是再没命中,只能去数据库取咯。

需要注意的是,本地缓存的容量肯定是远不如分布式缓存大的,所以本地缓存中的缓存数据是相对比较热点的数据。不然应用服务器的内存就会爆掉!!

到这里,有人可能会问这样一个问题,直接用Redis或Memcached不就好了吗?它们的效率又不会差,没事扯多一个本地缓存干嘛?

首先,需要说明的是,直接用Redis或Memcached是绝对没有问题的,毕竟已经有那么多成功的案例了,我们也从中受益了不少。

至于扯多一个本地缓存,是因为在使用Redis、Memcached的时候,是需要建立远程的连接,这里也是还需要花一定的时间的。

毕竟在当前服务器的内存中取数据肯定是比在另一台服务器的内存中取要快很多的!!

针对不同缓存的选择,可能还会涉及序列化与反序列化的过程。对于这些的耗时,我们还是有必要处理一下的。

当然在用了二级缓存之后,也会引发一些问题。最主要的还是级间缓存的同步问题。

下面我们先通过一个简单的例子看看这个问题是怎么产生的。

简单案例

现在有一个商品详情页(后面简称为单品页)的站点,主要是用于向用户展示商品数据,它有三台负载。

每台负载都有使用了本地缓存去缓存热门的商品数据,当本地缓存没有命中的时候,会从Redis(Cluster或主从)中读取数据。

还有一个商品资料管理的平台(后面简称平台),用于维护相应的商品资料数据。它有两台负载。

当管理人员在平台更新了商品资料之后,会将更新后的数据写进Redis。便于单品页读到这些最新的数据。

注:单品页在这个案例中仅是用作展示作用,并不包含下单之类的操作,它的数据来源有两块,一个是本地数据缓存,一个是Redis。

下面我们思考一个问题:

当在平台更新了商品资料后,缓存数据会不会出现问题?

当然这个是需要分情形来讨论的。

情形一:

当在平台更新商品资料后,会同时操作Redis中的数据,以确保Redis中的数据是最新的。

这个时候,对于分布式缓存是没有产生影响的!

情形二:

假设单品页三台负载的本地缓存中,都没有平台刚更新的那个商品资料的缓存信息。

在这个时候,从Nginx进来的任何一个请求,都不会直接命中本地缓存,都是需要从Redis中去获取这个数据。

由于Redis中的数据是最新的,从而也就说明,这种情形也是不会对系统产生影响的!

情形三:

如果说单品页这三台负载,有其中一台或多台负载的本地缓存中已经有了那个刚更新的商品资料的缓存(这里的缓存数据是更新前的旧数据)。

这个时候,当用户打开这个商品的单品页时,可能会从某台负载的本地缓存中读取到这个旧的商品数据。

从而也就造成用户看到的商品数据与实际并不相符!!试想一下,如果说正确的价格是1000,而在缓存中的数据是10。

那么一不小心可能就损失了几个亿,今年的年终奖说不定也黄了。

可想而知,如果缓存处理不得当,那会对我们的系统造成十分严重的影响。

我遇到过一个公司,就是因为缓存没处理好,经常导致他们商品资料显示不正确,而且还只是用了一级缓存(本地缓存)。

情形三已经引出了我们本文要讨论的重点问题了,还有其他情形就不再列出来了。

既然我们在使用二级缓存的时候会遇到这个缓存同步的问题,那么我们是不是就不要用二级缓存呢?

答案当然是否定的,用肯定是要用的,遇到问题,自然要想办法去解决的!

下面来看看这个二级缓存同步问题的解决方案。

解决方案

其实,对于二级缓存之间的同步问题,解决方案还是比较简单的!

思路就是:当更新分布式缓存(第二级缓存)的时候,顺便告诉一下那三台负载,让它们也更新一下本地缓存中的数据就可以了。

顺着这个思路,自然而然就想到了发布订阅这种机制。

三台负载都去订阅Redis缓存的更新,在更新商品资料后,同时向三台负载通知刚才更新的商品资料!

三台负载收到通知之后,去更新相应的缓存数据就可以了。

简单示意图:

这个可能是最为简单有效的处理方案,我个人暂时也没有想到其他更好的方案,如果您有好的建议,可以在下方留言评论或直接联系我!

背景,案例,解决方案都有了,下面就是要如何去实践这个二级缓存的同步问题了。

下面会通过ASP.NET Core做一个简单的Demo来实现这个缓存的同步。

简单实践

本地缓存采用:Microsoft.Extensions.Caching.Memory

分布式缓存采用:Redis

发布订阅采用:Redis的Pub/Sub

首先定义一个缓存操作的基类接口。

public interface ICacheProvider
{
    void Set(string cacheKey, object cacheValue, TimeSpan expiration);

    object Get(string cacheKey, Func<object> dataRetriever, TimeSpan expiration);

    void Remove(string cacheKey);
}

这个基类接口包含了最简单的三个操作。其中Get方法还带了一个额外的操作,当这次缓存没有取到数据,会从dateRetriever中拿数据。

定义两个新接口去继承上面这个基类接口,一个代表是本地缓存,一个代表是分布式缓存。

public interface ILocalCacheProvider : ICacheProvider
{
    
}

public interface IRemoteCacheProvider : ICacheProvider
{
    
}

这两个接口并没有定义什么额外的操作。

定义一个序列化的接口,用于处理缓存值的序列化操作。

public interface ISerializer
{
    string Serialize(object obj);

    object Deserialize(string str);

    T Deserialize<T>(string str);
}

这里只实现了针对Json的序列化操作。

public class JsonSerializer : ISerializer
{        
    public object Deserialize(string str)
    {
        return Newtonsoft.Json.JsonConvert.DeserializeObject(str);
    }

    public T Deserialize<T>(string str)
    {
        return Newtonsoft.Json.JsonConvert.DeserializeObject<T>(str);
    }

    public string Serialize(object obj)
    {
        return Newtonsoft.Json.JsonConvert.SerializeObject(obj);
    }
}

然后是基本的缓存操作的实现。

Memory缓存的实现:

public class MemoryCacheProvider : ILocalCacheProvider
{
    private IMemoryCache _cache;

    public MemoryCacheProvider(IMemoryCache cache)
    {
        _cache = cache;
    }

    public object Get(string cacheKey, Func<object> dataRetriever, TimeSpan expiration)
    {
        var result = _cache.Get(cacheKey);

        if (result != null)
            return result;

        var obj = dataRetriever.Invoke();

        if (obj != null)
            this.Set(cacheKey, obj, expiration);

        return obj;
    }

    //省略部分。。。
}

由于MemoryCache是可以直接操作object类型的,所以这里就不用进行序列化操作。

Redis缓存的实现:

public class RedisCacheProvider : IRemoteCacheProvider
{
    private readonly ISerializer _serializer;
    public RedisCacheProvider(ISerializer serializer)
    {
        this._serializer = serializer;
    }

    public void Set(string cacheKey, object cacheValue, TimeSpan expiration)
    {
        var value = _serializer.Serialize(cacheValue);
        RedisCacheConfig.Connection.GetDatabase().StringSet(cacheKey, value, expiration);
    }
    
    //省略部分。。。
}

这里需要用前面定义的序列化接口,因为我们不能像MemoryCache那样直接将object扔进Redis中。

这里还偷了一下懒,直接将Redis的连接信息写死到一个静态类里面了。

到这一步,基本的缓存操作已经有了。下面要关注的就是订阅和发布的内容了。

虽然说这个小Demo是用Redis来处理发布订阅,但是能完成发布订阅的还有MQ,所以发布订阅也还是要面向接口,便于更换调整。

首先是订阅分布式缓存变更的接口。

public interface ICacheSubscriber
{
    void Subscribe(string channel, NotifyType notifyType);
}

向本地缓存通知变更的接口:

public interface ICachePublisher
{
    void Notify(NotifyType notifyType ,string cacheKey, object cacheValue, TimeSpan expiration);
}

基于Redis实现的ICachePublisher接口

public class RedisCachePublisher : ICachePublisher
{
    private readonly ISerializer _serialize;

    public RedisCachePublisher(ISerializer serialize)
    {
        this._serialize = serialize;
    }

    public void Notify(NotifyType notifyType, string cacheKey, object cacheValue, TimeSpan expiration)
    {
        //省略部分。。。
        
        RedisPubSubConfig.Connection.GetSubscriber().Publish(channelName, _serialize.Serialize(args));            
    }              
}

这里是直接调用StackExchange.Redis里面的Publish方法来向本地缓存发起通知。这里通知的内容是经过序列化后的值。

基于Redis实现的ICacheSubscriber接口:

public class RedisCacheSubscriber : ICacheSubscriber
{
    private readonly ILocalCacheProvider _localCache;
    private readonly ISerializer _serialize;

    public RedisCacheSubscriber(ILocalCacheProvider localCache , ISerializer serialize)
    {            
        this._localCache = localCache;
        this._serialize = serialize;
    }

    public void Subscribe(string channel, NotifyType notifyType)
    {
        switch (notifyType)
        {
            case NotifyType.Add:
                RedisPubSubConfig.Connection.GetSubscriber().Subscribe(channel, CacheAddAction);
                break;
            case NotifyType.Update:
                RedisPubSubConfig.Connection.GetSubscriber().Subscribe(channel, CacheUpdateAction);
                break;
            case NotifyType.Delete:
                RedisPubSubConfig.Connection.GetSubscriber().Subscribe(channel, CacheUpdateAction);
                break;
        }
    }

    private void CacheDeleteAction(RedisChannel channel, RedisValue message)
    {
        var deleteNotification = _serialize.Deserialize<CacheNotificationObject>(message);

        _localCache.Remove(deleteNotification.CacheKey);
    }

    //省略部分...
}

在使用Redis的订阅之后,需要进行一个Action的处理,这里处理的就是上面Publish后的内容!

所以Action处理的第一步就是反序列化拿到变更信息,然后调用本地缓存的接口进行相应的操作。

由于在平台上面操作的时候,在更新Redis缓存的同时会发一个通知给本地缓存。

这就意味着调用方会进行两个操作,一是更新Redis缓存,二是通知本地缓存。

为了简化平台调用时的操作,这里也对其进行了整合。

定义一个平台更新缓存操作时用的接口。

public interface IPublishCacheProvider
{
    void Add(string cacheKey, object cacheValue, TimeSpan expiration, bool isNeedToNotify = false);

    void Update(string cacheKey, object cacheValue, TimeSpan expiration);

    void Delete(string cacheKey);
}

要注意的是,我们在平台进行添加操作的时候,不一定要通知本地缓存的!

因为添加的商品,必然是新产品,这个时候本地缓存是不会存在的相应数据的,可以让其去Redis缓存中取,这就和上面的情形一是一样的。

加上是否需要通知这个选项是为了预防有一些活动热门或特殊要求之类的产品要上新,这个时候可以考虑直接在本地缓存中也加上去。

但是,修改和删除就没得商量了,必须要去通知,不然造成数据不一致,那可就不好玩了。

下面是具体的实现:

public class PublishCacheProvider : IPublishCacheProvider
{
    private readonly IRemoteCacheProvider _remoteCache;
    private readonly ICachePublisher _cachePublisher;

    public PublishCacheProvider(IRemoteCacheProvider remoteCache, ICachePublisher publisher)
    {
        this._remoteCache = remoteCache;
        this._cachePublisher = publisher;
    }

    public void Add(string cacheKey, object cacheValue, TimeSpan expiration, bool isNeedToNotify = false)
    {
        _remoteCache.Set(cacheKey, cacheValue, expiration);

        if (isNeedToNotify)
            _cachePublisher.Notify(NotifyType.Add, cacheKey, cacheValue, expiration);
    }

    //省略部分。。。
}

到这里,我们对缓存的操作已经处理好了。下面就是单品页和平台两个地方的处理了。

先来看看平台这一块。

这里直接用一个API站点来模拟平台的操作。

[Route("api/[controller]")]
public class ValuesController : Controller
{
    private readonly IPublishCacheProvider _cache;

    public ValuesController(IPublishCacheProvider cache)
    {
        _cache = cache;
    }
     
    [HttpGet]
    public string Get(int type)
    {
        if(type == 1)
        {
            _cache.Update("Test", DateTime.Now.ToString(), TimeSpan.FromMinutes(5));
        }
        else if (type == 2)
        {
            _cache.Add("Test", DateTime.Now.ToString(), TimeSpan.FromMinutes(5),true);
        }
        else
        {
            _cache.Delete("Test");
        }

        return "Update Redis Cache And Notify Succeed!";
    }
}

这里固定了一个缓存的key,比较简单粗暴。

接下来是单品页。

单品页的操作会相对麻烦一点。

我们先来搞定比较棘手的一个东西,订阅。

如果是在MVC或Web Forms时代,我们会把订阅的代码写在Globle.acsx中。

但是在ASP.NET Core中就没有这个东西了,我们要转向Startup上面去了。

public class Startup
{
    //省略部分。。。
    
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc();
        services.AddTransient<ICacheSubscriber, RedisCacheSubscriber>();
        services.AddTransient<ILocalCacheProvider, MemoryCacheProvider>();
        services.AddTransient<IRemoteCacheProvider, RedisCacheProvider>();

        services.AddTransient<IDemoService,DemoService>();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        var subscriber = app.ApplicationServices.GetRequiredService<ICacheSubscriber>();                   

        //channel name should read from database or settings
        subscriber.Subscribe("CacheAdd", NotifyType.Add);
        subscriber.Subscribe("CacheUpdate", NotifyType.Update);
        subscriber.Subscribe("CacheDelete", NotifyType.Delete);
    }
}

我们是在Configure方法里面进行订阅操作的。这里需要注意的是ConfigureServices是在Configure方法之前执行的。

所以我们可以在Configure方法中拿到ICacheSubscriber的实现类,从而去完成订阅的操作。

另外这里的Channel和前面一样是硬编码的,这里应该要从配置中心读取才是比较好的。

这里还有一个模拟从数据库中拿数据的操作。

public interface IDemoService
{
    object Get();
}

public class DemoService : IDemoService
{
    public object Get()
    {
        return "Demo";
    }
}

最后就是单品页的使用了,这里用一个MVC项目来展示。

public class HomeController : Controller
{
    private readonly ILocalCacheProvider _localCache;
    private readonly IRemoteCacheProvider _remoteCache;
    private readonly IDemoService _service;

    public HomeController(ILocalCacheProvider localCache, IRemoteCacheProvider remoteCache, IDemoService service)
    {
        this._localCache = localCache;
        this._remoteCache = remoteCache;
        this._service = service;
    }

    public IActionResult Index()
    {
        TimeSpan ts = TimeSpan.FromMinutes(5);
        //ViewBag.Cache = _localCache.Get("Test", () => "123", ts).ToString();
        ViewBag.Cache = _localCache.Get("Test", () =>
        {
            return _remoteCache.Get("Test", () => _service.Get(), ts);
        }, ts).ToString();
        return View();
    }
}

在单品页中,我们只做了读的操作,代码比较简单就不一一解释了。

下面来看看效果如何。

上述效果图中,一开始是直接从IDemoService中取的值:Demo,因为本地缓存和分布式缓存中都没有相应的数据。

然后在平台中进行了操作,写入了新的数据,单品页中的本地缓存就更新了。最后是在平台执行了删除操作,单品页的缓存数据自然也就被删除了。

最后看一下平台更新后,单品页订阅那里的断点调试:

总结

二级缓存的同步问题处理起来还是比较简单的。如果使用了二级(多级)缓存,我们还是应该要考虑到这个问题的,不然到时踩雷了就不好了。

但是文中的Demo还是很粗糙,也并不优雅。后面还会对这个进行一些改造。

最后附上文中Demo的地址

SyncCachingDemo

原文地址:https://www.cnblogs.com/catcher1994/p/thinking-about-synchronization-when-using-multi-level-cache.html