Chapter 5. Drupal Coding for Abysmal Performance

Having looked at some Drupal best practices for performance, we’ll now take a look at some worst practices and antipatterns. No one intentionally writes abysmally performing code, but as with any system, misuse of Drupal APIs can lead to unexpected and hard-to-predict performance issues that are often hidden in the implementation details of a specific API.

It’s not possible to provide an exhaustive list of antipatterns in Drupal that are bad for performance, but one API in particular gets abused very frequently and can cause quite serious and hard-to-track-down performance issues on high-traffic sites with particular configurations.

variable_set() Abuse

The variables system prior to Drupal 8 is one of the simplest, most central, and most commonly used APIs, to the point where it’s very rare to find a module that doesn’t use it. It’s also one of the most frequently abused APIs, including in Drupal core, so it provides a good example of how a simple API can come to be misused over time as use cases change. As you’ll see, this API no longer exists in Drupal 8.

We’ll start with a brief explanation of the API and its implementation in Drupal core.

Variables are stored in a single table with a name and a serialized value. Early in each page request, every variable is loaded into the $conf global from the cache (or the cache item is rebuilt if it’s missing). Individual variables are accessed, set, or deleted via the variable_get(), variable_set(), and variable_del() functions. system_settings_form() provides a degree of automation between administrative configuration pages and the variables they control.

Over the years, as well as storing configuration, the variables system has been used for storing other things as well:

  • Cache items that are updated infrequently and expensive to rebuild
  • Site “state,” or metadata tracking the status of a specific site, which can’t be recreated in the same way a cache entry should be (so can’t be deleted when all caches are flushed), but is set dynamically by code rather than triggered by configuration UI changes

Prior to Drupal 8, core did not provide a dedicated key/value store, so the variables system was reused for this as the next closest thing.

Examples of where variables are used to store state in Drupal core include for tracking whether outgoing HTTP requests are failing, the time of the last cron run, and the filenames and file collections for generated CSS and JavaScript aggregates.

When a variable is set, three things can happen:

  1. The database row is updated. Updating the database invalidates the MySQL query cache and may cause issues with locking on high-traffic tables, but is usually a fast operation with a well-indexed query affecting just one row.
  2. The cache is cleared. Clearing a cache itself is very cheap.
  3. The next request to the site discovers that there’s no cache entry for the variable table, so it loads every variable (sometimes 2,000–3,000 of them on complex sites) from the variables table, unserializes them, then writes the cache entry. This could take anything from a few milliseconds to a couple of hundred milliseconds depending on the number of variables, site load, etc. No Drupal request can be served without all variables being loaded, so if a previous request has cleared the variable cache, the next request will try to rebuild it. If multiple requests hit the site before the cache item has been set, one of the following occurs:

    • (Drupal 6) All requests that get a cache miss will each rebuild the variables cache entry and write it back to the cache table.
    • (Drupal 7 and Drupal 6 Pressflow) The first request acquires a lock, rebuilds and writes back the cache entry; other requests wait for the lock to be released, then either build it if there’s still no cache entry, or read from the cache and continue.

If variable_set() ends up being called very frequently, this can result in either a cache stampede, where dozens of requests are constantly updating the cache entry, or a lock stampede, where processes are constantly waiting for a new variable cache entry that may be invalidated again before they’re able to retrieve it from the cache.

What makes this problem worse is that it can be relatively hard to track down the cause, for a few reasons:

  • When profiling a page, variable_set() won’t show up as one of the most expensive operations—the set itself is fairly quick, so it could easily be skipped over.
  • While generating the variables cache entry is quite expensive, there’s no way to tell from these requirements why the variables cache was empty. It’s not possible to know which variable was cleared without reviewing code or debugging, so even profiling a page where the variables cache was previously invalidated doesn’t give an indication of whether this was due to state tracking or via a configuration form submission.

If you find a module doing this in Drupal 7, consider checking that the value of the variable has changed before saving it, converting the code to use the cache system if that’s feasible, saving the value in a dedicated table if it’s not requested frequently, or, like drupal_http_request(), just logging the change via watchdog().

