Get More Out of Umbraco Using Server-Side Caching Strategies
Heads Up!
This article is several years old now, and much has happened since then, so please keep that in mind while reading it.
So what can be done to avoid CPU spikes?
Just get a bigger/faster server right? Well before you scale with brute force you should consider refactoring your code to eliminate bottlenecks. On one particular site I ran the built-in Umbraco Profiler along with the Visual Studio 2012 profiler to find bottlenecks. After deciding that the operation causing the trouble couldn't be refactored to improve performance (due to business rules) and refactoring the ones that could be optimized, I came up with three caching strategies to overcome the server spikes:
1. The Built-in Umbraco CachedPartial()
Umbraco has a built-in partial cacher which can easily cache a partial by page, by member and expire after a given amount of time. This is wonderful for a lot of uses where the page does not need to be dynamic past page\member combinations. However the particular site I was building needed to be cached by city\state which added the need for a custom key to be passed to Umbraco which presently does not allow for.
2. Custom Partial Cache
So since the built-in partial cacher didn't meet my needs, I examined the core and quickly put together my own cacher. My cacher then globbed together visitors by city\state and kept everyone visiting quickly. I was able to determine my own rules for cache clearing and which partials were cached and which were not. For this example, the user's current location is held in a "User" object.
This snippet is my custom cacher which was built based on the Umbraco Core source:
//the custom cacher
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Web;
using System.Web.Caching;
using System.Web.Mvc;
using System.Web.Mvc.Html;
using System.Web.Script.Serialization;
using System.Text.RegularExpressions;
using Umbraco.Core;
using Umbraco.Core.Logging;
using Umbraco.Web;
using MyConstants;
namespace MyProject.Umbraco.Extended
{
public static class PartialCacher
{
//an extension method is created to be used in the views
public static IHtmlString MyCachedPartial(
this HtmlHelper htmlHelper,
string partialViewName,
object model,
int cachedSeconds,
ViewDataDictionary viewData = null,
string customKey = "",
bool logPartial = false
)
{
//the key will determine the uniqueness of the cache
//the key ends up looking like this {prefix-url-customkey}
var cacheKey = new StringBuilder();
cacheKey.Append(MyConstants.PARTIAL_CACHE_PREFIX);
cacheKey.Append(HttpContext.Current.Request.Url);
if(customKey != "")
cacheKey.Append("-" + customKey);
var finalCacheKey = cacheKey.ToString().ToLower();
if(logPartial)
LogHelper.Info<IHtmlString>(partialViewName + " Partial Cacher Key=> " + finalCacheKey);
//this code was lifted from the Umbraco Core and does the actual caching/retrieval of html
return ApplicationContext.Current.ApplicationCache.GetCacheItem(
finalCacheKey,
CacheItemPriority.NotRemovable, //not removable, the same as macros (apparently issue #27610)
null,
new TimeSpan(0, 0, 0, cachedSeconds),
() => htmlHelper.Partial(partialViewName, model, viewData));
}
}
Using the Custom Partial Cacher in a view is easy:
//usage in view
@Html.MyCachedPartial("MyView", new MyModel(), 86400, null, User.CurrentLocation.City + "-" + User.CurrentLocation.State)
Then of course we have to come up with rules on when to clear it. For my needs I cleared it every time document was published:
//clear cache on publish
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Umbraco.Core;
using Umbraco.Core.Models;
using Umbraco.Core.Logging;
using Umbraco.Core.Publishing;
using Umbraco.Web;
using umbraco.interfaces;
namespace MyProject.Umbraco.Extended
{
public class PublishingRules : ApplicationEventHandler
{
protected override void ApplicationStarting(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext)
{
base.ApplicationStarting(umbracoApplication, applicationContext);
PublishingStrategy.Published += PublishingStrategy_Published;
}
void PublishingStrategy_Published(IPublishingStrategy sender, global::Umbraco.Core.Events.PublishEventArgs<IContent> e)
{
//we will clear the cache on each publish
//the ClearCacheByKeySearch method clears cache items starting with the string provided.
ApplicationContext.Current.ApplicationCache.ClearCacheByKeySearch(MyConstants.PARTIAL_CACHE_PREFIX);
LogHelper.Info<PublishingRules>("Partial Cache cleared due to publish.");
}
}
}
3. Singleton Cache
Finally, if you'd like to build a cache that works in views AND the backoffice, you could always press a Singleton (or several) into service. I've set up many Singletons to represent the site settings node, pools of data and slow loading view models. What happens here is a lazy lookup cache is loaded on first usage of the data. The data is then cleared based on any rules you decide. As you see in my example, a complex expensive data object could be lazy loaded on first usage, then each subsequent request would simply just use the cached data. I've taken care to make the Singleton thread-safe by using a double-check lock. The best part is this Singleton cache can be cleared and rebuilt as needed.
The Singleton Cache:
//the Singleton Cache
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Umbraco.Core.Models;
using Umbraco.Web;
using Umbraco.Core.Logging;
namespace MyProject.Umbraco.Extended
{
//the Singleton class
public sealed class MyHighLevelCache
{
private static volatile MyHighLevelCache instance;
private static object padLock = new Object();
private MyHighLevelCache() { }
//this is what we will cache
public SomeOtherwiseExpensivelyCreatedObject MyCachedData
{
get;
private set;
}
//this is the magic
public static MyHighLevelCache Instance
{
get
{
if (instance == null)
{
//thread safety
lock (padLock){
if (instance == null)
{
instance = new MyHighLevelCache();
//this is where you will build your data once instead of looking it up and building it on each request
MyCachedData = new SomeOtherwiseExpensivelyCreatedObject();
LogHelper.Info<MyHighLevelCache>("MyHighLevelCache Created");
}
}
}
return instance;
}
}
public void ClearCache()
{
if(instance != null){
//thread safety
lock(padLock){
if(instance != null){
LogHelper.Info<MyHighLevelCache>("MyHighLevelCache Cleared");
instance = null;
}
}
}
}
}
}
You can literally use this wherever you need and it will lazy load the data and persist it until you clear it:
///usage, can be used on views or in other code
MyHighLevelCache.Instance.MyCachedData.Whatever;
Of course if your data doesn't change, then you don't need to do this; but dynamic CMS's need to update from time to time:
//clear cache usage
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Umbraco.Core;
using Umbraco.Core.Models;
using Umbraco.Core.Logging;
using Umbraco.Core.Publishing;
using Umbraco.Web;
using umbraco.interfaces;
namespace MyProject.Umbraco.Extended
{
public class PublishingRules : ApplicationEventHandler
{
protected override void ApplicationStarting(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext)
{
base.ApplicationStarting(umbracoApplication, applicationContext);
PublishingStrategy.Published += PublishingStrategy_Published;
}
void PublishingStrategy_Published(IPublishingStrategy sender, global::Umbraco.Core.Events.PublishEventArgs<IContent> e)
{
//we are going to clear our cache if someone publishes a 'SomethingIamCaching' document type.
var clearMyHighLevelCache = false;
foreach (var node in e.PublishedEntities)
{
if (node.ContentType.Alias == "SomethingIamCaching")
{
clearMyHighLevelCache = true;
}
}
//let's only clear the cache once instead of per document
if (clearMyHighLevelCache)
{
MyHighLevelCache.Instance.ClearCache();
}
}
}
}
Summing It Up
Even though Umbraco does cache the data and keeps us insulated from DB requests, large datasets will eventually result in longer lookup and load times as models are pieced together for each request. After using a mix of the caching strategies outlined here, the CPU spikes on the server disappeared during high volume loads. Caching your data is especially helpful if the data has a large flat tree structure or there is an unavoidable expensive operation. Just be mindful of when to clear the cache and when not to because clearing the cache too often negates the benefits.
Please note that these caching strategies cache into memory as opposed to a physical disk. So make sure you have a little headspace with respect to RAM. The amount of RAM needed (not much) vs CPU savings (a lot) can be dramatic.
A Note About Load-Balancing
I've used option 2 & 3 with success in a load-balanced environment. The trick to that is sending a signal from the admin server to the load-balanced servers. I overcame this by setting up surface controller actions that cleared the local caches. The admin node iterated through the <server/> nodes of the UmbracoSettings.config and visited the url's as needed (maybe on publish or whatever). My colleague Tom Fulton has reported a bug for option 1 in load balanced situations, you can find the details here: http://issues.umbraco.org/issue/U4-2879
Thank you to Tom Fulton for his feedback and contributions to this article!
For more information:
CachedPartial - http://our.umbraco.org/documentation/Reference/Mvc/partial-views
Profiling - http://msdn.microsoft.com/en-us/library/ms182372.aspx
Singletons - http://msdn.microsoft.com/en-us/library/ff650316.aspx
Kevin Giszewski
Kevin is on Twitter as @kevingiszewski