Summary: This article is for developers who are sold on caching, but want to know how to implement
caching properly. This article covers best practices for web developers
using data caching. Many of the guidelines apply whether using
ASPCache
or other caching objects. These best practices will help developers avoid
common mistakes and use data caching more effectively.
This article explains some best practices for data caching in your ASP applications.
Specifically, data caching with
ASPCache is covered, though many of these principles
also apply to caching with the ASP Application object.
These best practices are proven to yield the best web site performance, reduce bugs,
and obtain the most reliable behavior.
A discussion on caching best practices is important, because many developers are used to
thinking in terms of local variables that don't unexpectedly change, in a single-threaded
environment. The programming landscape changes when caching is introduced:
ASPCache is a threadsafe COM object, which means it can be accessed
simultaneously by any number of threads, without corrupting data. This is a
necessity because
each ASP page executes in
its own thread, and ASPCache provides data sharing across pages. Another page
can change the data at any point in a page's execution. In addition, an item
may be removed from the
Cache at any time, either because the item has expired, or because it is under-used and is being
cleaned up. By applying the pointers in this article, your ASP application will
behave as designed.
The best practices explained below are:
1) Store the cached data in a variable first
1a) Be careful when retrieving objects with VB/VBScript Set
2) Don't cache Apartment-Threaded COM objects
2a) Don't cache VB COM objects
2b) Don't cache JScript arrays or classes, or VBScript classes
2c) Do cache value types
2d) Do cache COM objects that are "Both" threaded, threadsafe, and written in C++
2e) Use Recordset.GetRows() to store Recordset data
3) When caching large amounts of data, enable the cleanup mechanisms
3a) Cache.FlushEnabled and Cache.FlushInterval must be set
to enable background cleanup of expired and under-used items
4) Consider that 2 or more instances of an ASP page may be run simultaneously
5) Cache data in the most useful format possible
5a) If applicable, fragment caching is the most efficient form of caching
6) Turn debugging off
7) Be aware that the Session object serializes all requests in the same session
As an aside, many of these best practices apply even if you are not using ASPCache - for example,
if you are using the ASP Application object to store shared data. Specifically, best
practices 2, 4, 5, 6, and 7 apply equally whether you are using ASPCache or the ASP Application
object.
Best Practices Code Example
Let's start with a code example that applies several of these best practices. This code shows
a good data caching
design pattern to use in your web apps.
In subsequent code,
best practices are referenced using labels like BP#3, which stands for Best Practice #3.
ASPCache objects are always instantiated in the global.asa file - this is necessary to give the
ASPCache object Application-wide scope. Also, note that background cleanup (flushing) is enabled for
the ASPCache object:
REM #######################################
REM GLOBAL.ASA #
REM #######################################
<OBJECT RUNAT=Server SCOPE=Application ID=Cache PROGID=ASPCache></OBJECT>
<SCRIPT LANGUAGE=VBScript RUNAT=Server>
Option Explicit
Sub Application_OnStart
Cache.FlushEnabled = True
Cache.FlushTimeout = 600000
Cache.FlushInterval = 30000
End Sub
</SCRIPT>
Next we look at an ASP page that uses cached data from a SQL Server database. The Cache object
refers to the ASPCache instance declared in global.asa. The cached data expires
every minute, which keeps the displayed data relatively fresh.
<!-- #include file="i_db.asp" -->
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<HTML><HEAD>
<TITLE>Cached Array From Pubs</TITLE>
</HEAD>
<BODY>
<H3>Cached Array From Pubs</H3>
<TABLE width="100%" border=1 bordercolor=gray cellspacing="0">
<tr><th>Title ID</th><th>Title</th><th>Price</th></tr>
<%
Function GetRecordset()
Dim rs
Set rs = Server.CreateObject("ADODB.Recordset")
rs.Open "SELECT title_id, title, price FROM titles", connStr
Set GetRecordset = rs
End Function
Function GetCachedArray()
GetCachedArray = Cache("cachedArray")
If IsEmpty(GetCachedArray) Then
Dim rs
Set rs = GetRecordset()
GetCachedArray = rs.GetRows()
Dim bAdded
bAdded = Cache.Add("cachedArray", GetCachedArray, 60000)
If bAdded Then
Response.Write "<div style=""color:red"">Cache Refreshed</div>"
End If
End If
End Function
Dim cachedArray
cachedArray = GetCachedArray()
Dim nLastRow, iRow
nLastRow = UBound(cachedArray, 2)
For iRow = 0 To nLastRow
Response.Write "<tr><td>" & cachedArray(0, iRow) & "</td>"
Response.Write "<td>" & cachedArray(1, iRow) & "</td>"
Response.Write "<td>$" & cachedArray(2, iRow) & "</td></tr>" & vbCRLF
Next
%>
</TABLE>
</BODY></HTML>
Now let's discuss the data caching best practices and the reasons behind them:
1) Store the cached data in a variable first
This is a design pattern that ensures that the cached object will
not be destroyed (removed) while you are working with it. By setting a reference
to the object first (a variable), you ensure that it won't be change or disappear at some random point in
your code.
The best pattern for using cached data could be written in pseudo-code like this:
Var x = Cache.Read(key)
If (x Is Null) Then
Build x the slow way
Then store x in the Cache:
Cache.Store(key, x)
End If
Now, use x however you want
x will not disappear on you
One way this pattern is implemented is by putting it in a function that returns the cached
data. In the code above, function GetCachedArray() implements this pattern.
Here is an example of how not to access items in the cache:
<H3>How not to use ASPCache</H3>
<%
If (Not Cache.Exists("key")) Then
Dim rs, sDisplay
Set rs = Server.CreateObject("ADODB.Recordset")
rs.Open "SELECT title_id, title, price FROM titles", connStr
sDisplay = FormatRecordset(rs)
Cache("key") = sDisplay
Cache.SetExpiration "key", 60000
Response.Write "<div style='color:red'>Cache Refreshed</div>"
End If
%>
<DIV style="font-family:Verdana">
<% = Cache("key") %>
</DIV>
What is wrong with this code? Only one thing: The item in the cache labelled "key" may be present when
Cache.Exists() is called, but may have been removed from the cache by the time
Cache("key") is called. If you are using
the expiration feature of ASPCache, any expiring item can be removed when your code
least expects it. This is considered a race condition bug - it is
timing dependent and will not occur very often, but can be difficult to find and debug.
The
solution to this predicament is to set a variable to the cached data before you do anything else. Since
the variable resides in the page and not in the cache, its data will not change or go away unexpectedly.
1a) Be careful when retrieving objects with VB/VBScript Set
The following code checks whether an item in the cache is an object, and uses the VBScript
Set keyword if it is an object. What is wrong here?
Dim key, val
If IsObject(Cache(key)) Then
Set val = Cache(key)
Else
val = Cache(key)
End If
The lurking problem is due to a race condition.
The problem is that, if expiration is enabled, the item may expire after the IsObject
call but before the Set val = Cache(key) line. If this occurs, the Set
line will raise an error, because Cache(key) will return Empty, and keyword
Set raises an error if the right-hand side is not an object.
To get around this race condition/Set issue, I recommend adding a function like
this:
Function GetCachedValue(key, ByRef val)
If IsObject(Cache(key)) Then
On Error Resume Next
Set val = Cache(key)
If Err.number <> 0 Then
GetCachedValue = False
Else
GetCachedValue = True
End If
Else
val = Cache(key)
If IsEmpty(val) Then
GetCachedValue = False
Else
GetCachedValue = True
End If
End If
End Function
This function returns False if the item doesn't exist, and True
if the item exists. Whether the item is an object or not, it is returned in the val
parameter. And lastly, this function handles the race condition where the item expires after
the IsObject call but before the Set line.
The GetCachedValue() function can be used like:
Dim s, o
If GetCachedValue("string", s) Then
%><p>Cache("string"): "<% =s %>"</p><%
Else
%><p>Cache("string") not present</p><%
End If
If GetCachedValue("oXml", o) Then
%><p>Cache("oXML").xml: "<% =Server.HTMLEncode(o.xml) %>"</p><%
Else
%><p>Cache("oXml") not present</p><%
End If
Note that a function like GetCachedValue() is not necessary in JScript. 
This is because JScript does not require
a keyword Set, and objects and values are set in the same way.
2) Don't cache Apartment-Threaded COM objects
Most COM objects are apartment-threaded - including all VB COM objects, and many
other widely used COM objects (including ADO and the FileSystemObject). If you
store an apartment-threaded COM object in ASPCache (or in the ASP Application object),
all access to that
object will be marshalled to the original thread where the object was created. This can drastically
reduce the performance of your site - enough to negate the benefits of caching.
A quick explanation of apartment-threading: In COM, threads live in either a
single-threaded apartment (STA)
or in a multithreaded apartment (MTA). Single-threaded apartments contain
only one thread, and the multithreaded apartment contains all the
threads in the process that are marked MTA. Apartment-threaded COM objects can be
created in an STA and invoked directly from that thread. But all cross-apartment (cross-thread)
calls to the object are marshalled through the Win32 message processing system. Through
this mechanism, all
calls to objects in the STA are actually executed in the STA thread. The advantage
is that calls to STA objects are serialized, which avoids any thread safety issues. The
disadvantage is that performance is significantly degraded - in addition to the method call to message queue
packaging/unpackaging overhead, all calls to any object in the STA are
queued until all previous calls to objects in the STA have completed executing.
For more information on COM threading models and apartments, see
http://msdn.microsoft.com/library/default.asp?url=/library/en-us/com/aptnthrd_4a3w.asp.
In ASP, page code is executed in an STA thread. This means that any apartment-threaded COM
object created within the page can be invoked directly. But if an apartment-threaded COM object
is used that was created in another ASP page (in another STA), any calls to that object are cross-apartment
calls, and will have to pass through the marshalling mechanism.
ASPCache supports the Both COM threading model, which means it can be accessed
directly from STAs and from MTAs, without any marshalling. The problem occurs if you
store an apartment-threaded object in ASPCache - that object can only be invoked via STA marshalling.
So, avoid storing apartment-threaded COM objects in ASPCache.
Following are a few corollaries to this rule:
2a) Don't cache VB COM objects
All VB COM objects are apartment-threaded. VB 6 is not capable of generating
thread-safe code. Therefore no VB COM objects should ever be stored in ASPCache.
2b) Don't cache JScript arrays or classes, or VBScript classes
JScript arrays are actually COM objects, as are JScript classes and VBScript classes. These
COM objects are not thread-safe and are apartment-threaded, so
don't store them in ASPCache.
2c) Do cache value types
All value types, including numeric types, strings, and safe arrays (VB arrays), are safe to store
in ASPCache. They are not COM objects, and thus the threading rules and marshalling don't apply.
2d) Do cache COM objects that are "Both" threaded, threadsafe, and written in C++
The only COM objects that should be stored in ASPCache are those that are
"both" threaded, that use locking to
prevent simultaneous access to data members, and are written in C++. The objects should
also aggregate the Free-Threaded Marshaller. "Both" threaded
objects can be accessed directly from any ASP page without going through the apartment-threaded marshaller.
An example is that other instances of ASPCache can be stored within a parent ASPCache object.
This is an effective way of storing separate dictionaries within your application. It can also be
used to set different flushing and cleanup behavior for different sets of data.
2e) Use Recordset.GetRows() to store Recordset data
Using Recordset.GetRows() is a best practice for these reasons:
- Performance - avoids column name lookup by string
- Performance - avoids a number of IDispatch calls, which are significantly slower than
reading from an array index
- Performance - the 2d safe array returned from
Recordset.GetRows() is a value type. It
can be stored and read from ASPCache without any marshalling overhead. - Performance - avoids the overhead of calling
Recordset.Clone() each time the
cached recordset is used. This would be necessary so multiple ASP pages won't interfere
with each other's read point.
A code snippet for reading database data and storing it in the cache:
Dim rs, rows
Set rs = Server.CreateObject("ADODB.Recordset")
rs.Open "SELECT title_id, title, price FROM titles", connStr
rows = rs.GetRows()
Dim bAdded
bAdded = Cache.Add("db_rows", rows, 60000)
This example code is useful for displaying the contents of the GetRows() array.
Note that since the values are retrieved using numeric indexes, the retrieval is much faster than
rs("ColumnName").Value .
Dim nLastRow, iRow
nLastRow = UBound(rows, 2)
For iRow = 0 To nLastRow
Response.Write "<tr><td>" & rows(0, iRow) & "</td>"
Response.Write "<td>" & rows(1, iRow) & "</td>"
Response.Write "<td>$" & rows(2, iRow) & "</td></tr>" & vbCRLF
Next
3) When caching large amounts of data, enable the cleanup mechanisms
ASPCache includes cleanup mechanisms for these reasons:
- To make it easy for the developer to specify the lifetime of cached data
- To limit the memory and resources consumed by cached data
- To ensure that the most-used items remain in the cache, while the
least-used items are evicted from the cache
The computer science concept of caching means to store results so later
identical calculations can be short-circuited. Caching trades storage
for performance short-cuts. But there can be a point of diminishing returns: If
no cleanup is enabled, and a large set of keys are used, it is possible that the
amount of cached data can grow too large, resulting in excessive memory use, memory paging, and decreased
performance. This is why it is important to use cleanup mechanisms that optimize
the memory vs. performance trade-off.
If a small set of keys are used, and there is no need for item expiration, your application
may work best without any cleanup enabled. But for applications that cache lots of data
and have a large set of possible keys, it is critical to enable and then tune the cleanup mechanisms.
ASPCache includes three separate cleanup mechanisms. Each cleanup mechanism operates
independently of the others, so they can be used in any combination. They are:
- Expiration - Sets the maximum lifetime for an item in the cache. Each item in the
cache can have its own lifetime - this lifetime is absolute, not to be confused with sliding expiration.
When its lifetime is exceeded, the item can no longer be accessed in the cache.
Expiration is enabled via Cache.Add() and Cache.SetExpiration():
Dim bAdded
bAdded = Cache.Add("key", value, 60000)
Cache.SetExpiration "key", 120000
- Flushing - Flushing removes items that have not been used in a while.
The
FlushTimeout property applies to all items in the cache - if the background cleanup thread
finds an item that has not been accessed (read or written) in FlushTimeout milliseconds, the item
is removed.
Flushing is enabled by enabling the background cleanup thread, and then setting an
appropriate FlushTimeout:
Cache.FlushEnabled = true
Cache.FlushInterval = 300000
Cache.FlushTimeout = 600000
This code tells the cleanup thread to run every 5 minutes. If the cleanup thread
finds an item that has not been accessed (read or written) in 10 minutes, that item is
marked for removal. Removal of all marked items occurs after scavenging is complete.
Because cleanup is not immediate, it is possible that an item could be unused for up to 15 minutes
before it is removed.
Note that the cleanup thread
runs at below normal priority, so if the CPU is maxed out with ASP requests (which run at
normal priority), the cleanup thread won't run until some unused cycles are available. It
is also important that the cleanup thread does not lock access to the Cache - it makes
copies of the expiration and timestamp data, so the cleanup (scavenging) process operates independently of
the cached data. With these mechanisms in place, the background cleanup thread has virtually no
impact on the performance of your ASP pages.
- Shrinking - Shrinking lets the developer set a
MaxSize for the cache. If that
capacity is exceeded, the least frequently used items are removed from the cache to reduce the Count
to MaxSize.
Shrinking can be enabled using this code:
Cache.MaxSize = 5000
When shrinking is enabled, the background cleanup thread is run any time the number of items in the
cache exceeds the MaxSize. The items with the longest TimeSinceLastUse
are removed from the cache, to reduce the number of items to MaxSize.
3a) Cache.FlushEnabled and Cache.FlushInterval must be set to enable
background cleanup of expired and under-used items
By default, ASPCache's background cleanup thread does not run. This results in
expired items not being removed until they are accessed after their expiration. While this
behavior honors the principle of absolute expiration, it does not provide optimal usage of memory
and resources. To enable background cleanup of expired items, the background cleanup
thread must be enabled using the FlushEnabled and FlushInterval properties:
Cache.FlushEnabled = true
Cache.FlushInterval = 300000
Cache.FlushTimeout = 0
In this case, the names of the FlushEnabled and FlushInterval properties
are misleading - they could be better named BackgroundCleanupEnabled and
BackgroundCleanupInterval. The background cleanup thread performs all 3 types of cleanup,
but the thread must be enabled by setting FlushEnabled and FlushInterval.
Shrinking also occurs on the background cleanup thread. However, FlushEnabled does
not have to be set. If the number of items in the cache ever exceeds MaxSize,
the background cleanup thread is started, independent of FlushEnabled and
FlushInterval.
4) Consider that 2 or more instances of an ASP page may be run simultaneously
On a web site, it is entirely possibly that 2 or more instances of the same page may
be run simultaneously. As long as each page has its own variables, then parallel
execution will not affect the page's behavior. As soon as you add any global or
shared resources that may be shared between multiple pages or multiple instances of the same
page, you have to consider what could happen.
For example, consider this code, which creates and stores data in the cache if the data is not already
cached:
<%
Dim rows
rows = Cache("db_rows")
If IsEmpty(rows) Then
Dim rs, rows
Set rs = Server.CreateObject("ADODB.Recordset")
rs.Open sQuery, connStr
rows = rs.GetRows()
Cache("db_rows") = rows
Cache.SetExpiration "db_rows", 60000
Response.Write "<div style='color:red'>Cache Refreshed</div>"
End If
%>
There is nothing wrong with this example. However, the developer should be aware that the
If IsEmpty() block may be simultaneously executed by 2 page requests. Thus the
call to Cache("db_rows") = rows may be replacing a value that was just inserted there by
another page, or another instance of the same page. In addition, the call to
Cache.SetExpiration() may be overwriting another value. Even though there
was no value in the cache when
Cache("db_rows") was read, there may be a value there by the time
Cache("db_rows") is written. The developer should be sure that overwriting the value
that was written by another page instance will not cause any undesirable side effects.
If the possibility of overwriting the value stored by another page instance is problematic, the
developer should use the Cache.Add() method. Cache.Add() will only add
the key and value if the key is not already present in the cache - if will never overwrite an
existing value. Like all other ASPCache methods, Add() is atomic and threadsafe.
If the item is already present in the cache, Add() will not replace the value and will return false.
5) Cache data in the most useful format possible
The goal here is to cache your data in a format that is immediately useful when an
ASP page reads the data from the cache. It is based on the assumption
that the cached data will be read more often than it is updated. If this
assumption is not true, then caching is not providing much value.
If your data has to be transformed into
a certain format each time it is read from the cache, it would be more efficient to
do that transformation work when you first store the data in the cache. By minimizing
the work required to use the cached data, you will significantly improve the
performance of your web app.
For example, consider a situation where you store a Recordset array in the cache,
and most of the time when the data is displayed it is sorted by the 2nd column.
By sorting by the 2nd column before the data is stored in the cache, you can skip the
sorting work for many of the times the data is displayed.
Also, if a recordset is always displayed the same way, within a table or within
some combo-box output, it is more efficient to cache the output string than to cache the
data as an array, and then rebuild the output each time the page is viewed. In contrast,
if the data is frequently displayed differently, or if lookups are required, then caching a
data array is more practical.
Database-driven, output cached, state-preserving drop-down list box
This next code example shows how to implement a drop-down list box that is populated from the database,
output cached in ASPCache, and that preserves its selected value across requests.
Caching the list box's output is more efficient than re-creating it for each request. The
interesting part is how the selected attribute is inserted in the correct place before the
output is displayed, to maintain continuity for the user.
This is a good example of caching data in the most useful format possible.
The most useful format for sharing across pages is the database populated list box HTML.
However, that output
still needs to be tweaked before it can be displayed, so that the user's selection is
preserved.
To use this database-driven, cached, state-preserving drop-down list box, all you have to
do is include this code in your page:
DisplaySelectedDBDropDown "ListBoxName", "SELECT ID, Display FROM Table"
Here is the code for the list box implementation:
<%@ Language=VBScript %>
<% Option Explicit
Dim cnn
Sub OpenConn
If Not IsObject(cnn) Then
Set cnn=Server.CreateObject("ADODB.Connection")
cnn.Open connStr
End If
End Sub
Sub CloseConn
If IsObject(cnn) Then
cnn.Close
Set cnn = Nothing
End If
End Sub
Function GetCachedDBDropDown (sDDLName, sQuery)
Dim sKey
sKey = sDDLName & "DropDown"
GetCachedDBDropDown = Cache(sKey)
If Not IsEmpty(GetCachedDBDropDown) Then
Exit Function
End If
Dim s, rst
s = "<select name=""" & sDDLName & """>"
OpenConn
Set rst = cnn.Execute(sQuery)
Do Until rst.EOF
s = s & "<option value=""" & rst(0) & """ >"
s = s & rst(1) & "</option>"
rst.MoveNext
Loop
s = s & "</select>"
Cache.Add sKey, s, 3600000
GetCachedDBDropDown = s
End Function
Sub DisplaySelectedDBDropDown (sDDLName, sQuery)
Dim s
s = GetCachedDBDropDown(sDDLName, sQuery)
Dim selID
selID = Request(sDDLName)
If IsEmpty(selID) Then
Response.Write s
Exit Sub
End If
Dim sSearch, i, splitPoint
sSearch = "<option value=""" & selID & """"
i = InStr(s, sSearch)
If i = 0 Then
Response.Write s
Exit Sub
End If
splitPoint = i + Len(sSearch)
Response.Write Left(s, splitPoint - 1) & " selected" & Mid(s, splitPoint)
End Sub
Dim sCategoryQuery
sCategoryQuery = "SELECT CategoryID, CategoryName FROM Categories" _
& " ORDER BY CategoryName;"
%><html>
<head>
<title>Products optimized with ASPCache</title>
</head>
<body>
<form action="Products_ach.asp" method="post" ID="Form1">
Select a Category:
<% DisplaySelectedDBDropDown "Category", sCategoryQuery %>
<input type="submit" value="Show Products">
5a) If applicable, fragment caching is the most efficient form of caching
If a section of content will be displayed identically for multiple users and/or multiple requests, we
recommend using ASPCache's page fragment caching mechanism. Output caching is much more efficient
than caching data and rebuilding the output for each page view. ASPCache's fragment caching
implementation is further optimized to:
- Avoid overhead from BSTR allocation and concatenation
- Avoid text conversion to Unicode and back to ANSI
- Avoid COM object creation (no registry hits)
- Avoid repetitive file access
- Avoid execution of script code and IDispatch calls
Thanks to all these optimizations, ASPCache's fragment caching mechanism is better than
3 times faster than caching strings and calling
Response.Write() on the cached strings. Our tests have shown ASP pages
using ASPCache fragment caching can execute at over 1000 requests per second.
This code snippet shows how to use ASPCache's fragment caching:
Dim bFragWritten
bFragWritten = Cache.WriteFile("products/product" & pfid & ".htm")
If Not bFragWritten Then
Call UnknownProduct( )
End If
Sub UnknownProduct( )
Response.Write "<h3>Product cannot be found</h3>"
End Sub
The page fragment files can be programatically created and database driven. We
recommend using Active Page Generator for
event-driven, programmable, file generation. Typically events like a database
change or posting a new article would dictate that the page fragment file be re-generated.
Custom code and XSL/T can also be used to generate the page fragments, but Active Page Generator
tends to be more powerful and flexible.
6) Turn debugging off
When ASP debugging is enabled, all ASP requests execute in a single thread. So, make sure you
turn debugging off before running any performance tests or deploying to a live site.
Also, be aware that the robustness of your code with respect to thread-safety cannot be tested
until debugging is turned off.
7) Be aware that the Session object serializes all requests in the same session
All requests using the same session cookie are serialized to the same thread. This means
you don't have to worry about locking or thread safety when using the Session object.
But, beware when testing: If your test clients all pass in the same SessionID cookie, all requests
will be handled by the same ASP thread, so the test will not properly reflect the performance
and scalability of
your application.