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.