使用 .NET Framework 內建的 MemoryCache 來 Cache 常用資料 - Part 3 隱藏的效能瓶頸

之前筆記 使用 .NET Framework 內建的 MemoryCache 來 Cache 常用資料 - Part 2 使用 lock 避免 ddos db 解決程式可能 ddos db 的重大缺失,最近重新 review code 時發現一個效能瓶頸:取資料時會 lock 所有 MemoryCache 物件而造成所有 access cache 都被 blocking。

問題發生原因

  1. 測試方法有缺陷

    • 測試方法

      void Main()
      {
      MemoryCache _cache = MemoryCache.Default;
      _cache.Remove("TableData");
      _cache.Remove("Table2Data");
      
      // sleep 時間較久先跑
      Task.Run(() => CacheHelper.RefreshTable2Data());
      Task.Run( () => CacheHelper.RefreshTableData());
      
      Console.WriteLine("Done");
      }
      
    • 並非直接測試取資料屬性,而是測試更新資料的方法

  2. lock 根物件

    • 取資料方法

      /// <summary>
      ///  TableData
      /// </summary>
      public static List<Table> TableData
      {
          get
          {
              MemoryCache _cache = MemoryCache.Default;
              //加上 lock
              lock (_cache)
              {
                  if (!_cache.Contains("TableData"))
                  {
                      RefreshTableData();
                  }
              }
              return _cache.Get("TableData") as List<Table>;
          }
      }
      
    • lock 了整個 MemoryCache 物件

      如何改善

  3. 重現問題

    • 修改測試方法

      Task.Run(() => CacheHelper.Table2Data.Dump());
      Task.Run(() => CacheHelper.TableData.Dump());
      
    • 多執行緒仍出現依序執行的現象

      1error

  4. 僅 lock 需要的物件

    • 依 key 來產生唯一個的 lock 物件

      // lock 以 key 產生的專屬 lock object,如果 object 過期或不存在時自動 new 出新的
      // 如果直接 lock cache[key] 會造成無法寫入 cache 資料
      lock (GetCacheObject(key))
      
    • 如果 MemoryCache[key] 尚未被建立時需先建立

      //設定一個 key 用來識別唯一的 lock
      const string LockKey = "@#$%";
      static Object GetCacheObject(string key)
      {
      // 取得每個 Key 的鎖定 object
          string _lockKey =$"{LockKey}{key}";
      //仍然會 lock 整個 memorycahce object 但少了取資料過程  lock 時間會縮短
      lock (_cache)
      {
          // lock object 不存在時直接建立
          if (_cache[_lockKey] == null)
          _cache.Add(_lockKey,new object(),new CacheItemPolicy(){SlidingExpiration = new TimeSpan(0, 10, 0)});
      }
      return _cache[_lockKey];
      }
      
    • 因為有先建立暫存物件的關係,更新判斷流程也需調整

      if ((_cache.Get("TableData") as  List<Table>) == null )
      {
          RefreshTableData();
      }
      
    • 調整後完整程式碼

      public class CacheHelper
      {
          private static MemoryCache _cache = MemoryCache.Default;
          //設定一個 key 用來識別唯一的 lock
          const string LockKey = "@#$%";
          /// <summary>
          /// 更新 TableData
          /// </summary>
          public static void RefreshTableData()
          {
              Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} Start Job,Now:{DateTime.Now}");
              Thread.Sleep(3000);
      
              //移除 cache 中資料
              _cache.Remove("TableData");
      
              //存取資料
              List<Table> listAgency= new List<UserQuery.Table>();
              for (int i = 0; i < 3; i++)
              {
                  listAgency.Add(new Table { Id = Guid.NewGuid(), Name = $"{Guid.NewGuid()}"});
              }
      
              //設定 cache 過期時間
              CacheItemPolicy cacheItemPolicy = new CacheItemPolicy() { AbsoluteExpiration = DateTime.Now.AddDays(1) };
              //加入 cache
              _cache.Add("TableData", listAgency, cacheItemPolicy);
              Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} Stop Job,Now:{DateTime.Now}");
              Console.WriteLine("OK");
      
          }
          /// <summary>
          /// 依 key 取得 cache object
          /// </summary>
          static Object GetCacheObject(string key)
          {
              // 取得每個 Key 的鎖定 object
              string _lockKey =$"{LockKey}{key}";
              //仍然會 lock 整個 memorycahce object 但少了取資料過程  lock 時間會縮短
              lock (_cache)
              {
                  // lock object 不存在時直接建立
                  if (_cache[_lockKey] == null)
                      _cache.Add(_lockKey,new object(),new CacheItemPolicy(){SlidingExpiration = new TimeSpan(0, 10, 0)});
              }
              return _cache[_lockKey];
          }
      
          /// <summary>
          ///  TableData
          /// </summary>
          public static List<Table> TableData
          {
              get
              {
                  //加上 lock
                  lock (GetCacheObject("TableData"))
                  {
                      if ((_cache.Get("TableData") as  List<Table>) == null )
                      {
                          RefreshTableData();
                      }
                  }
                  return _cache.Get("TableData") as List<Table>;
              }
          }
      }
      

      測試結果

  5. 兩個 thread 同時啟動 而不是像之前的測試出現等待前一個 thread 執行結束才啟動的現象

    2result

  • 測試及使用的完整程式碼(可以使用 linqpad 直接執行)

    void Main()
    {
        // //先清除 cache 中資料
        MemoryCache _cache = MemoryCache.Default;
        _cache.Remove("TableData");
        _cache.Remove("Table2Data");
    
        Task.Run(() => CacheHelper.Table2Data.Dump());
        Task.Run(() => CacheHelper.TableData.Dump());
    
        Console.WriteLine("Done");
    }
    public class CacheHelper
    {
        private static MemoryCache _cache = MemoryCache.Default;
        //設定一個 key 用來識別唯一的 lock
        const string LockKey = "@#$%";
            
        /// <summary>
        /// 更新 TableData
        /// </summary>
        public static void RefreshTableData()
        {
            Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} Start Job,Now:{DateTime.Now}");
            Thread.Sleep(3000);
    
            //移除 cache 中資料
            _cache.Remove("TableData");
    
            //存取資料
            List<Table> listAgency= new List<UserQuery.Table>();
            for (int i = 0; i < 3; i++)
            {
                listAgency.Add(new Table { Id = Guid.NewGuid(), Name = $"{Guid.NewGuid()}"});
            }
    
            //設定 cache 過期時間
            CacheItemPolicy cacheItemPolicy = new CacheItemPolicy() { AbsoluteExpiration = DateTime.Now.AddDays(1) };
            //加入 cache
            _cache.Add("TableData", listAgency, cacheItemPolicy);
            Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} Stop Job,Now:{DateTime.Now}");
            Console.WriteLine("OK");
    
        }
        /// <summary>
        /// 依 key 取得 cache object
        /// </summary>
        static Object GetCacheObject(string key)
        {
            // 取得每個 Key 的鎖定 object
            string _lockKey =$"{LockKey}{key}";
            //仍然會 lock 整個 memorycahce object 但少了取資料過程  lock 時間會縮短
            lock (_cache)
            {
                // lock object 不存在時直接建立
                if (_cache[_lockKey] == null)
                    _cache.Add(_lockKey,new object(),new CacheItemPolicy(){SlidingExpiration = new TimeSpan(0, 10, 0)});
            }
            return _cache[_lockKey];
        }
    
        /// <summary>
        ///  TableData
        /// </summary>
        public static List<Table> TableData
        {
            get
            {
                //加上 lock
                lock (GetCacheObject("TableData"))
                {
                    if ((_cache.Get("TableData") as  List<Table>) == null )
                    {
                        RefreshTableData();
                    }
                }
                return _cache.Get("TableData") as List<Table>;
            }
        }
        public static List<Table> Table2Data
        {
            get
            {
                lock (GetCacheObject("Table2Data"))
                {
                    if ((_cache.Get("Table2Data") as  List<Table>) == null )
                    {
                        RefreshTable2Data();
                    }
                }
                return _cache.Get("Table2Data") as List<Table>;
            }
        }
        /// <summary>
        /// 更新 TableData
        /// </summary>
        public static void RefreshTable2Data()
        {
            Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} Start Job,Now:{DateTime.Now}");
            Thread.Sleep(10000);
    
            //移除 cache 中資料
            _cache.Remove("Table2Data");
    
            //存取資料
            List<Table> listAgency = new List<UserQuery.Table>();
            for (int i = 0; i < 3; i++)
            {
                listAgency.Add(new Table { Id = Guid.NewGuid(), Name = $"{Guid.NewGuid()}" });
            }
    
            //設定 cache 過期時間
            CacheItemPolicy cacheItemPolicy = new CacheItemPolicy() { AbsoluteExpiration = DateTime.Now.AddDays(1) };
            //加入 cache
            _cache.Add("Table2Data", listAgency, cacheItemPolicy);
            Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} Stop Job,Now:{DateTime.Now}");
            Console.WriteLine("OK");
        }
    }
    
    public class Table
    {
        public Guid Id{ get; set; }
        public string Name { get; set; }
    }
    

心得

又再次體會到寫筆記的好處,如果有個想法立馬紀錄一下,過程中可能還可以順道解決以前自己埋下的地雷XD

參考資料

  1. 改良式GetCachableData可快取查詢函式