|
Introduction to ASPCache |
|
See Also: |
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:
SetRecordset.GetRows() to store Recordset dataCache.FlushEnabled and Cache.FlushInterval must be set
to enable background cleanup of expired and under-used itemsAs 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.
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
' BP#3) Enable the cleanup mechanisms (flushing)
' Enable background cleanup
Cache.FlushEnabled = True
' Flush items that haven't been used in 10 minutes
Cache.FlushTimeout = 600000
' Perform the background cleanup every 5 minutes
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()
' Grab the recordset containing the data
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()
' Retrieve the cached array
' BP#1) Store the cached data in a variable first
GetCachedArray = Cache("cachedArray")
If IsEmpty(GetCachedArray) Then
' Item is not in the cache
' Create and cache the recordset data
Dim rs
Set rs = GetRecordset()
' Preferable to cache an array, not a recordset,
' Arrays are lighter, faster, and more agile than recordsets.
' BP#2d) Use Recordset.GetRows() to store Recordset data
GetCachedArray = rs.GetRows()
' Add the array to the ASPCache collection
' Expire the item in 1 minute
' BP#3) Enable the cleanup mechanisms (expiration)
' BP#4) Be aware that Cache.Add may return false, if the item was
' already added
Dim bAdded
bAdded = Cache.Add("cachedArray", GetCachedArray, 60000)
' Display a message that the cache was refreshed
If bAdded Then
Response.Write "<div style=""color:red"">Cache Refreshed</div>"
End If
End If
End Function
' Retrieve the database array, either from the
' cache or directly from the DB.
Dim cachedArray
cachedArray = GetCachedArray()
' Display the array:
Dim nLastRow, iRow
nLastRow = UBound(cachedArray, 2)
' Loop through the array
For iRow = 0 To nLastRow
' Write out the array contents
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:
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
' Create the cached data
Dim rs, sDisplay
Set rs = Server.CreateObject("ADODB.Recordset")
rs.Open "SELECT title_id, title, price FROM titles", connStr
' This function takes the recordset and creates a string
' displaying the data - this can be a best practice if the data
' will only be displayed one way.
sDisplay = FormatRecordset(rs)
' Store the data in the cache
Cache("key") = sDisplay
Cache.SetExpiration "key", 60000
' Display a message that the cache was refreshed
Response.Write "<div style='color:red'>Cache Refreshed</div>"
End If
' Display the cached data
' This is bad! - the item could have been removed from the Cache after
' Cache.Exists() returned true.
%>
<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.
SetThe 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:
' Gets a cached object or data
' Only necessary because of VBScript "Set"
' Returns False if value not present
Function GetCachedValue(key, ByRef val)
If IsObject(Cache(key)) Then
' Error handling required due to possibility of race condition
On Error Resume Next
' Get object
Set val = Cache(key)
If Err.number <> 0 Then
GetCachedValue = False
Else
GetCachedValue = True
End If
Else
' Reading a regular variable
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.
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:
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.
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.
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.
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.
Recordset.GetRows() to store Recordset dataUsing Recordset.GetRows() is a best practice for these reasons:
Recordset.GetRows() is a value type. It
can be stored and read from ASPCache without any marshalling overhead.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:
' Retrieve the recordset data
Dim rs, rows
Set rs = Server.CreateObject("ADODB.Recordset")
rs.Open "SELECT title_id, title, price FROM titles", connStr
rows = rs.GetRows()
' Add the array to ASPCache
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 .
' Display the array:
Dim nLastRow, iRow
nLastRow = UBound(rows, 2)
' Loop through the array
For iRow = 0 To nLastRow
' Write out the array contents
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
ASPCache includes cleanup mechanisms for these reasons:
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 is enabled via Cache.Add() and Cache.SetExpiration():
' Add an item that expires after 1 minute
Dim bAdded
bAdded = Cache.Add("key", value, 60000)
' Extend the item's expiration to 2 minutes (from now)
Cache.SetExpiration "key", 120000
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:
' This code would normally be placed in global.asa
' Enable the background cleanup thread
Cache.FlushEnabled = true
' Have the cleanup run every 5 minutes
Cache.FlushInterval = 300000
' Set so items that haven't been used in 10 minutes
' are removed (flushed).
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.
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:
' This code would normally be placed in global.asa
' Enable shrinking
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.
Cache.FlushEnabled and Cache.FlushInterval must be set to enable
background cleanup of expired and under-used itemsBy 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:
' This code would normally be placed in global.asa
' Enable the background cleanup thread
Cache.FlushEnabled = true
' Have the cleanup run every 5 minutes
Cache.FlushInterval = 300000
' Disable flushing, so rarely-used items aren't removed
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.
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
' Create the cached data
Dim rs, rows
Set rs = Server.CreateObject("ADODB.Recordset")
rs.Open sQuery, connStr
rows = rs.GetRows()
' Store the data in the cache
Cache("db_rows") = rows
Cache.SetExpiration "db_rows", 60000
' Display a message that the cache was refreshed
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.
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
' Global connection variable
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
' Parameters:
' sDDLName Name of the <select> control
' sQuery SQL query, first column is the
' option value, 2nd col is option display
'
' Caches and returns an HTML string that displays
' a drop-down list box filled with DB data.
'===============================================
Function GetCachedDBDropDown (sDDLName, sQuery)
' Read from the cache first
Dim sKey
sKey = sDDLName & "DropDown"
GetCachedDBDropDown = Cache(sKey)
If Not IsEmpty(GetCachedDBDropDown) Then
' Found in cache, return it
Exit Function
End If
' Not cached, so build it
Dim s, rst
s = "<select name=""" & sDDLName & """>"
OpenConn
Set rst = cnn.Execute(sQuery)
' Use the ADO recordset to populate the dropdown list.
Do Until rst.EOF
s = s & "<option value=""" & rst(0) & """ >"
s = s & rst(1) & "</option>"
rst.MoveNext
Loop
s = s & "</select>"
' Store the cached output string
' Expire in 1hr
Cache.Add sKey, s, 3600000
GetCachedDBDropDown = s
End Function
'===============================================
' Sub DisplaySelectedDBDropDown
' Parameters:
' sDDLName Name of the <select> control
' sQuery SQL query, first column is the
' option value, 2nd col is option display
'
' Displays a drop-down list box filled with DB data.
' Current item selected is preserved across requests.
' GetCachedDBDropDown is called, so the main contents
' of the dropdown are held in the Cache.
'===============================================
Sub DisplaySelectedDBDropDown (sDDLName, sQuery)
' Grab the cached dropdown list string
Dim s
s = GetCachedDBDropDown(sDDLName, sQuery)
' Determine which ID is selected
Dim selID
selID = Request(sDDLName)
If IsEmpty(selID) Then
' Nothing selected, display the standard DDL
Response.Write s
Exit Sub
End If
' Insert the 'selected' tag
' This is necessary so the user doesn't have to
' start over each time.
Dim sSearch, i, splitPoint
sSearch = "<option value=""" & selID & """"
i = InStr(s, sSearch)
If i = 0 Then
' No matching option found
Response.Write s
Exit Sub
End If
splitPoint = i + Len(sSearch)
Response.Write Left(s, splitPoint - 1) & " selected" & Mid(s, splitPoint)
End Sub
'======================
' Page code starts here
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">
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:
' Display the page fragment for the specified product
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.
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.
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.
| Download the code for this article |
Requirements:
|