歡迎光臨
每天分享高質量文章

關於二級快取之間同步問題的思考

作者:Catcher ( 黃文清 )

連結:https://www.cnblogs.com/catcher1994/p/thinking-about-synchronization-when-using-multi-level-cache.html

前言

近兩篇部落格寫的都是與資料快取相關的,這篇還是繼續快取相關的話題,主要是二級快取間的資料同步問題。

 

快取可以分為本地快取(行程內)和分散式快取(行程外),單獨用其中一種是比較常見的。

 

組合起來用的,或許也有不少企業在用!本文要討論的內容是屬於這種組合起來用的情形。

 

先簡單囉嗦一下什麼是二級快取?

何為二級快取?

二級,可以理解成有兩個不同的級別。二級快取,可以理解成有兩個不同級別的快取。

 

甚至三級,四級也是同樣的概念。這裡可以看看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(string str);
}

這裡只實現了針對Json的序列化操作。

 

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

    public T Deserialize(string str)
    {
        return Newtonsoft.Json.JsonConvert.DeserializeObject(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 ICacheSubscriber
{
    void Subscribe(string channel, NotifyType notifyType);
}

基於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(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();
        services.AddTransient();
        services.AddTransient();

        services.AddTransient();
    }

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

        //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的地址

https://github.com/catcherwong-archive/SyncCachingDemo

已同步到看一看
贊(0)

分享創造快樂