High-Traffic WooCommerce Checkout Concurrency: Optimizing Redis Session Locks

Flash sales concentrate writes onto a handful of rows. Every add-to-cart and every update_order_review refresh persists session state, and checkout adds stock checks, stock reservation, and order inserts. The sessions table is keyed per customer, so unrelated shoppers rarely block each other there. Contention comes from elsewhere: concurrent AJAX calls from the same customer serializing on their own session row, and every buyer converging on the same hot rows, namely the sale SKU's stock row (the reservation and decrement paths take row locks) and shared wp_options entries such as transients and Action Scheduler bookkeeping. Under heavy checkout concurrency, these database operations can lead to InnoDB lock wait timeouts and, when transactions acquire conflicting locks, database deadlocks. Unlike a lock wait timeout, a detected database deadlock causes MySQL to roll back one of the conflicting transactions.

Each transaction waiting on a lock holds a PHP-FPM worker. InnoDB waits up to innodb_lock_wait_timeout (50 seconds by default), far longer than a checkout request should live. Once every worker up to pm.max_children is parked on a lock, new connections queue in the listen backlog, nginx fails the upstream, and the visitor gets a 502 Bad Gateway (a 504 if fastcgi_read_timeout fires first). Because the FPM pool is shared, the failure hits every page, not just checkout. These checkout failures can also contribute to cart abandonment, as customers encounter slow loading times, payment interruptions, or repeated gateway errors during high-traffic sales events.

Redis can take session and cache traffic off MySQL, but only if it is configured for what it will hold.

The Flawed Generic Fix: Default Redis Object Cache

The common advice is to install an object cache plugin, point it at a local Redis, and move on. Defaults are tuned for a quiet blog, not a flash sale, and the danger comes from putting two different kinds of data in one instance with one policy. In WordPress, a Redis object cache plugin typically installs the wp-content/object-cache.php drop-in, enabling persistent object caching across requests. However, this Redis object cache drop-in does not automatically replace WooCommerce's database-backed session storage.

Volatile data is anything MySQL can regenerate: rendered menu HTML, term and query caches, alloptions. Losing it costs a few extra queries.

Mission-critical data is anything where Redis holds the only copy: guest cart state, applied coupons, and checkout progress, if sessions are stored in Redis rather than just cached by it.

With stock WooCommerce the distinction is subtler than it first appears. WC_Session_Handler::get_session() reads through the object cache (wp_cache_get() on the WC_SESSION_CACHE_GROUP group) and falls back to the wp_woocommerce_sessions table on a miss. The table is the source of truth, and Redis is a read-through cache in front of it. So with default WooCommerce, a Redis eviction doesn't lose carts. It sends reads back to MySQL, which is the contention you were trying to avoid. The risks of a default install are operational:

  • No memory ceiling. maxmemory defaults to 0 (unlimited), so Redis grows until the kernel's OOM killer picks a victim, often Redis or PHP-FPM.
  • Write failures once a ceiling exists. The default policy is noeviction, covered next.
  • Shared blast radius. A cache flush on a shared database wipes everything in it. After a flush at peak traffic, every request misses at once and the stampede lands on MySQL.
  • Stranded sessions if you go Redis-only. If you replace the sessions table with a Redis-backed handler, as described in the architecture section, that data is now primary and must be protected like primary data.

The Redis Eviction Policy (redis.conf)

noeviction is the default maxmemory-policy. Once memory reaches maxmemory, Redis stops accepting commands that would allocate more memory. Writes return OOM command not allowed when used memory > 'maxmemory', while reads keep working. How the application experiences that depends on the client. Depending on the object cache drop-in's error handling, you get either a silently degraded cache or exceptions surfacing in checkout requests, at exactly the moment traffic peaks.

For an instance that holds only regenerable cache data, switch to LRU eviction so Redis discards its least recently used keys instead of refusing writes:

# /etc/redis/redis.conf — object cache instance (port 6379)
maxmemory 2gb
maxmemory-policy allkeys-lru
maxmemory-samples 10