In Drupal 8, the variables system has been completely removed and replaced with three distinct APIs:

Settings
Low-level configuration required before storage is available to the system as a whole; settings can only be changed in settings.php.
Configuration
The majority of module configuration is stored as YAML, providing the ability to stage configuration changes between sites.
State and key/value store
A new key/value store interface and implementation have been added, and a “state” service is made available by default using this API. All the things that previously would have been abusing the variables system to store state can now use the state service. Since state is separated from overall site configuration, updating values in the state system won’t invalidate the entire configuration cache.

External Requests

Increasingly, site development includes interacting with external APIs, whether for social sharing, login, content sharing, or similar purposes. When making HTTP requests from PHP code, it’s easy to introduce a single point of failure in your architecture, and in the case of requests to an external API, it’s one that you will have no control over if the service happens to be sluggish or go down due to network or infrastructure issues.

Examples of where this can go wrong include features such as allowing users to specify an RSS feed in their profile pages and then displaying it in a block, or using the PHP APIs for services such as Facebook and Twitter. If the request to the external service takes several seconds, times out, or returns a bad status code, will your page still be served in a timely manner, or at all?

External HTTP requests can also be a hidden cause of performance issues on a site, since they may be intermittent and will not cause high CPU or disk load in the same way poorly optimized PHP algorithms or slow database queries can.

There are several steps that can be taken to reduce the potential for catastrophic failure. We’ll use a user-specific RSS feed displayed in a block on the user’s profile page as an example:

  1. Add caching. A call to an external API can easily take 200–300 ms. If that call is a simple GET request for a list of posts or the like, it will be very straightforward to cache the results using Drupal’s caching API. If the site has empty caches, this won’t prevent downtime, so just adding caching won’t be sufficient, but it will mean that sites with a warm cache entry won’t depend on the service being available.
  2. Add a timeout. If an API can’t respond within a second or two, can you afford to wait any longer before serving the page? If not, adding a timeout to the HTTP request ensures that your code will continue.
  3. Move the HTTP request out of the critical path. It may be possible to move the HTTP request to hook_cron() (for example, precaching the RSS feeds for each user once per hour instead of checking each time the block is viewed) or a queue. Alternatively, the block could be moved to an AJAX callback, with JavaScript on the parent page requesting the block and inserting it into the DOM (known as a client-side include). You can also use both methods: if the HTTP request fails, cron might take a bit longer, or the AJAX request might not return, but the main request itself will still be able to complete and be served.

Sessions

Since Drupal 7, sessions are only created for unauthenticated users once something is actually put into $_SESSION. Once a user has a session, page caching is disabled to avoid serving incorrect content based on the contents of the user’s session, such as the contents of a shopping cart, or status messages. This happens when using both the internal page cache and a reverse proxy, if using a standard configuration.

$_SESSION should therefore only be used for situations where storing the information for the anonymous user should also prevent pages being cached for that user. An example would be a user’s shopping cart choices, where the shopping cart contents may be displayed on each page, and the site may want to preserve the choices in the session when the user logs in. While adding items to a shopping cart is a valid reason to disable caching, attention should be paid if a user removes an item from the cart. A common mistake is to set $_SESSION['foo'] ='' or $_SESSION['foo'] = array() when emptying out a session variable; using anything other than unset($_SESSION['foo']) will leave the session open.

Excessive Cache Granularity

When you’re working on a project, requests may come in to make particular content user specific (per user, geolocated), or to vary things on every page—for example, excluding the article in the main content area from a list of featured articles in the sidebar. Often these requests are unavoidable, but the details of how this extra information is added to or removed from pages can have profound effects on a site’s performance.

Let’s take for example a snippet that shows the latest three articles added to a site. A simplified version of the block on a Drupal 7 site might look like:

