通過Dapr實現一個簡單的基於.net的微服務電商系統(六)——一步一步教你如何擼Dapr之Actor服務

a1010發表於2021-04-22

  我個人認為Actor應該是Dapr裡比較重頭的部分也是Dapr一直在講的所謂“stateful applications”真正具體的一個實現(個人認為),上一章講到有狀態服務可能很多同學看到後的第一反應是“不就是個分散式快取嗎”。那今天就講講Actor,看看這個東西到底能不能算得上有狀態服務,同時由於篇幅有限,這裡只會快速的過一遍Actor相關的概念,著重還是程式碼層面的實現。

目錄:
一、通過Dapr實現一個簡單的基於.net的微服務電商系統

二、通過Dapr實現一個簡單的基於.net的微服務電商系統(二)——通訊框架講解

三、通過Dapr實現一個簡單的基於.net的微服務電商系統(三)——一步一步教你如何擼Dapr

四、通過Dapr實現一個簡單的基於.net的微服務電商系統(四)——一步一步教你如何擼Dapr之訂閱釋出

五、通過Dapr實現一個簡單的基於.net的微服務電商系統(五)——一步一步教你如何擼Dapr之狀態管理

六、通過Dapr實現一個簡單的基於.net的微服務電商系統(六)——一步一步教你如何擼Dapr之Actor服務
附錄:(如果你覺得對你有用,請給個star)
一、電商Demo地址

二、通訊框架地址

  最早我接觸到Actor應該是微軟的Orleans框架(熟悉Actor或者Orleans的同學這一大段可以直接跳過),百度Actor關鍵詞一大堆“通用併發程式設計模型”可能讓人云裡霧裡的,其實它並不是一個特別複雜的概念。什麼是併發程式設計?這個概念大家應該很熟悉了,現在主流的web伺服器(如.netcore的kestrel或者dotnetty)幾乎都是支援並行訪問的,通過執行緒池充分排程作業系統的多執行緒來並行完成任務。在傳統的多執行緒模式中如果多個執行緒同時訪問某個資料並對其進行非冪等操作,往往是執行緒不安全的。

  在單應用時代我們可以很方便的通過lock關鍵字或者semaphore訊號量或者concurrent執行緒安全集合或者Interlocked這樣的CAS原子操作去規避多執行緒訪問導致的資料不安全,亦或者直接採用以資料庫事務為基礎的樂觀 or 悲觀事務來實現,而一旦我們的應用由於吞吐瓶頸需要以叢集的方式部署時或者分散式部署後對資料庫也進行了拆分後,上面的那些方案都會失效或者會導致高昂的成本(比如資料庫分散式事務協調機制)。這個時候往往需要引入一些分散式元件比如zookeeper或者redis鎖來解決。這也是分散式系統比較常用的資料一致性方案。而actor則是提出了一個新的在分散式環境下解決多執行緒汙染資料的思路。

  actor概念相對比較複雜這裡就不展開了,簡單粗暴的來理解就是在記憶體裡為每一個actor物件維護了一個訊息佇列,當任意的請求不管該請求是來自於其他程式的執行緒亦或是當前程式的執行緒,都會將請求寫入該訊息佇列,而Actor物件會監聽該佇列,當收到訊息後Actor會處理該請求,在請求處理期間,外部執行緒會被阻塞在訊息佇列中,並且新的請求也會入隊等待,直到actor物件完成操作後從佇列裡取出下一個請求處理直到整個佇列為空。同時每一個actor物件在其臨界區內的記憶體是私有的,並不會被其他執行緒共享,從而就實現了記憶體安全。這樣當我們客戶端發起數個請求訪問一個或多個Actor物件時每個請求都會進入對應的Actor物件的訊息佇列(術語叫Mailboxs)並等待actor消費。同時Dapr框架會確保同一個Actor物件在同一時間在整個分散式系統中只會被啟用一個例項!從而確保了你無論從分散式系統的任意角落訪問某個Actor物件(user?id=1),總能得到唯一的一個例項

  Dapr框架會確保你的Actor例項永遠能夠被訪問到(正確啟用),哪怕物件在長時間未被訪問後系統回收休眠亦或者在未處理的異常導致其崩潰後

  正確使用Actor唯一的要求就只有一條,由於Actor是一個記憶體併發模型所以不要在併發訪問Actor時去做任意的可能的IO阻塞(比如讀取資料庫)!

  開始擼碼,首先我們做一個RPC服務,看看多執行緒訪問下的資料會是什麼個情況,再對比一下Actor模式!在RPC層我們建立一個介面,代表產品服務,其有兩個方法對應讀取產品以及減扣庫存

   接著我們在servicesample層實現一下這個服務(這裡直接建立一個靜態變數模擬多執行緒下訪問共享記憶體資料的場景)

   接著我們在clientsample發起對著兩個服務的RPC呼叫

   現在我們通過併發測試統計jmter對其進行併發測試,併發1000個執行緒去減100個庫存,最後我們通過postman去訪問get方法看看結果是什麼

 

