Advanced PHP Programming
.pdf
10
Data Component Caching
WRITING DYNAMIC WEB PAGES IS A BALANCING act. On the one hand, highly
dynamic and personalized pages are cool. On the other hand, every dynamic call adds to the time it takes for a page to be rendered.Text processing and intense data manipulations take precious processing power. Database and remote procedure call (RPC) queries incur not only the processing time on the remote server, but network latency for the data transfer.The more dynamic the content, the more resources it takes to generate. Database queries are often the slowest portion of an online application, and multiple database queries per page are common, especially in highly dynamic sites. Eliminating these expensive database calls tremendously boost performance. Caching can provide the answer.
Caching is the storage of data for later usage.You cache commonly used data so that you can access it faster than you could otherwise. Caching examples abound both within and outside computer and software engineering.
A simple example of a cache is the system used for accessing phone numbers.The phone company periodically sends out phone books.These books are large, ordered volumes in which you can find any number, but they take a long time to flip through (They provide large storage but have high access time.) To provide faster access to commonly used numbers, I keep a list on my refrigerator of the numbers for friends, family, and pizza places.This list is very small and thus requires very little time to access. (It provides small storage but has low access time.)
Caching Issues
Any caching system you implement must exhibit certain features in order to operate correctly:
nCache size maintenance—As my refrigerator phone list grows, it threatens to outgrow the sheet of paper I wrote it on. Although I can add more sheets of
240 Chapter 10 Data Component Caching
paper, my fridge is only so big, and the more sheets I need to scan to find the number I am looking for, the slower cache access becomes in general.This means that as I add new numbers to my list, I must also cull out others that are not as important.There are a number of possible algorithms for this.
nCache concurrency—My wife and I should be able to access the refrigerator phone list at the same time—not only for reading but for writing as well. For example, if I am reading a number while my wife is updating it, what I get will likely be a jumble of the new number and the original. Although concurrent write
access may be a stretch for a phone list, anyone who has worked as part of a group on a single set of files knows that it is easy to get merge conflicts and overwrite other people’s data. It’s important to protect against corruption.
nCache invalidation—As new phone books come out, my list should stay up-to- date. Most importantly, I need to ensure that the numbers on my list are never
incorrect. Out-of-date data in the cache is referred to as stale, and invalidating data is called poisoning the cache.
nCache coherency—In addition to my list in the kitchen, I have a phone list in my office. Although my kitchen list and my office list may have different contents, they should not have any contradictory contents; that is, if someone’s number appears on both lists, it should be the same on both.
There are some additional features that are present in some caches:
nHierarchical caching—Hierarchical caching means having multiple layers of caching. In the phone list example, a phone with speed-dial would add an additional layer of caching. Using speed-dial is even faster than going to the list, but it holds fewer numbers than the list.
nCache pre-fetching—If I know that I will be accessing certain numbers frequently (for example, my parents’ home number or the number of the pizza place down on the corner), I might add these to my list proactively.
Dynamic Web pages are hard to effectively cache in their entirety—at least on the client side. Much of Chapter 9,“External Performance Tunings,” looks at how to control client-side and network-level caches.To solve this problem, you don’t try to render the entire page cacheable, but instead you cache as much of the dynamic data as possible within your own application.
There are three degrees to which you can cache objects in this context:
n Caching entire rendered pages or page components, as in these examples:
nTemporarily storing the output of a generated page whose contents seldom change
nCaching a database-driven navigation bar
Choosing the Right Strategy: Hand-Made or Prefab Classes |
241 |
n Caching data between user requests, as in these examples:
nArbitrary session data (such as shopping carts)
nUser profile information
n Caching computed data, as in these examples:
nA database query cache
nCaching RPC data requests
Recognizing Cacheable Data Components
The first trick in adding caching to an application is to determine which data is cacheable.When analyzing an application, I start with the following list, which roughly moves from easiest to cache to most difficult to cache:
nWhat pages are completely static? If a page is dynamic but depends entirely on static data, it is functionally static.
nWhat pages are static for a decent period of time? “A decent period” is intentionally vague and is highly dependent on the frequency of page accesses. For almost any site, days or hours fits.The front page of www.cnn.com updates every few minutes (and minute-by-minute during a crisis). Relative to the site’s traffic, this qualifies as “a decent period.”
n What data is completely static (for example, reference tables)?
nWhat data is static for a decent period of time? In many sites, a user’s personal data will likely be static across his or her visit.
The key to successful caching is cache locality. Cache locality is the ratio of cache read hits to cache read attempts.With a good degree of cache locality, you usually find objects that you are looking for in the cache, which reduces the cost of the access.With poor cache locality, you often look for a cached object but fail to find it, which means you have no performance improvement and in fact have a performance decrease.
Choosing the Right Strategy: Hand-Made or Prefab Classes
So far in this book we have tried to take advantage of preexisting implementations in PEAR whenever possible. I have never been a big fan of reinventing the wheel, and in general, a class that is resident in PEAR can be assumed to have had more edge cases found and addressed than anything you might write from scratch. PEAR has classes that provide caching functionality (Cache and Cache_Lite), but I almost always opt to build my own.Why? For three main reasons:
242 Chapter 10 Data Component Caching
nCustomizability—The key to an optimal cache implementation is to ensure that it exploits all the cacheable facets of the application it resides in. It is impossible to do this with a black-box solution and difficult with a prepackaged solution.
nEfficiency—Caching code should add minimal additional overhead to a system. By implementing something from scratch, you can ensure that it performs only the operations you need.
nMaintainability—Bugs in a cache implementation can cause unpredictable and unintuitive errors. For example, a bug in a database query cache might cause a query to return corrupted results.The better you understand the internals of a caching system, the easier it is to debug any problems that occur in it.While debugging is certainly possible with one of the PEAR libraries, I find it infinitely easier to debug code I wrote myself.
Intelligent Black-Box Solutions
There are a number of smart caching “appliances” on the market, by vendors such as Network Appliance, IBM, and Cisco. While these appliances keep getting smarter and smarter, I remain quite skeptical about their ability to replace the intimate knowledge of my application that I have and they don’t. These types of appliances do, however, fit well as a commercial replacement for reverse-proxy caches, as discussed in Chapter 9.
Output Buffering
Since version 4, PHP has supported output buffering. Output buffering allows you to have all output from a script stored in a buffer instead of having it immediately transmitted to the client. Chapter 9 looks at ways that output buffering can be used to improve network performance (such as by breaking data transmission into fewer packets and implementing content compression).This chapter describes how to use similar techniques to capture content for server-side caching.
If you wanted to capture the output of a script before output buffering, you would have to write this to a string and then echo that when the string is complete:
<?php
$output = “<HTML><BODY>”;
$output .= “Today is “.strftime(“%A, %B %e %Y”); $output .= “</BODY></HTML>”;
echo $output; cache($output);
?>
If you are old enough to have learned Web programming with Perl-based CGI scripts, this likely sends a shiver of painful remembrance down your spine! If you’re not that old, you can just imagine an era when Web scripts looked like this.
Output Buffering |
243 |
With output buffering, the script looks normal again. All you do is add this before you start actually generating the page:
<?php ob_start(); ?>
This turns on output buffering support. All output henceforth is stored in an internal buffer.Then you add the page code exactly as you would in a regular script:
<HTML>
<BODY>
Today is <?= strftime(“%A, %B %e %Y”) ?>
</BODY>
</HTML>
After all the content is generated, you grab the content and flush it:
<?php
$output = ob_get_contents(); ob_end_flush(); cache($output);
?>
ob_get_contents() returns the current contents of the output buffer as a string.You can then do whatever you want with it. ob_end_flush() stops buffering and sends the current contents of the buffer to the client. If you wanted to just grab the contents into a string and not send them to the browser, you could call ob_end_clean() to end buffering and destroy the contents of the buffer. It is important to note that both ob_end_flush() and ob_end_clean() destroy the buffer when they are done. In order to capture the buffer’s contents for later use, you need to make sure to call ob_get_contents() before you end buffering.
Output buffering is good.
Using Output Buffering with header() and setcookie()
A number of the online examples for output buffering use as an example of sending headers after page text. Normally if you do this:
<?php
echo “Hello World”; header(“Content-Type: text/plain”);
?>
You get this error:
Cannot add header information - headers already sent
In an HTTP response, all the headers must be sent at the beginning of the response, before any content (hence the name headers). Because PHP by default sends out content as it comes in, when you send headers after page text, you get an error. With output buffering, though, the transmission of the body of the response awaits a call to flush(), and the headers are sent synchronously. Thus the following works fine:
244 Chapter 10 Data Component Caching
<?php
ob_start();
echo “Hello World”; header(“Content-Type: text/plain”);
ob_end_flush();
?>
I see this as less an example of the usefulness of output buffering than as an illustration of how some sloppy coding practices. Sending headers after content is generated is a bad design choice because it forces all code that employs it to always use output buffering. Needlessly forcing design constraints like these on code is a bad choice.
In-Memory Caching
Having resources shared between threads or across process invocations will probably seem natural to programmers coming from Java or mod_perl. In PHP, all user data structures are destroyed at request shutdown.This means that with the exception of resources (such as persistent database connections), any objects you create will not be available in subsequent requests.
Although in many ways this lack of cross-request persistence is lamentable, it has the effect of making PHP an incredibly sand-boxed language, in the sense that nothing done in one request can affect the interpreter’s behavior in a subsequent request (I play in my sandbox, you play in yours.) One of the downsides of the persistent state in something like mod_perl is that it is possible to irrevocably trash your interpreter for future requests or to have improperly initialized variables take unexpected values. In PHP, this type of problem is close to impossible. User scripts always enter a pristine interpreter.
Flat-File Caches
A flat-file cache uses a flat, or unstructured, file to store user data. Data is written to the file by the caching process, and then the file (usually the entire file) is sourced when the cache is requested. A simple example is a strategy for caching the news items on a page. To start off, you can structure such a page by using includes to separate page components.
File-based caches are particularly useful in applications that simply use include() on the cache file or otherwise directly use it as a file. Although it is certainly possible to store individual variables or objects in a file-based cache, that is not where this technique excels.
Cache Size Maintenance
With a single file per cache item, you risk not only consuming a large amount of disk space but creating a large number of files. Many filesystems (including ext2 and ext3 in
In-Memory Caching |
245 |
Linux) perform very poorly when a large number of files accumulate in a directory. If a file-based cache is going to be large, you should look at creating a multitiered caching structure to keep the number of files in a single directory manageable.This technique is often utilized by mail servers for managing large spools, and it is easily adapted to many caching situations.
Don’t let preconceptions that a cache must be small constrain your design choices. Although small caches in general are faster to access than large caches, as long as the cached version (including maintenance overhead) is faster than the uncached version; it is worth consideration. Later on in this chapter we will look at an example in which a multigigabyte file-based cache can make sense and provide significant performance gains. Without interprocess communication, it is difficult to implement a least recently used (LRU) cache removal policy (because we don’t have statistics on the rate at which the files are being accessed). Choices for removal policies include the following:
nLRU—You can use the access time (atime, in the structure returned by stat()) to find and remove the least recently used cache files. Systems administrators often disable access time updates to reduce the number of disk writes in a read-intensive application (and thus improve disk performance). If this is the case, an LRU that is based on file atime will not work. Further, reading through the cache directory structure and calling stat() on all the files is increasingly slow as the number of cache files and cache usage increases.
nFirst in, first out (FIFO)—To implement a FIFO caching policy, you can use the modification time (mtime in the stat() array), to order files based on the time they were last updated.This also suffers from the same slowness issues in regards to stat() as the LRU policy.
nAd hoc—Although it might seem overly simplistic, in many cases simply remov-
ing the entire cache, or entire portions of the cache, can be an easy and effective way of handling cache maintenance.This is especially true in large caches where maintenance occurs infrequently and a search of the entire cache would be extremely expensive.This is probably the most common method of cache removal.
In general, when implementing caches, you usually have specialized information about your data that you can exploit to more effectively manage the data.This unfortunately means that there is no one true way of best managing caches.
Cache Concurrency and Coherency
While files can be read by multiple processes simultaneously without any risk, writing to files while they are being read is extremely dangerous.To understand what the dangers are and how to avoid them, you need to understand how filesystems work.
A filesystem is a tree that consists of branch nodes (directories) and leaf nodes (files). When you open a file by using fopen(“/path/to/file.php”, $mode), the operating system searches for the path you pass to it. It starts in the root directory, opening the
246 Chapter 10 Data Component Caching
directory and inspecting the contents. A directory is a table that consists of a list of names of files and directories, as well as inodes associated with each.The inode associated with the filename directly corresponds to the physical disk location of the file.This is an important nuance:The filename does not directly translate to the location; the filename is mapped to an inode that in turn corresponds to the storage.When you open a file, you are returned a file pointer.The operating system associates this structure with the file’s inode so that it knows where to find the file on disk. Again, note the nuance:The file pointer returned to you by fopen() has information about the file inode you are opening—not the filename.
If you only read and write to the file, a cache that ignores this nuance will behave as you expect—as a single buffer for that file.This is dangerous because if you write to a file while simultaneously reading from it (say, in a different process), it is possible to read in data that is partially the old file content and partially the new content that was just written. As you can imagine, this causes the data that you read in to be inconsistent and corrupt.
Here is an example of what you would like to do to cache an entire page:
<? if(file_exists(“first.cache”)) {
include(“first.cache”); return;
}
else {
// open file with ‘w’ mode, truncating it for writing $cachefp = fopen(“first.cache”, “w”);
ob_start();
}
?>
<HTML>
<BODY>
<!-- Cacheable for a day -->
Today is <?= strftime(“%A, %B %e %Y”) ?> </BODY>
</HTML>
<?
if( $cachefp) {
$file = ob_get_contents(); fwrite($cachefp, $file); ob_end_flush();
}
?>
The problem with this is illustrated in Figure 10.1.You can see that by reading and writing simultaneously in different processes, you risk reading corrupted data.
In-Memory Caching |
247 |
Process A |
Process B |
check if file exists
begin writing |
File creation time |
|
check if file exists
begin reading
end reading
end writing |
File is consistent |
Figure 10.1 A race condition in unprotected cache accesses.
You have two ways to solve this problem:You can use file locks or file swaps.
Using file locks is a simple but powerful way to control access to files. File locks are either mandatory or advisory. Mandatory file locks are actually enforced in the operating system kernel and prevent read() and write() calls to the locked file from occurring. Mandatory locks aren’t defined in the POSIX standards, nor are they part of the standard BSD file-locking semantics; their implementation varies among the systems that support them. Mandatory locks are also rarely, if ever, necessary. Because you are implementing all the processes that will interact with the cache files, you can ensure that they all behave politely.
Advisory locks tend to come in two flavors:
n flock—flock dates from BSD version 4.2, and it provides shared (read) and exclusive (write) locks on entire files
n fcntl—fcntl is part of the POSIX standard, and it provides shared and exclusive locks on sections of file (that is, you can lock particular byte ranges, which is particularly helpful for managing database files or another application where you might want multiple processes to concurrently modify multiple parts of a file).