<?php
  $nids = db_query_range('
  SELECT nid
  FROM {node}
  WHERE status = 1
  ORDER BY created DESC', 0, 3)->fetchCol();
  $nodes = node_load_multiple($nids);
  return node_view_multiple($nodes, 'block_listing');
?>

However, when you’re on the node/n page for one of the nodes in this block, you might want to exclude it from the list and show the fourth-most-recent node instead:

<?php
  $current_node = menu_get_object();
  $nids = db_query_range('
  SELECT nid
  FROM {node}
  WHERE status = 1
  AND nid <> :nid
  ORDER BY created DESC', 0, 3, array(:nid => $current_node->nid))->fetchCol();
  $nodes = node_load_multiple($nids);
  return node_view_multiple($nodes, 'block_listing');
?>

Only one condition was added, but this fundamentally changes the performance of this block. With the first version, despite potentially being displayed on tens of thousands of node pages across the site, the query was always exactly the same. This allowed it to be cached in a single cache entry, both by the MySQL query cache and in Drupal’s cache API if desired. With the second version of the query, however, a different version will be run on every node page. A million node pages might mean a million variations on the query, vastly reducing the effectiveness of both the MySQL query cache and Drupal’s own cache layer.

Rather than excluding the current node ID in SQL, we could do it in PHP:

<?php
  $current_node = menu_get_object();
  // This time load four nodes.
  $nids = db_query_range('
  SELECT nid
  FROM {node}
  WHERE status = 1
  ORDER BY created DESC', 0, 4)->fetchCol();

  foreach ($nids as $key => $nid) {
    // If one of the nodes is the current one, remove it.
    if ($nid == $current_node->nid) {
      unset($nids[$key]);
    }
  }
  // Ensure only three nodes are loaded.
  array_splice($nids, 3);
  $nodes = node_load_multiple($nids);
  return node_view_multiple($nodes, 'block_listing');
?>

This allows the same query to be cached across the whole site again, but still the markup will need to be cached per page. A further optimization in terms of cacheability would be to render four nodes every time, then implement the same logic for removing either the current node or the fourth-most-recent node in JavaScript, allowing the block to be safely cached in a reverse proxy via edge-side or client-side includes. At this point, it’s a trade-off between caching the rendered output and the additional JavaScript requirement.

A similar issue exists with per-user markup. Drupal traditionally adds per-user markup to many site elements: for example, “new” or “updated” markers on nodes and comments based on the last time the user visited a node, or quick administration links such as contextual links. Drupal 8 will continue to add user-specific markup in these places, but it does so via JavaScript replacement so that the markup generated for the blocks themselves is the same regardless of the user. For examples of this implementation, see the Comment and History modules in Drupal 8.

PHP Errors

While Drupal core complies with E_STRICT by default and has fairly comprehensive test coverage, it is common when reviewing a live site to find PHP notices and warnings logged on every request (in some cases, tens per request). When using Drupal’s default database logging (dblog) module for logging, this means potentially hundreds of additional database writes per second on a high-traffic site. Even if using syslog or another alternative log implementation, PHP error handling is expensive and should be avoided. PHP errors and notices may well be a sign of underlying functional bugs, but even if they’re not, there’s no excuse for writing and deploying code that’s not E_ALL compliant.

Debug Code in the Code Base

Code review as part of the development process should prevent this, but “emergency” fixes can result in debug code being committed to the live code base. This could include things like clearing the entity cache before loading entities, disabling the theme registry or locale caching, excessive logging, and various other operations that are both expensive and unnecessary on a production site.

Development Settings

While not strictly a “coding” matter, a common issue on live websites is leaving one of the many debug features provided by modules and themes enabled, such as rebuilding the theme registry on every request. Settings like this should never, ever be enabled in the user interface, but rather overwritten by settings.local.php in $conf, which can be different for development, staging, and live environments (see Chapter 9). Similarly, development modules should be enabled locally by developers after each database refresh and never enabled on a live environment. While this may be standard practice for some readers, others may recognize the frustration of discovering that a downtime issue was due to a forgotten development setting that was never switched off.

Get High Performance Drupal now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.