|
The Hitchhiker's Guide to PHP
Scaling Oracle and PHP
by George Schlossnagle
Learn generic techniques and designs for writing manageable, scalable, and fast PHP code that directly relate to using Oracle Database.
Over the past nine years, PHP has evolved from being a niche language for putting together personal Web sites to powering some of the world's largest and most highly trafficked sites. The three most important design aspects of any high-traffic Web site are scalability, performance, and maintainability. Scalability means that your application's traffic loads can grow without fundamentally breaking the way it works. Performance is the ability to serve individual requests quickly. Maintainability is the quality of being able to fix, refactor, augment, or otherwise change your application without too much trouble.
Tackling these three design goals with PHP is not difficult, but it does require forethought into how your application is structured and built. The topic of writing manageable, scalable, and fast PHP code is a broad one; there are numerous techniques and articles for addressing each topic individually. In this article, we will discuss those factors that directly relate to using Oracle with PHP. There are a number of generic (non-Oracle-specific) techniques and designs that can be very beneficial.
I like to start any performance-related talk or article with a pointed warning: Always remember that at the end of the day, a fast but incomplete application is worthless. It is easy to distract yourself with performance tuning and design abstraction to the detriment of your application. The nature of the Web is such that a release-frequently methodology works very well. (There is little cost in releasing a "new version" of a Web site, since the end user is always coming back for the code.) This allows you to defer serious tuning of your code until it is required. Thus, the primary goal should be to create code that can be easily refactored.
Creating and Managing Connections
One of the most fundamental interactions with an Oracle database is connecting. To understand how connections impact your application's performance and scalability, you need to understand the connection lifecycle, shown in Figure 1.The work involved in each step is as follows:
- Client Creates Connection: The client makes a network connection to the Oracle listener, provides its authentication credentials, and requests a session.
- Server Creates a New Session: After authentication, the server creates a new session for the client. If you are not using shared sessions via Oracle Multi-Threaded Server (MTS- which has notorious scalability and performance issues), this involves the server creating a dedicated process for the session. This process is commonly referred to as a shadow process. Creating this process involves a non-trivial amount of work. In addition to the normal overhead of process creation, the shadow process must temporarily lock certain shared system resources during its creation.
- Client Issues Query: Now that the client has an open connection, it may issue queries, as needed.
- Client Closes Connection: When the client has completed its work, it closes the connection to the server.
- Server Destroys Session: The shadow process associated with the user's session is destroyed, and any uncommitted transactions are rolled back.
|
Figure 1: Connection Lifecycle
Because the cost of creating a new shadow process is relatively large, we should try to avoid it when necessary. The easiest way to accomplish this is by using persistent connections. PHP is a stateless language by design. This means that by default any information created (or resource instantiated) during a request will be cleaned up and destroyed at the end of the request. For Oracle client connections, we want to avoid this behavior.
To enable connections that persist from one request to the next, you can use one of the two following connect variants:
OCIPLogin($username, $password [, $tnsname])
or
OCINLogin($username, $password [, $tnsname])
Both of these functions create persistent server connections, although OCINLogin() will create a new session handle for every request. If your application uses transactions and you want to separate simultaneous transactions into multiple sessions, use OCINLogin().
A side effect of using persistent connections is that you are more prone to process-starvation. When running a single Apache Web server (with a default maximum of 256 child processes) against a dedicated Oracle database, you are likely to never run into any problems. If you grow that to 4 Web servers, though, each running 256 children with persistent Oracle connections, you will now be making 1,024 connections to your Oracle database and soon will be running up against the Oracle instance's resource limits.
In your Oracle instance's configuration file (init.ora), there are two tunable parameters:
sessions = NNNN and processes = NNNN.
These control the maximum number of sessions and processes that the instance will support. If you need to support 1,024 simultaneous connections, sessions will need to be at least 1,024 (since OCINLogin() connects and certain recursive queries can require multiple sessions per connection), and processes will need to be higher still (since we need to account for the Oracle background processes as well). Unfortunately, setting these processes arbitrarily high is not an option. Oracle processes consume a non-trivial amount of private memory (2 to 3MB per process on most systems). Individually these are small, but when taken as a group and combined with the shared memory required for the server system global area (SGA), this will quickly cause you to bump your head against the database server's physical memory limits.
Users coming from a MySQL environment might be tempted to eschew persistent connections altogether (as is recommended for MySQL environments). The latch and file contention incurred by an Oracle shadow process startup makes using non-persistent connections extremely inefficient. So what are the solutions?
- Determine how many simultaneous sessions our database can sustain and set its limits accordingly. There are articles that detail how to do this, but it is mostly an arithmetic exercise: Take the total amount of physical memory in the system and subtract the memory used by the kernel, any support programs, and the Oracle background processes. Next, subtract all the memory configured as shared memory for Oracle (shared pool and buffer cache). What we have left over can be used for shadow processes. Divide that by the average amount of private process memory used by one of your shadow processes (you should measure this yourself, since it changes depending on the nature of the queries you run), and we have the number of processes sustainable.
- Configure our Web servers so that they can never make more connections than your process settings allow. This is achieved by setting each Web server's MaxChildren tunable low enough so that all the Web servers together will have fewer children than sustainable Oracle connections. This means no longer supporting 256 children per Apache instance.
- Restructure our application so that it won't miss the extra children. We'll come back to that later in the article.
How important is this? At one employer's, we experienced consistent latching issues even though we had persistent connections enabled. As anyone who has experienced serious latch contention can attest, it is a crippling problem where the server becomes largely unresponsive as it spends an excessive amount of time doing locking operations. During our investigation, we found that, while a good number of processes served hundreds of requests before terminating, a large number of processes served only a single request. What had happened was that we had set the Apache setting MaxSpareServers quite low. Some issues with the load-balancing equipment we were using caused "bursty" behavior, where a Web server was hit with a number of simultaneous requests and then left idle for a few seconds. Inside Apache, this caused additional children to be created to serve the high request level; but as soon as it subsided (almost instantly), most of the now-idle children were reaped (terminating the process and closing its Oracle connections). Globally this had a similar effect to running non-persistent processes. Setting MaxSpareServers higher eliminated this problem and eliminated the latch contention.
Executing SQL
The meat of any Oracle client-server relationship is executing queries. There isn't space here to talk about tuning queriesthat is a topic that consumes entire books. Instead, we'll focus on how to make your already-tuned queries run as efficiently as possible.
The first step in writing good Oracle application code in PHP is to always use bind SQL. When we write a query like this:
SELECT * FROM USERS WHERE USERNAME = 'george'
Oracle must soft-parse the query to see if it has compiled it before. By default, the value 'george' is taken as a literal, meaning that if we execute this query with a different name ('bob'), Oracle will treat it as an entirely separate query. Oracle keeps a parsed copy of every query it executes in its shared pool, so if you execute this with thousands of names, there will be thousands of distinct copies of this query in your shared pool. On even a mildly active site, this will cause severe memory fragmentation and a proliferation of ORA-4031 errors.
The solution to this is to use bind SQL. Bind SQL allows us to replace the literal values in the WHERE clause with placeholders, as follows:
SELECT * FROM USERS WHERE USERNAME = :NAME
Here the query will have to be completely parsed (hard-parsed) only once; all subsequent parses will be so-called soft-parses, where the engine simply fetches the compiled query from the SGA. Also, only a single-parsed copy will be stored, drastically reducing the memory requirements for frequently executed queries.
Now we can execute this as follows:
<?php
$db = OCIPLogin('scott', 'tiger', 'testdb');
$stmt = OCIParse($db, 'SELECT * FROM USERS WHERE USERNAME = :NAME');
$name = 'george';
OCIBindByName($stmt, ':NAME', $name, -1);
OCIExecute($stmt);
?>
In the pre-Oracle8i days, we had to manually bind queries; starting in 8i we can instruct the optimizer to do it for us by setting the init.ora parameter 'cursor_sharing = FORCE'. This setting tells the optimizer to look for literal values that could be bound and to perform the binding manually. As of 9i, we can use the setting 'cursor_sharing = SIMILAR', which instructs the optimizer to peek into the underlying table statistics to see if autobinding the literals is beneficial (it may not be, in the case where a field is highly skewed). While these settings should be enabled (FORCE in 8i, SIMILAR in 9i and above), the act of peeking into the query to analyze potential bindings is expensive for the optimizer, so you should manually bind your queries when possible.
SQLNet, the protocol that the Oracle client-server is carried over, is notoriously chatty. For example, if you execute a query that returns 100 rows, there will be a conversation exchange to parse the query, an exchange for each bind variable, a query to perform the execution, and a query for each row fetched. Each of these exchanges involves a network packet exchange (called a round-trip) between the client and the server. Reducing the number of round-trips can have profound performance impacts.
The first way to tackle this problem is to set up the client's pre-fetch buffer. Pulling a single row over the network is extremely inefficient, so you are much better off pulling them over aggressively and reading them out of a local buffer. The Oracle client library can perform this service automatically, if we set the following for our statement handles:
$stmt = OCIParse($db, $query);
OCISetPreFetch($stmt, 1000);
// execute and fetch
This instructs the OCI client library to internally buffer 1,000 rows at a time and will give you a positive return on any query that returns more than one row. 1,000 is a rather arbitrary numberthe only limiting factors in selecting the pre-fetch buffer size are:
- The buffer needs to be filled before the execute returnsif having immediate access to the partial result set is important, you will not want the buffer size to be too large.
- The buffered result set is held in local memory until it is refreshedif you have very large rows or very large result sets, you should not make the buffer size too large, to prevent the client from having a memory shortage.
Managing Your Interaction with Oracle
You have now seen a few techniques for improving the way you interact with your Oracle database through PHP. Making those sorts of structural changes to your programs is a management hassle, as it usually requires making nontrivial changes to a number of places in your code. The correct way to approach the problem is to encapsulate all your database access code in a wrapper library so that the internals of how queries are prepared and executed can be changed without having to audit your entire code base.
I say "wrapper library" instead of "abstraction layer" because many so-called abstraction layers make a point of hiding from you not only the minutiae of the low-level database calls but the SQL as well. They implement their own database-neutral syntax so that you can effortlessly change your application over to another database. I have three major problems with this philosophy:
- SQL is a powerful and descriptive language that many developers know. Hiding it from them and making them learn a new, less flexible language is silly.
- Every major database product has non-standard SQL syntaxes for accomplishing particular tasks. Forsaking these differences restricts your flexibility and removes some of the value of your platform of choice. By considering only the intersection of their supported features, you end up with the lowest common denominator.
- Switching database vendors is an uncommon occurrence for most people and will involve considerable data migration efforts, regardless of whether your application is database-agnostic or not.
The two most popular database wrapper/abstraction libraries for PHP are PEAR::DB (available from http://pear.php.net/) and ADODB (available from http://php.weblogs.com/adodb). Despite the popularity (and quality) of both of these implementations, I usually implement my own database wrapper library from scratch. I don't need many of the advanced features in ADODB or PEAR::DB, and keeping the library simple makes it a faster and more maintainable library. Without the complex features, a tight library can be achieved in about 100 lines of code. Given the amount of information that is carried around in the Oracle database and statement handles, I prefer an object-oriented wrapper, although I have seen procedural wrappers that work fine as well.
Listing 1 shows a complete wrapper library, consisting of a wrapper around database connections (DB_Oracle) and a wrapper around cursors (DB_OracleStatement).
The goal of these classes is to make managing your database interaction simple, straightforward, and clean. Here is how to use them to execute a simple query:
<?php
include_once("DB_Oracle.inc");
$dbh =& new DB_Oracle('scott', 'tiger', 'testdb');
$stmt =& $dbh->prepare("SELECT * FROM users WHERE name = :name");
$stmt->execute(array(':name' => 'george'));
$result = $stmt->fetch();
// ...
?>
Since the wrapper is object-oriented, you can trivially hide all of your connection parameters by extending the class. This is very useful for providing a simple, parameter-free connection class, as follows:
class DB_Oracle_Test extends DB_Oracle {
var $user = "scott";
var $pass = "tiger";
var $tnsname = "testdb";
function DB_Oracle_Test() {}
}
This new class hides all the connection criteria from the programmer, allowing the TNSNAME of the instance, or the connection criteria to be changed transparently. Also notice how the OCISetPreFetch() call is hidden from the user. If you need to remove that, or add another connection modifier, it is trivial to do so and have it affect all your connections. This is the value of using a wrapper library.
Making Your Processes More Efficient
At the beginning of the article, I noted that I would help you not miss the children you reduced in Apache to achieve your scalability requirements. The three simplest ways to accomplish your tasks with fewer resources are:
- Accomplish your tasks faster. Anyone can tell you that the fastest route to having your workload increased is to finish your existing work faster. Whether this is installing a compiler cache, profiling your code, or tuning your database queries, any performance gains you can extract from your application pay off in terms of scalability.
- Subcontract out certain tasks to specialists. For example, Web sites generally consist of dynamic components (what your PHP scripts are) and static components (images and non-dynamic HTML). Having your tightly constrained, Oracle-accessing Apache instance serving static content is a waste of its time. Outsource it to a Web server designed to handle that sort of load.
- Skip work entirely. Analysis of your application will often reveal database-driven components that do not need to be generated from the database on every request. Finding these and reengineering them to be as static as possible can be an enormous performance and scalability win.
Offloading static content. If the average page in your Web application contains nine images, then only ten percent of the requests to your Web server actually used the persistent connections they have assigned to them. In other words, ninety percent of the requests are wasting a valuable (and expensive, from a scalability standpoint) Oracle connection handle. Your goal should be to ensure that only requests that require Oracle connectivity (or at least require dynamic content) are served off of your dynamic Web server. This will increase the amount of Oracle-related work done by each process, which in turn reduces the number of children required to generate dynamic content.
The easiest way to promote this is by offloading all of your images onto a separate Web server (or set of Web servers). This is amazingly easy. The first step is to set up a secondary Web server to handle static requests. Although you can use Apache for this, there are other Web servers designed specifically to excel at serving static data (for example, tux and thttpd) that may return you even better results. You should set up this Web server to serve requests for an alternative sub-domain. A common practice is to delegate images traffic for "www.example.com" to "images.example.com." Some hardware load-balancers will actually allow you to serve images off the same domain and perform the dispatching internallycheck your load-balancers documentation for details.
Once your domain is set up, you should create a global configuration file that contains any global constants you use throughout your app, and add at least the following line:
define(CDN_URL, "http://images.example.com");
This file either can be included manually at the top of every file, or it can be run automatically at the beginning of every script by adding the following line to your php.ini configuration file:
auto_prepend_file = /path/to/config.inc
Now, whenever you create an image tag in your HTML, you should add the following code:
<img src="<?= CDN_URL ?>/path/to/image.png">
If you prefer to have a tag-writing library, you can write an image tag creation function as follows:
function img_tag($local_uri, $attr)
{
$attribute = '';
foreach ($attr as $k=>$v) {
$k = urlencode($k);
$v = urlencode($v);
$attribute .= " $k=\"$v\" ";
}
return "<img src=\"".CDN_URL."$local_uri\" $attribute>";
}
Even if you don't plan on using a dedicated network for images immediately, you should still code your application to use a separate base path for images and simply set it as follows:
define(CDN_URL, "http://www.example.com/images");
This allows you to cut over your entire site to an alternative image-serving network with a single line of code change at any time. Depending on the proportion of your site that is static content/images, you can see a significant to enormous reduction in server resources. At one client, moving static content off Apache and onto a dedicated set of thttpd boxes allowed them to reduce their overall infrastructure by 50 percent.
Why work when you don't have to? The ultimate performance enhancement to any query is simply not to run it at all. Many "dynamic" Web pages are not actually dynamic but are simply static for short periods of time. Consider a news site: the content does not change until the news items are updated. Whether this update happens every minute or every hour, between updates the site is static. This means that instead of doing a database lookup for current news items on every page request, you only need to do it once per update cycle.
The simplest type of cache is the full-page on-demand cache. In that, when a request comes in, the application checks the cache for the desired file. If the cached copy exists, it is returned to the requestor; otherwise a cached copy is created and returned to the user. To refresh the cache, the old cached copy is simply removedthe next request will automatically regenerate the cache.
Figure 2 shows the flow diagram we want to achieve: when a request comes in for /archive/123.html, the Web server should look to see if the file actually exists. If so, the file is returned. If not, the user will be redirected to a PHP page, generate.php, with the page identifier '123' passed as a parameter. That page will then generate the cache entry for the page. In an Apache environment, the two classic ways of performing this cache lookup are to use either a custom ErrorHandler or mod_rewrite. mod_rewrite provides you some greater flexibility, so I'll implement that.
Figure 2: Full-Page On-Demand Cache Flow Diagram
First, you need to set up the rewrite rules inside your httpd.conf. Here is a sample stanza:
RewriteEngine On
RewriteConf %{REQUEST_FILENAME} ^/archive/[0-9]+\.html
RewriteConf %{REQUEST_FILENAME} !-f
RewriteRule ^/archive/([0-9]+)\.html /generate.php?id=$1
This first enables the rewriting engine and then says that any requested filename that matches the archive pattern (^/archive/[0-9]+\.html) that does not exist (!-f) will be rewritten to go to the generation page with the page identifier passed as a parameter.
The generator page is similarly simple:
<?php
$id = $_GET['id'];
$dbh =& new DB_Oracle_TestDB;
$cursor =& $dbh->execute("SELECT content FROM news WHERE id = $id");
$result = $cursor->fetch();
if(!$result) {
header("HTTP/1.0 404 Not Found");
exit;
}
else {
echo $result['CONTENT'];
$outfile = $_SERVER['DOCUMENT_ROOT']."/archive/$id.html";
if(($fp = fopen($outfile, "w")) === false) {
exit;
}
fwrite($fp, $result['CONTENT']);
fclose($fp);
}
?>
In this example, the generator assumes that the news table's content column contains fully formed content. In reality, you would most likely perform some formatting in the script as well, capturing the output using output buffering.
Conclusion
What you've seen here is just a brief description of techniques for scaling Oracle and PHP. While the examples have hopefully been useful, the key lessons I hope you've taken from this article are:
- Closely manage your Oracle connections to avoid resource exhaustion.
- Speed brings you efficiency, which aids scalability.
- When possible, use caching techniques to avoid database queries completely.
George Schlossnagle is a principal at OmniTI Computer Consulting, a Maryland-based tech company specializing in high-volume Web and e-mail systems. Before joining OmniTI, Schlossnagle led technical operations at several high-profile community Web sites, where he developed experience managing PHP in very large enterprise environments. Schlossnagle is a frequent contributor to the PHP community. His work can be found in the PHP core as well as in the PEAR and PECL extension repositories.
|