ASP.NET Caching, Micro-Caching, and Performance

As a programmer, I've been a "performance freak" for a very long time. I believe in extensive testing and good metrics -- just like scientists who practice good science. Some people think I'm nuts, but I just tell them, "You become interested in performance when you're fortunate enough to have to worry about it". If you can think big, then you need to worry about performance, period.

A case in point is the story of Dapper-Dot-Net. The guys at Stackoverflow were using LINQ-To-SQL for almost everything. LINQ-To-SQL is very cool and fun to work with. But guess what? Under heavy load, it can be an absolute dog, even with compiled queries. So they developed their own drop-in replacement, "Dapper". I use Dapper in this demo project. It caches queries and generates IL code on the fly to perform the object mapping. The result is a data access Micro-ORM framework that is lightning fast and easy to use. But even with something like Dapper, you still want to use caching.

Some years ago at a Microsoft Tech-Ed, Rob Howard gave a presentation on ASP.NET caching, and the message sunk in: Caching is your friend. If you have to present data that comes out of a database, and you run a website that gets a lot of traffic and requests, especially if the data is "read only"  (which it almost always is), you can get - in most cases - vastly improved throughput by caching this data for as little as 1/2 of one second.  Improved throughput means you don't have to solve load problems by throwing more hardware at them. Well - not always - but you get the idea.

For this exercise, I thought I would compare caching performance with non-cached page delivery and I saw a very interesting implementation of an ASP.NET "MicroCache" by Keith Wood here:  http://www.superstarcoders.com/blogs/posts/micro-caching-in-asp-net.aspx   So, I included that in my tests.

What I did was to create a simple ASP.NET Web Application with a single page that would deliver a databound GridView from the Northwind Employees table in SQL Server. To acompany this, I built a regular Console Application that would throw a heavy load at the page, with a querystring item "cache" that the page would use to determine which of the tests to deliver:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Caching;
using System.Web.UI;
using System.Web.UI.WebControls;
using Dapper;
using DapperHelper;

namespace ASPNETMicroCache
{
    public partial class Default : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            if (Request["cache"] == "true")
            {
                var data = Global.Cache.GetOrAdd<List<Employee>>("data", () => QueryDatabase());
                GridView1.DataSource = data;
                GridView1.DataBind();
                
            }
            else if(Request["cache"]=="false")
            {
                var data = QueryDatabase();
                GridView1.DataSource = data;
                GridView1.DataBind();
            }

            else if(Request["cache"]=="regular")
            {
                if (Cache["data"] == null)
                {
                    Cache.Add("data",QueryDatabase() ,null,
                        DateTime.Now.AddSeconds(Global.CacheSeconds),
                         System.Web.Caching.Cache.NoSlidingExpiration,CacheItemPriority.High, null) ;
                }
                var data = (List<Employee>) Cache["data"];
                GridView1.DataSource = data;
                GridView1.DataBind();
            }
        }

        protected List<Employee> QueryDatabase()
        {
          return  SqlMapperUtil.SqlWithParams<Employee>("Select * from Employees",null, "Northwind");
        }
    }
}

The cache expiration is set in Global so you only need to change one value to run a different test:

public static MicroCache<string> Cache;
        public static double CacheSeconds = 30;

        protected void Application_Start(object sender, EventArgs e)
        {
            Cache = new MicroCache<string>(TimeSpan.FromSeconds(CacheSeconds));
        }

Finally, here's the code for the "tester" app:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;

namespace LoadTester
{
    class Program
    {
        private static long totcacheTime = 0;
        private static long totnoncachetime = 0;
        private static long totregularcachetime = 0;
        private static int totcachebytes = 0;
        private static int totregularcachebytes = 0;
        private static int totnoncachebytes = 0;
    