Notes on these values:

  • Set maxmemory explicitly. The policy only applies once a limit exists. If persistence is enabled, leave headroom: RDB snapshots and AOF rewrites fork the process, and copy-on-write can temporarily add substantial memory. A limit around 60–70% of available RAM is a sensible starting point. Redis memory fragmentation can also increase physical RAM consumption beyond the memory used by stored keys. Monitor used_memory_rss, mem_fragmentation_ratio, and allocator_frag_ratio through redis-cli INFO memory to identify memory allocation overhead before it contributes to server instability.
  • maxmemory-samples. Redis approximates LRU by sampling keys. The default of 5 is cheap, and 10 is closer to true LRU at a small CPU cost.
  • allkeys-lfu is the alternative. It favors frequently used keys over recently used ones, which can suit stores with a stable hot set.

Apply at runtime and persist it, then monitor:

redis-cli CONFIG SET maxmemory-policy allkeys-lru
redis-cli CONFIG REWRITE

redis-cli INFO memory | grep -E 'used_memory_human|maxmemory_human|maxmemory_policy'
redis-cli INFO stats  | grep -E 'evicted_keys|keyspace_hits|keyspace_misses'

A steadily climbing evicted_keys means the instance is undersized, and a falling hit ratio means evictions are now costing you MySQL queries.

One limit matters for the next section: allkeys-lru is safe only for an instance whose contents are all reproducible. Eviction policy is an instance-wide setting, not a per-database one.

Flowchart illustrating how Redis eviction policies like noeviction and allkeys-lru impact WooCommerce checkout traffic and prevent 502 Bad Gateway errors.

Architectural Isolation (wp-config.php)

Redis logical databases (SELECT 0, SELECT 1, ...) separate keyspaces and nothing else. All databases in an instance share one maxmemory, one eviction policy, one persistence configuration (RDB/AOF), and one command-execution thread. FLUSHDB is scoped to a database, but allkeys-lru will happily evict keys from database 1 to make room for database 0. A database index is a namespace, not a durability boundary. To isolate sessions from cache, run a second instance on its own port, with its own redis.conf.

First, configure the object cache with constants the Redis Object Cache plugin actually reads:

define( 'WP_REDIS_HOST', '127.0.0.1' );
define( 'WP_REDIS_PORT', 6379 );
define( 'WP_REDIS_DATABASE', 0 );
define( 'WP_REDIS_PREFIX', 'shop_' );      // unique per site on a shared host
define( 'WP_REDIS_MAXTTL', 86400 );        // cap TTLs so stale keys age out
define( 'WP_REDIS_TIMEOUT', 1 );           // connect timeout, seconds
define( 'WP_REDIS_READ_TIMEOUT', 1 );      // never let Redis pin a PHP worker

The timeouts matter more than they look. A slow Redis that blocks workers for the default timeout reproduces the same pool exhaustion the lock contention caused.

Then give session data its own instance with a policy that fits primary data:

# /etc/redis/redis-sessions.conf — dedicated session instance (port 6380)
port 6380
maxmemory 1gb
maxmemory-policy noeviction     # or volatile-ttl to shed soonest-expiring carts first
appendonly yes
appendfsync everysec

noeviction is correct here because the instance holds primary data: size it for peak sessions, alert on memory use, and accept hard failures over silent cart loss. If availability matters more than retention, volatile-ttl evicts the sessions closest to expiry first.

Routing sessions to that instance requires a custom handler. WooCommerce applies the woocommerce_session_handler filter to choose its session class, and WC_Session_Handler exposes get_session(), save_data(), delete_session(), update_session_timestamp(), and cleanup_sessions() as public methods that can be overridden. This is a reference implementation using the phpredis extension. Verify the signatures against your installed WooCommerce version before deploying, and test it on staging:

<?php
// mu-plugins/mcf-redis-sessions.php
defined( 'ABSPATH' ) || exit;

add_filter( 'woocommerce_session_handler', function () {
    require_once __DIR__ . '/class-mcf-redis-session.php';
    return 'MCF_Redis_Session';
} );
<?php
// mu-plugins/class-mcf-redis-session.php
class MCF_Redis_Session extends WC_Session_Handler {

    private const KEY_PREFIX = 'wcs:';
    private $redis;

    private function redis() {
        if ( ! $this->redis ) {
            $r = new Redis();
            $r->connect( '127.0.0.1', 6380, 1.0 );
            $r->setOption( Redis::OPT_READ_TIMEOUT, 1.0 );
            $this->redis = $r;
        }
        return $this->redis;
    }