減庫存前

 

 

 

 並行訪問1000次

  

  可以看到由於沒有併發控制,我們的庫存被扣負了。現在我們開始對其進行Actor改造。首先我們將介面繼承iactorservice並申明服務的方法為actor(這一步的目的是為型別生成actor代理)

    [RemoteService("servicesample", "product")]
    public interface IProductService : IActorService
    {
        [RemoteFunc(FuncType.Actor)]
        Task<ProductOutput> Get(ProductInput input);
        [RemoteFunc(FuncType.Actor)]
        Task<ProductOutput> ReduceStock(ProductInput input);
    }

  接著我們讓入參類繼承一個基類,這個基類需要派生類重寫其Actorid欄位。原因是Actor是通過全域性唯一識別符號在內部被標識的,訪問相同標識會被路由到同一個actor。

    public class ProductInput : ActorSendDto
    {
        public int PorductId { get; set; }
        public int ReduceStock { get; set; }
        public override string ActorId { get; set; }
    }

  接下來我們改造一下clientsample的呼叫方法,這裡修改的部分不多,只是把代理生成的方式替換了一下

        public async Task<dynamic> GetProduct()
        {
            var actorService = serviceProxyFactory.CreateActorProxy<IProductService>();
            return await actorService.Get(new ProductInput() { ActorId = "1", PorductId = 1 });
        }
        public async Task<dynamic> ProductReduceStock()
        {
            var actorService = serviceProxyFactory.CreateActorProxy<IProductService>();
            return await actorService.ReduceStock(new ProductInput() { ActorId = "1", PorductId = 1, ReduceStock = 1 });
        }

  接著我們對servicesample進行改造,首先我們需要在hostbuilder裡替換掉預設的OxygenStartup,OxygenActorStartup會幫我們掃描型別生成對應的actor代理(其他程式碼無變化,略)

           .ConfigureWebHostDefaults(webhostbuilder => {
               //註冊成為oxygen服務節點
               webhostbuilder.StartOxygenServer<OxygenActorStartup>((config) => {
                   config.Port = 80;
                   config.PubSubCompentName = "pubsub";
                   config.StateStoreCompentName = "statestore";
                   config.TracingHeaders = "Authentication";
               });
           })

  接著我們需要將之前的商品持久化PO類繼承一個基類ActorStateModel,該基類會強制派生類重寫兩個屬性AutoSave和ReminderSeconds,前者代表是否自動持久化(呼叫Actor SDK的Statemanage持久化到中介軟體,第二個代表如果開啟持久化,是瞬時持久化還是由Actor的Timer按照週期持久化,這裡的設計有點類似於redis aof模式下的always和everysec,前者(ReminderSeconds=0)採用每一次變更同步一次,效能損耗較大,後者採用每n(取決於ReminderSeconds設定)秒通過timer非同步同步一次,同時我在Actor代理中新增了版本管理,並不會導致你的ReminderSeconds設定了週期同步後到時間就會請求你的同步委託,而是檢測到版本變化後才會請求),這裡我測試就直接開啟自動同步並使用always模式

    public class ProductPo : ActorStateModel
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int Stock { get; set; }
        public override bool AutoSave { get; set; } = true;
        public override int ReminderSeconds => 0;
    }

  最後我們對ProductService進行改造,如下:

    public class ProductService : BaseActorService<ProductPo>, IProductService
    {
        static int visitCount = 0;
        static ProductPo ProductPoInstance;
        public async Task<ProductOutput> Get(ProductInput input)
        {
            ActorData ??= new ProductPo() { Id = 1, Name = "小白菜", Stock = 100 };
            return new ProductOutput() { Message = $"第{visitCount}次請求成功,當前庫存剩餘{ActorData.Stock}" };
        }
        public async Task<ProductOutput> ReduceStock(ProductInput input)
        {
            Interlocked.Increment(ref visitCount);
            await Task.Delay(new Random(Guid.NewGuid().GetHashCode()).Next(20, 50));//模擬資料庫耗時
            ActorData ??= new ProductPo() { Id = 1, Name = "小白菜", Stock = 100 };
            if (ActorData.Stock >= input.ReduceStock)
            {
                await Task.Delay(new Random(Guid.NewGuid().GetHashCode()).Next(50, 100));//模擬資料庫耗時
                ActorData.Stock -= input.ReduceStock;
            }
            return new ProductOutput() { Message = $"第{visitCount}次請求成功,當前庫存剩餘{ActorData.Stock}" };
        }

        public override async Task SaveData(ProductPo model, ILifetimeScope scope)
        {
            Console.WriteLine("同步請求被呼叫了,此處可以進行資料庫持久化!");
            await Task.CompletedTask;
        }
    }

  可以看到我的服務繼承了一個基類BaseActorService,並需要傳遞一個型別為ActorStateModel的泛型,這樣在我的服務裡不再通過IO去拉取ProductPoInstance,而是直接使用ActorData這個泛型例項進行各種操作即可,所以我刪除掉了對應的資料庫模擬耗時(避免actor佇列訪問阻塞),最後你必須重寫BaseActorService的SaveData方法,該方法就是上文提到的同步委託,當我們開啟AutoSave時,ReminderSeconds=0會在actor被呼叫操作完成後啟用該委託,ReminderSeconds>0時會被定時器定期根據actor對比版本後判斷是否需要啟用。同時無論哪種方式我都在actor代理內部維護了一個channel非同步佇列通過非同步訂閱釋出的方式實現非阻塞式的actor持久化而不用擔心持久化導致的io阻塞問題。SaveData入參返回的一個ILifetimeScope容器可以很方便的獲取到你的repository或者直接獲取ef的上下文進行對應的資料庫持久化操作(這裡需要注意一下,Actor持久化有兩層意思,第一層意思是Actor sdk會自帶一個StateManager,當Component開啟actor支援後,可以通過StateManager將actor物件寫入中介軟體,而這裡提供的SaveData是我封裝的一個通過訂閱釋出非同步呼叫的委託,方便開發人員持久化到資料庫用的,非actor原生自帶的設計)。

  最後我們需要擴充套件我們的Component,需要開啟Actor持久化支援,編輯檔案後用kubectl apply -f x.yaml即可:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: actorStateStore
    value: "true"
  - name: redisHost
    value: redis.infrastructure.svc.cluster.local:6379
  - name: keyPrefix
    value: none

  接下來我們看看通過jmter重新請求後的情況

 

  可以看到Actor確實解決了併發訪問安全的問題,同時也能看到我們的委託被正確的呼叫了。

  總結一下,Actor確實通過其特殊的設計模式解決了併發訪問資料安全的問題,同時也帶來了一些問題諸如需要特定框架支援,諸如Actor行為內不能阻塞等等限制,不過相比其帶來的無鎖物件訪問來講,這點限制都是可以克服的,至少在特定場景下比如搶票、發紅包等等有一定併發同時又需要確保資料一致的場景,Actor算是一個可選方案。至於更多的場景探索則需要同學們自己去摸索了,今天的分享就到這裡。下期不出意外的話我們會分享一下Dapr的服務限流

相關文章