        private static ManualResetEvent mre = new ManualResetEvent(false);
        static void Main(string[] args)
        {
            ThreadPool.SetMaxThreads(1000, 1000);

            for (int i = 0; i < 10000; i++)
            {
                ThreadPool.QueueUserWorkItem(new WaitCallback(GetPageNonCached), i);
                ThreadPool.QueueUserWorkItem(new WaitCallback(GetPageRegularCached), i);
                ThreadPool.QueueUserWorkItem(new WaitCallback(GetPageCached), i);
            }
            mre.WaitOne();
            Console.WriteLine("Cached: " +totcacheTime  + " bytes: " +totcachebytes);
            Console.WriteLine(" Regular Cached: " + totregularcachetime + " bytes: "+totregularcachebytes );
            Console.WriteLine("Non-Cached " + totnoncachetime + " bytes: " +totnoncachebytes);
            Console.ReadKey();
        }

        static void GetPageCached( object state)
        {
            Stopwatch sw = Stopwatch.StartNew();
            int i = (int) state;
            WebClient wc = new WebClient();
           string s= wc.DownloadString("Http://localhost:1000/Default.aspx?cache=true");
            totcachebytes += s.Length;
            wc.Dispose();
            sw.Stop();
            totcacheTime += sw.ElapsedMilliseconds;
            if (i % 1000 == 0) Console.WriteLine(i);
            if (i == 9999)
            {
                Console.WriteLine("MicroCache Sleep");
                Thread.Sleep(10000);
                mre.Set();
            }
        }

        static void GetPageRegularCached(object state)
        {
            Stopwatch sw = Stopwatch.StartNew();
            int i = (int)state;
            WebClient wc = new WebClient();
            string s = wc.DownloadString("Http://localhost:1000/Default.aspx?cache=regular");
            totregularcachebytes += s.Length;
            wc.Dispose();
            sw.Stop();
            totregularcachetime += sw.ElapsedMilliseconds;
            if (i % 1000 == 0) Console.WriteLine(i);
            if (i == 9999)
            {
                Console.WriteLine("Regular Cache Sleep");
                Thread.Sleep(10000);
            }
        }

        static void GetPageNonCached(object state)
        {
            Stopwatch sw = Stopwatch.StartNew();
            int i = (int)state;
            WebClient wc = new WebClient();
            string s = wc.DownloadString("Http://localhost:1000/Default.aspx?cache=false");
            totnoncachebytes += s.Length;
            wc.Dispose();
            sw.Stop();
            totnoncachetime += sw.ElapsedMilliseconds;
            if(i % 1000==0) Console.WriteLine(i);
            if (i == 9999)
            {
                Console.WriteLine("Non-Cache Sleep");
                Thread.Sleep(10000);
            }
        }
    }
}

The program uses the Threadpool to generate 10,000 requests each for the page - using MicroCache, the regular ASP.NET Cache, and for a page that is not cached at all. I use IIS Express here, you do not want to use the built-in ASP.NET Web Development server, as it will not accurately reproduce the behavior of the IIS engine for production.  Essentially what the threadpool does is to saturate IIS with requests, causing them to be queued, and I've put in a few Thread.Sleep(10000) statements at the bottom of each loop to "let the CPU cool down" and the requests to finish unwinding.

Here I am only testing the caching of the payload that is bound to the Gridview - but you can cache an entire page, too. Or if you have content that you don't want cached, you can use the SubstitutionCache control or the corresponding API.

Here are the results of my tests:




The vertical axis represents the total time in milliseconds to finish delivering the 10,000 requests, and the horizontal axis represents the expiration setting of the cache - starting at .5 seconds and running out to a test with a 30 second expiration time. An interesting thing I found is that with this type of usage, the MicroCache is not more efficient than using the regular ASP.NET Cache. But the really important lesson here is that in all test cases - even with cache expirations of as little as one - half second - caching improved throughput by a large margin, with the best performance  appearing to occur with a 2 second cache expiration.

You can download the complete Visual Studio 2010 solution here. You'll need to have the Northwind database attached to a local instance of SQL Server - or, if you are using SQLEXPRESS, change the connection string. And again, you either want to use IIS Express or IIS - not the built-in Visual Studio Web Development Server.

By Peter Bromberg   Popularity  (10059 Views)