    public function get_session( $customer_id, $default_value = false ) {
        $raw = $this->redis()->get( self::KEY_PREFIX . $customer_id );
        return false === $raw ? $default_value : maybe_unserialize( $raw );
    }

    public function save_data( $old_session_key = '' ) {
        if ( $this->_dirty && $this->has_session() ) {
            $ttl = max( 1, (int) $this->_session_expiration - time() );
            $this->redis()->setex(
                self::KEY_PREFIX . $this->_customer_id,
                $ttl,
                maybe_serialize( $this->_data )
            );
            $this->_dirty = false;

            if ( get_current_user_id() != $old_session_key
                && ! is_object( get_user_by( 'id', $old_session_key ) ) ) {
                $this->delete_session( $old_session_key );
            }
        }
    }

    public function delete_session( $customer_id ) {
        $this->redis()->del( self::KEY_PREFIX . $customer_id );
    }

    public function update_session_timestamp( $customer_id, $timestamp ) {
        $this->redis()->expireAt( self::KEY_PREFIX . $customer_id, (int) $timestamp );
    }

    public function cleanup_sessions() {
        // Redis TTLs handle expiry; there is no table to purge.
    }
}

Before shipping this, wrap the Redis calls in try/catch (RedisException) and decide whether a failure should fall back to the parent MySQL implementation or fail closed. Also plan the cutover for off-peak hours, because existing guest carts in the old table are not migrated. Logged-in customers are unaffected, since WooCommerce keeps their persistent cart in user meta. The WooCommerce admin tool that clears customer sessions operates on the old table, not on these keys.

Settings you will see in other guides that do nothing here

  • define('WC_SESSION_REDIS_DATABASE', 1); No component of WooCommerce or the Redis Object Cache plugin reads this constant. It has no effect.
  • define('WC_SESSION_CACHE_GROUP', 'wc_session_cache'); Core uses this constant as the cache group name in its wp_cache_get() and wp_cache_add() calls. A group name labels keys. It doesn't choose a server or a database.
  • ini_set('session.save_handler', 'redis'); and ini_set('session.save_path', 'tcp://127.0.0.1:6379?database=1'); These configure PHP's native $_SESSION storage. WooCommerce identifies customers with its own cookie and a database row, never calling session_start(), so these lines only affect other code that does. As a side effect, WooCommerce avoids native session locking, where a handler serializes every concurrent request from one user.

Related Architecture Guides: Before offloading session writes to a dedicated Redis database index, ensure your MySQL initialization footprint is lean by eradicating wp_options autoload bloat and expired WooCommerce transients. In addition, pairing your Redis session configuration with HPOS custom order tables and CRUD meta queries removes the remaining MySQL bottlenecks during order creation. Finally, verify that your CDN rules respect dynamic session cookies by reviewing our WP Rocket and Cloudflare checkout cache exclusion rules.

Conclusion

Moving session traffic to a dedicated Redis instance takes it out of the MySQL lock path. Redis executes commands on a single thread, so each individual GET, SETEX, or INCRBY is atomic without row locks, gap locks, or lock-wait timeouts. Reads and writes are O(1), and a session write no longer queues behind anything else.

Redis also offers optimistic locking for read-modify-write sequences: WATCH a key, read it, queue changes with MULTI, and EXEC aborts if another client modified the key in the meantime, at which point you retry. Neither WooCommerce core nor the object cache plugin uses this for sessions. WooCommerce writes the whole session blob, so two simultaneous requests from one customer can overwrite each other (last write wins). If lost updates matter on your store, the custom handler above is where you would add WATCH/MULTI/EXEC or a Lua compare-and-set. The same primitives suit flash-sale stock counters, where DECR is atomic on its own.

Redis does not remove contention on the stock row for the sale SKU, which stays in MySQL. Pair this setup with sensible pm.max_children limits, tight Redis and database timeouts, and a queue or waiting room in front of checkout, so a lock wait stays a slow request instead of a pool-wide outage. To measure the business impact of these optimizations, monitor your WooCommerce checkout conversion rate, cart abandonment rate, average checkout response time, and successful order completion rate before and after deployment. Redis performance improvements should ultimately translate into a more reliable checkout experience, not just lower database load.

0 Comments