15 分鐘將 ASP.NET MVC Movie 範例程式導入 Azure Redis Cache

原始文章發佈於 MVC movie app with Azure Redis Cache in 15 minutes

最近公開預覽的 Azure Redis Cache 很容易就能整合進您的 Azure 網站應用程式中,這裡我將 MVC Movie 範例程式整合 Azure Redis Cache 然後部署到 Azure 網站服務(Websites)上,大約只花了 15 分鐘左右。

在導入快取(cache)之後,程式的速度比起純用資料庫來說快了將近 100 倍,因為經常被存取的資料就可以直接從快取中取得,而不必再到資料庫中撈資料,如此一來你也可以降低資料庫的存取次數,讓資料查詢的動作變得更迅速。

整合完畢的範例程式碼可以在這裡下載

接下來我會說明我是如何把 Azure Redis Cache 整合進我的 ASP.NET MVC Movie 範例之中。

  1. 在預覽中新的 Azure 管理界面,我們可以如圖所示建立一個新的 Redis Cache 服務。

     建立 Cache 服務大概會花個 15 分鐘左右,不過同一時間我不會在這裡等著,而是去修改我的程式。如果你需要完整的參考手冊,可以參考 How to Use Azure Redis Cache 這篇文章,不過要記得的是,Redis Cache 服務必須要跟你的網站服務是在同樣的資料中心,不同的資料中心可能速度會相差到 25 倍左右,建立的步驟可以參考 Create a Redis Cache 這一頁,你可以下載原始的 MvcMovie 範例程來改,或是直接下載我修改後的範例,但是要記得修改 cache 服務的 URL 及認證資料才會正常運作。

  2. 當 Cache 服務建立完成後,記下 Cache 名稱像是 <yourName>.redis.cache.windows.net 以及密碼(點擊 keys 按鈕就可以取得名稱及密碼)。

  3. 在你的 MVC 專案中,使用 NuGet 套件管理工具安裝 StackExchange.Redis 這個套件,如果您下載的是修改後的範例,那專案中已經安裝並參考了這個套件。

  4. 打開套件管理主控台(package manager console)中輸入 Update-Database 的指令。

  5. 在相關的 controller 中加入連結快取服務的程式碼:

     public class MoviesController : Controller
       {
          private MovieDBContext db = newMovieDBContext();
          private static ConnectionMultiplexer connection;
          private static ConnectionMultiplexer Connection
          {
             get
             {
                if (connection == null || !connection.IsConnected)
                {
                   connection = ConnectionMultiplexer.Connect(
                   ".redis.cache.windows.net,ssl=true," +
                   "password=");
                }
                return connection;
             }
          }
    

注意: 一般來說都不建議你把帳號密碼的資料直接寫在程式碼中,這裡只是為了方便瞭解程式碼才這麼做。請參考 Windows Azure Web Sites: How Application Strings and Connection Strings Work 這篇文章介紹的方法來處理帳號密碼這些敏感資料。

在上述的程式碼中,我已經將連結快取服務的部份用靜態成員的方式儲存,所以你不必在每一次 request 都重新建立一個快取服務的連結,你只需要在使用時檢查連結是否還在,若已經失去連結再重新建立連結就好。

新增一個類別,並包含這個 SampleStackExchangeRedisExtension 類別:

 public static class SampleStackExchangeRedisExtensions
{
   public static T Get<T>(this IDatabase cache, string key)
   {
      return Deserialize<T>(cache.StringGet(key));
   }

   public static object Get(this IDatabase cache, string key)
   {
      return Deserialize<object>(cache.StringGet(key));
   }

   public static void Set(this IDatabase cache, string key, object value)
   {
      cache.StringSet(key, Serialize(value));
   }

   static byte[] Serialize(object o)
   {
      if (o == null)
      {
         return null;
      }
      BinaryFormatter binaryFormatter = new BinaryFormatter();
      using (MemoryStream memoryStream = new MemoryStream())
      {
         binaryFormatter.Serialize(memoryStream, o);
         byte[] objectDataAsStream = memoryStream.ToArray();
         return objectDataAsStream;
      }
   }

   static T Deserialize<T>(byte[] stream)
   {
      BinaryFormatter binaryFormatter = new BinaryFormatter();
      if (stream == null)
         return default(T);

      using (MemoryStream memoryStream = new MemoryStream(stream))
      {
         T result = (T)binaryFormatter.Deserialize(memoryStream);
         return result;
      }
   }
}

SampleStackExchangeRedisExtensions 類別可以讓你很輕易就將任何可序列化(serializable)的類別做快取。你可以在你的模型(model)上加上 [Serializable] 屬性。

 [Serializable]
public class Movie

然後將所有 Movie movie = db.Movies.Find(id); 的部份都修改成:

 //Movie movie = db.Movies.Find(id);
Movie movie = getMovie((int)id);

在 POST 呼叫的 EditDelete 方法中,記得要清除快取。

 ClearMovieCache(movie.ID);

在 controller 中加入下面這段程式碼,其中 getMovie 是一個很標準的快取操作:

 Movie getMovie(int id)
{
   Stopwatch sw = Stopwatch.StartNew();
   IDatabase cache = Connection.GetDatabase();
   Movie m = (Movie)cache.Get(id.ToString()); 

   if (m == null)
   {
      Movie movie = db.Movies.Find(id);
      cache.Set(id.ToString(), movie);
      StopWatchMiss(sw);
      return movie;
   }
   StopWatchHit(sw); 

   return m;
} 

private void ClearMovieCache(int p)
{
   IDatabase cache = connection.GetDatabase();
   if (cache.KeyExists(p.ToString()))
      cache.KeyDelete(p.ToString());
} 

void StopWatchEnd(Stopwatch sw, string msg)
{
   sw.Stop();
   double ms = sw.ElapsedTicks / (Stopwatch.Frequency / (1000.0));
   ViewBag.cacheMsg = msg + ms.ToString() +
       ” PID: ” + Process.GetCurrentProcess().Id.ToString();
} 

void StopWatchMiss(Stopwatch sw)
{
   StopWatchEnd(sw, “Miss – MS:”);
} 

void StopWatchHit(Stopwatch sw)
{
   StopWatchEnd(sw, “Hit – MS:”);
}

另外,在 Views\Shared\_Layout.cshtml 檔案中加入 ViewBag.cacheMsg 的程式碼,這是為了在頁面上顯示快取的資訊。

 <div class="container body-content">
  @RenderBody()
  <hr />
  <footer>
    <h2>@ViewBag.cacheMsg</h2>
  </footer>
</div>

@Scripts.Render("~/bundles/jquery")
@Scripts.Render("~/bundles/bootstrap")
@RenderSection("scripts", required: false)
</body>
</html>

現在你可以在開發環境中測試快取的效果了,不過如果你的資料庫很小,而快取服務又是在雲端,效果可能不會太明顯,部署到 Azure 上應該就可以感受到明顯的差異。

在管理介面中監控快取服務

在管理介面中,你可以看到快取服務的 hit/miss 的統計資料:

你可以加上其它的相關資訊來監控,像是自訂時間範圍、被清除的鍵值、過期的鍵值、使用的 CPU 或記憶體等。

當然,你也可以加入警告通知(Add Alert)來幫助你監控快取服務的使用狀況,像是下圖我就加入了一個警告通知,在 15 分鐘內,清除的鍵值過多時,可能要使用更大的快取。

從 Visual Studio 將網站部署到 Azure 是十分容易的,只要在 Web 專案上按右鍵,選擇發行就可以了,再次提醒,網站服務跟快取服務一定要在同一個資料中心,否則網路傳輸的延遲會拖垮快取的效能。而在發行時別忘了勾選 Execute Code First Migrations

部署完成後,你就可以試試看有沒有快取的效能是不是有明顯的差異。

壓力測試快取服務

預設的快取操作時間是 1000ms (1秒),你可以試著用下面這段程式碼,將 time out (強制清除快取)改成更長或更短的時間,測試你的快取服務是否正常。當 #define NotTestingTimeOut 這段程式碼被註解掉時,timeout 便會被設定為 150ms,讓快取在很短的時間被清掉。

 #else
      #region StressTest
      private static ConnectionMultiplexer Connection
      {

         get
         {
            if (connection != null && connection.IsConnected)
            {
               return connection;
            }
            var config = new ConfigurationOptions();
            config.EndPoints.Add(Keys.URL);
            config.Password = Keys.passwd;
            config.Ssl = true;
            config.SyncTimeout = 150;

            connection = ConnectionMultiplexer.Connect(config);
            return connection;
         }
      }
      #endregion
#endif

在壓力測試時,最好將 session 快取關閉,簡單的作法是到 web.config 檔案中把整個應用程式的 session 快取關閉。

 <sessionState mode="Off" />

或是在你的 controller 中使用 [SessionState(SessionStateBehavior.Disabled)] 來做。以下這個更新後的 getMovie 方法可以運作得更穩定,因為當 time out 例外發生時會重試 3 次。

       Movie getMovie(int id, int retryAttempts = 0)
      {
         IDatabase cache = Connection.GetDatabase();
         if (retryAttempts > 3)
         {
            string error = "getMovie timeout with " + retryAttempts.ToString()
               + " retry attempts. Movie ID = " + id.ToString();
            Logger(error);

            ViewBag.cacheMsg = error + " Fetch from DB";
            // Cache unavailable, get data from DB
            return db.Movies.Find(id);
         }
         Stopwatch sw = Stopwatch.StartNew();
         Movie m;

         try
         {
            m = (Movie)cache.Get(id.ToString());
         }

         catch (TimeoutException tx)
         {
            Logger("getMovie fail, ID = " + id.ToString(), tx);
            return getMovie(id, ++retryAttempts);
         }

         if (m == null)
         {
            Movie movie = db.Movies.Find(id);
            cache.Set(id.ToString(), movie);
            StopWatchMiss(sw);
            return movie;
         }
         StopWatchHit(sw);

         return m;
      }

這個範例程式中還有很多方法可以測試快取服務。

像 WriteCache 或 ReadCache 方法會預設寫入或讀取 1,000 筆資料,你可以在 URL 後加上 "/n" 讓它們變成讀寫 n * 1000 筆的資料,像上圖的例子 https://<your site>.azurewebsites.net/Movies/ReadCache/3 就會讀取 3,000 筆快取的資料。

在這個 150ms timeout 的環境下,我的程式就會很容易碰到 timeout 的狀況而去存取資料庫,這是因為我的程式有正確處理這個 timeout 的例外才能順利去讀取資料庫。所以建議您上線的應用程式也要能處理好這個例外,因為根據雲端平台的服務水準,如果您選擇的是基本方案,那一個月中可能會有幾分鐘無法存取(比如正在更新 VM),除非選擇了標準方案,並且建立好 master-slave 的架構備援,不過還是建議您在程式碼中預先處理這個可能發生的例外狀況。

Azure Redis Cache (Preview) ASP.NET Session State Provider

ASP.NET 預設的 In-memory Session State Provider 無法同時被多個網站實體使用,而 SQL Server session state 雖然可以同時讓不同的網站使用相同的 session state,但這會受限於資料庫查詢的延遲時間,進而影響效能。而 Redis session state cache provider 則是另一個選擇,如果你的網站只會用到不是很大的 session state,則可以利用 Redis Cache 來快取這些 session state data。

您可以參考這篇文章,在你的網站應用程式中加入 RedisSessionStateProvider,然後修改 Web.config 檔案來設定 Redis Cache 服務:

  <system.web>
     <customErrors mode="Off" />
     <!--<sessionState mode="Off" />-->
    <authentication mode="None" />
    <compilation debug="true" targetFramework="4.5" />
    <httpRuntime targetFramework="4.5" />
  <sessionState mode="Custom" customProvider="RedisSessionProvider">
               <add name="RedisSessionProvider" 
              type="Microsoft.Web.Redis.RedisSessionStateProvider" 
              port="6380"
              host="movie2.redis.cache.windows.net" 
              accessKey="m7PNV60CrvKpLqMUxosC3dSe6kx9nQ6jP5del8TmADk=" 
              ssl="true" />
      <!--<add name="MySessionStateStore" type="Microsoft.Web.Redis.RedisSessionStateProvider" host="127.0.0.1" accessKey="" ssl="false" />-->
      </providers>
    </sessionState>
  </system.web>
  <system.webServer>

如此一來你就可以在你的網站應用程式中使用 Redis Cache 來處理 session state。在範例程式中也提供了測試的方法,你可以透過 https://<your site>.azurewebsites.net/SessionTest/WriteSession/Hello_joe 來存取,如此便會將 "Hello_joe" 寫入 session state,你可以試著增加網站服務實體,看看這個 session state 是否會在多個實體間共用。

如果你對於 Redis Cache 有任何問題,可以在 twitter 上跟隨 @RickAndMSFT 並且向他發問。

參考資料

這篇文章原始發佈於「Microsoft Azure 中文部落格」