• Skip to secondary menu
  • Skip to main content
  • Skip to primary sidebar
  • Home
  • Projects
  • Products
  • Themes
  • Tools
  • Request for Quote

Vengala Vinay

Having 12+ Years of Experience in Software Development

  • Home
  • WordPress
  • PHP
    • Codeigniter
  • Django
  • Magento
  • Selenium
  • Server
Home » Fixing Deadlocks on InnoDB row-level locking during simultaneous checkout writes in Legacy Magento 2 Codebases Without Breaking API Contracts

Fixing Deadlocks on InnoDB row-level locking during simultaneous checkout writes in Legacy Magento 2 Codebases Without Breaking API Contracts

Diagnosing InnoDB Row-Level Deadlocks in Magento 2 Checkout

Deadlocks during simultaneous checkout writes in legacy Magento 2 codebases, particularly those leveraging InnoDB’s row-level locking, are a common and frustrating issue. These deadlocks typically manifest when multiple concurrent requests attempt to modify the same database rows, leading to a circular dependency where each transaction waits indefinitely for the other to release a lock. The root cause is often a race condition in how product quantities, stock statuses, or order-related data are accessed and updated. Identifying the exact SQL statements involved is paramount for effective resolution.

The first step in diagnosing these deadlocks is to enable MySQL’s general query log and the slow query log, and crucially, the `innodb_print_all_deadlocks` setting. This will log every deadlock encountered, providing the transaction information and the SQL statements that caused the deadlock. Ensure your MySQL server is configured to capture this information. For production environments, consider a robust logging and monitoring solution that can ingest and analyze these logs efficiently.

Enabling and Analyzing MySQL Deadlock Logs

To enable deadlock logging, you’ll need to modify your MySQL configuration file (typically `my.cnf` or `my.ini`). Restart the MySQL server after making these changes.

MySQL Configuration for Deadlock Logging

Add or modify the following lines in your MySQL configuration file:

[mysqld]
general_log = 1
general_log_file = /var/log/mysql/mysql.log
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 2
innodb_print_all_deadlocks = 1

After enabling these settings and reproducing the deadlock scenario (e.g., by having multiple users attempt to checkout simultaneously with limited stock), examine the MySQL error log and the general query log. The `innodb_print_all_deadlocks` setting will output detailed information about the deadlocking transactions directly into the MySQL error log (or a specified log file if `log_error` is configured). This output is invaluable.

Interpreting Deadlock Output and Identifying Problematic Queries

A typical deadlock log entry will look something like this (simplified):

2023-10-27 10:30:00 0 [ERROR] InnoDB: Transaction: 12345, state: RUNNING, started: 2023-10-27 10:29:55
2023-10-27 10:30:00 0 [ERROR] InnoDB: Transaction: 67890, state: RUNNING, started: 2023-10-27 10:29:58
...
2023-10-27 10:30:00 0 [ERROR] InnoDB: ---BEGIN TRANSACTION 12345---
2023-10-27 10:30:00 0 [ERROR] InnoDB: ---SQL STATEMENT---
INSERT INTO sales_order (...) VALUES (...)
...
2023-10-27 10:30:00 0 [ERROR] InnoDB: ---END TRANSACTION 12345---
2023-10-27 10:30:00 0 [ERROR] InnoDB: ---BEGIN TRANSACTION 67890---
2023-10-27 10:30:00 0 [ERROR] InnoDB: ---SQL STATEMENT---
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 123 AND stock_id = 456
...
2023-10-27 10:30:00 0 [ERROR] InnoDB: ---END TRANSACTION 67890---
...
2023-10-27 10:30:00 0 [ERROR] InnoDB: ---DEADLOCK DETECTED---
2023-10-27 10:30:00 0 [ERROR] InnoDB: Transaction 12345 (process id 12345) was waiting for a lock on row X lock ...
2023-10-27 10:30:00 0 [ERROR] InnoDB: Transaction 67890 (process id 67890) was waiting for a lock on row X lock ...
2023-10-27 10:30:00 0 [ERROR] InnoDB: Transaction 12345 was chosen as the victim. Rollback.

In this example, Transaction 12345 was trying to insert an order, and Transaction 67890 was trying to update inventory. The deadlock occurs because Transaction 12345 might have acquired locks on other tables/rows before Transaction 67890 acquired its lock, and vice-versa, creating a circular wait. The key is to identify the specific SQL statements and the tables/rows they are operating on. In Magento 2, common culprits include operations on sales_order, quote, inventory_stock_1 (or similar inventory tables), and product-related tables during the checkout process.

Refactoring Strategy: Optimistic Locking and Transactional Integrity

Directly modifying core Magento 2 checkout logic to avoid deadlocks can be risky due to API contract implications and the complexity of the checkout flow. A more robust approach involves refactoring the critical sections to use optimistic locking or to ensure that database operations are performed in a consistent, predictable order across all concurrent transactions. For legacy codebases, this often means introducing custom observers or plugins that intercept and modify the behavior of core classes.

Implementing Optimistic Locking for Stock Updates

Optimistic locking assumes that conflicts are rare. Instead of acquiring locks upfront, you read a version number (or a timestamp, or in this case, the current quantity) and then, when updating, you check if the version/quantity has changed since it was read. If it has, you retry the operation. This is particularly effective for stock management.

Consider a scenario where you need to decrement stock. Instead of a simple `UPDATE inventory SET quantity = quantity – 1 WHERE product_id = ? AND stock_id = ?`, you can implement a retry mechanism. This can be done within a custom module, perhaps by extending or observing the relevant service contracts.

Example: Custom Stock Decrement Service (Conceptual PHP)

This is a conceptual example. The actual implementation would involve Magento’s dependency injection and service contracts.

use Magento\Framework\DB\Adapter\AdapterInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\StateException;

class CustomStockService
{
    private const MAX_RETRIES = 5;
    private const RETRY_DELAY_MS = 100; // 100 milliseconds

    /**
     * @var AdapterInterface
     */
    private $connection;

    public function __construct(AdapterInterface $connection)
    {
        $this->connection = $connection;
    }

    /**
     * Decrements stock quantity with optimistic locking retry mechanism.
     *
     * @param int $productId
     * @param int $stockId
     * @param int $decrementBy
     * @throws StateException
     * @throws LocalizedException
     */
    public function decrementStock(int $productId, int $stockId, int $decrementBy = 1): void
    {
        $currentQuantity = null;
        $initialQuantity = null;
        $retries = 0;

        while ($retries < self::MAX_RETRIES) {
            $this->connection->beginTransaction();
            try {
                // 1. Read current quantity and lock the row for update
                $select = $this->connection->select()
                    ->from($this->connection->getTableName('inventory_stock_1'), ['quantity']) // Adjust table name as needed
                    ->where('product_id = ?', $productId)
                    ->where('stock_id = ?', $stockId)
                    ->forUpdate(); // Lock the row

                $row = $this->connection->fetchRow($select);

                if (!$row) {
                    $this->connection->rollBack();
                    throw new LocalizedException(__('Product or stock not found.'));
                }

                $currentQuantity = (float) $row['quantity'];
                if ($initialQuantity === null) {
                    $initialQuantity = $currentQuantity; // Store initial quantity for retry logic
                }

                // 2. Check if stock is sufficient
                if ($currentQuantity < $decrementBy) {
                    $this->connection->rollBack();
                    throw new StateException(__('Insufficient stock for product %1.', $productId));
                }

                // 3. Prepare the update statement
                $newQuantity = $currentQuantity - $decrementBy;
                $updateData = ['quantity' => $newQuantity];
                $where = [
                    'product_id = ?' => $productId,
                    'stock_id = ?' => $stockId,
                    'quantity = ?' => $currentQuantity // Crucial for optimistic locking
                ];

                $affectedRows = $this->connection->update(
                    $this->connection->getTableName('inventory_stock_1'), // Adjust table name
                    $updateData,
                    $where
                );

                // 4. Commit if update was successful
                if ($affectedRows > 0) {
                    $this->connection->commit();
                    return; // Success
                } else {
                    // If affectedRows is 0, it means the quantity changed between read and update.
                    // This is the optimistic lock failure. Rollback and retry.
                    $this->connection->rollBack();
                    $retries++;
                    if ($retries < self::MAX_RETRIES) {
                        usleep(self::RETRY_DELAY_MS * 1000); // Wait before retrying
                    }
                }

            } catch (\Exception $e) {
                $this->connection->rollBack();
                // Log the exception and potentially re-throw or handle specific DB errors
                throw $e;
            }
        }

        // If loop finishes without returning, it means max retries were reached
        throw new StateException(
            __(
                'Could not decrement stock for product %1. Maximum retries reached. Last quantity read: %2',
                $productId,
                $initialQuantity // Or log the actual last read quantity
            )
        );
    }
}

The key here is the `forUpdate()` clause in the SELECT statement, which acquires a row-level exclusive lock. Then, the `WHERE` clause in the `UPDATE` statement includes the original quantity (`quantity = ? => $currentQuantity`). If another transaction modified the quantity between the `SELECT` and `UPDATE`, the `UPDATE` statement will affect 0 rows, triggering the rollback and retry logic. This pattern can be applied to other critical data points that are prone to race conditions.

Ensuring Consistent Transaction Ordering

Another strategy is to enforce a consistent order of operations for database writes across all concurrent transactions. If all transactions attempt to lock and update resources in the same sequence (e.g., always update product stock before creating the order, or vice-versa, and always in a defined order of product IDs), deadlocks can be avoided. This is often harder to implement in complex, legacy systems where different parts of the checkout process might be managed by disparate modules.

For instance, if multiple items are in the cart, ensure that stock updates for all items within a single checkout transaction are performed in a deterministic order (e.g., by product ID). This can be achieved by collecting all necessary stock updates and then processing them in a sorted list within a single database transaction block.

Example: Batch Stock Update with Ordered Processing (Conceptual PHP)

use Magento\Framework\DB\Adapter\AdapterInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\StateException;

class BatchStockUpdater
{
    private const MAX_RETRIES = 5;
    private const RETRY_DELAY_MS = 100;

    /**
     * @var AdapterInterface
     */
    private $connection;

    public function __construct(AdapterInterface $connection)
    {
        $this->connection = $connection;
    }

    /**
     * Updates stock for multiple items in a single transaction, ordered by product ID.
     *
     * @param array<string, int> $stockUpdates [productId => decrementBy]
     * @throws StateException
     * @throws LocalizedException
     */
    public function updateStockInBatch(array $stockUpdates): void
    {
        if (empty($stockUpdates)) {
            return;
        }

        // Ensure consistent ordering by product ID
        ksort($stockUpdates);

        $retries = 0;
        while ($retries < self::MAX_RETRIES) {
            $this->connection->beginTransaction();
            try {
                $allUpdatesSuccessful = true;
                $initialQuantities = []; // To store quantities read at the start of the transaction

                // First pass: Read all quantities and check for sufficient stock
                foreach ($stockUpdates as $productId => $decrementBy) {
                    $select = $this->connection->select()
                        ->from($this->connection->getTableName('inventory_stock_1'), ['quantity'])
                        ->where('product_id = ?', $productId)
                        ->where('stock_id = ?', 1) // Assuming stock_id 1
                        ->forUpdate(); // Lock rows for update

                    $row = $this->connection->fetchRow($select);

                    if (!$row) {
                        $this->connection->rollBack();
                        throw new LocalizedException(__('Product %1 not found.', $productId));
                    }

                    $currentQuantity = (float) $row['quantity'];
                    $initialQuantities[$productId] = $currentQuantity; // Store for retry logic

                    if ($currentQuantity < $decrementBy) {
                        $this->connection->rollBack();
                        throw new StateException(__('Insufficient stock for product %1.', $productId));
                    }
                }

                // Second pass: Perform the updates
                foreach ($stockUpdates as $productId => $decrementBy) {
                    $currentQuantity = $initialQuantities[$productId]; // Use the quantity read at the start
                    $newQuantity = $currentQuantity - $decrementBy;

                    $updateData = ['quantity' => $newQuantity];
                    $where = [
                        'product_id = ?' => $productId,
                        'stock_id = ?' => 1,
                        'quantity = ?' => $currentQuantity // Optimistic lock check
                    ];

                    $affectedRows = $this->connection->update(
                        $this->connection->getTableName('inventory_stock_1'),
                        $updateData,
                        $where
                    );

                    if ($affectedRows === 0) {
                        // Optimistic lock failed for this item, need to retry the whole batch
                        $allUpdatesSuccessful = false;
                        break; // Exit inner loop to trigger retry
                    }
                }

                // If all updates were successful, commit
                if ($allUpdatesSuccessful) {
                    $this->connection->commit();
                    return; // Success
                } else {
                    // Rollback and retry the entire batch
                    $this->connection->rollBack();
                    $retries++;
                    if ($retries < self::MAX_RETRIES) {
                        usleep(self::RETRY_DELAY_MS * 1000);
                    }
                }

            } catch (\Exception $e) {
                $this->connection->rollBack();
                // Log and re-throw
                throw $e;
            }
        }

        throw new StateException(
            __('Could not update stock in batch. Maximum retries reached.')
        );
    }
}

This batch approach, combined with ordering and optimistic locking within the batch, significantly reduces the window for deadlocks. The `ksort($stockUpdates)` ensures that regardless of the order items were added to the cart or processed initially, the database operations will always attempt to acquire locks and update rows in the same sequence (by product ID). If any single update within the batch fails the optimistic lock check, the entire transaction is rolled back, and the whole batch is retried.

Leveraging Magento’s Service Contracts and Plugins

When refactoring legacy Magento 2 code, it’s crucial to adhere to Magento’s architectural principles. Instead of directly modifying core files, use plugins (interceptor methods) or preference rewrites to inject your custom logic. For example, you might use an `around` plugin on a method responsible for saving order data or updating inventory. This allows you to wrap the original method call with your retry logic or batch processing, ensuring that API contracts remain intact.

Example: Plugin for Order Save (Conceptual)

Imagine a core method like SalesOrderRepository::save() or a service that handles stock deduction. You could create a plugin to intercept this call.

// app/code/Vendor/Module/etc/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Sales\Model\OrderRepository">
        <plugin name="vendor_module_order_save_deadlock_handler"
                type="Vendor\Module\Plugin\OrderSaveDeadlockHandler"
                sortOrder="10" />
    </type>
</config>

// app/code/Vendor/Module/Plugin/OrderSaveDeadlockHandler.php
namespace Vendor\Module\Plugin;

use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Model\OrderRepository;
use Magento\Framework\Exception\StateException;
use Vendor\Module\Service\CustomStockService; // Your refactored service

class OrderSaveDeadlockHandler
{
    /**
     * @var CustomStockService
     */
    private $customStockService;

    // Inject dependencies, e.g., the refactored stock service

    public function __construct(
        CustomStockService $customStockService
        // ... other dependencies
    ) {
        $this->customStockService = $customStockService;
        // ...
    }

    /**
     * Around plugin to intercept order saving.
     *
     * @param OrderRepository $subject
     * @param callable $proceed
     * @param OrderInterface $order
     * @return OrderInterface
     * @throws StateException
     */
    public function aroundSave(
        OrderRepository $subject,
        callable $proceed,
        OrderInterface $order
    ): OrderInterface {
        // Logic to extract product IDs and quantities from the order
        $stockUpdates = $this->extractStockUpdatesFromOrder($order);

        // Attempt to update stock using the robust method
        try {
            // This is where you'd call your batch updater or individual retry logic
            // For simplicity, let's assume we call a method that handles retries internally
            $this->customStockService->processOrderStockUpdates($stockUpdates);

            // If stock update succeeds, proceed with the original order save
            return $proceed($order);

        } catch (StateException $e) {
            // Handle stock update failures (e.g., insufficient stock after retries)
            // Log the error and potentially throw a more specific Magento exception
            throw new LocalizedException(__('Failed to save order due to stock issues: %1', $e->getMessage()));
        } catch (\Exception $e) {
            // Catch other potential exceptions during stock update
            throw new LocalizedException(__('An unexpected error occurred during order processing: %1', $e->getMessage()));
        }
    }

    /**
     * Placeholder method to extract stock updates from order items.
     * This needs to be implemented based on your order structure.
     *
     * @param OrderInterface $order
     * @return array<string, int> [productId => decrementBy]
     */
    private function extractStockUpdatesFromOrder(OrderInterface $order): array
    {
        $updates = [];
        foreach ($order->getItems() as $item) {
            // Ensure it's a product item and not a bundle/configurable parent etc.
            if ($item->getSku() && $item->getQtyOrdered() > 0) {
                // You'll need to map SKU to Product ID if not directly available
                // And handle different item types (simple, configurable, bundle)
                // For simplicity, assuming direct mapping and simple products
                $productId = $this->getProductIdBySku($item->getSku()); // Implement this lookup
                if ($productId) {
                    $updates[$productId] = ($updates[$productId] ?? 0) + $item->getQtyOrdered();
                }
            }
        }
        return $updates;
    }

    /**
     * Placeholder for SKU to Product ID lookup.
     * In a real scenario, use Magento's ProductRepository or a dedicated service.
     *
     * @param string $sku
     * @return int|null
     */
    private function getProductIdBySku(string $sku): ?int
    {
        // Implement proper lookup using Magento's ProductRepository
        // Example:
        // try {
        //     $product = $this->productRepository->get($sku);
        //     return $product->getId();
        // } catch (\Magento\Framework\Exception\NoSuchEntityException $e) {
        //     return null;
        // }
        return null; // Replace with actual implementation
    }
}

By using plugins, you can introduce the retry logic or batch processing without altering the core Magento classes, thus preserving API contracts and making upgrades easier. The `around` plugin is particularly powerful as it allows you to decide whether to call the original method (`$proceed($order)`) at all, or to modify its behavior entirely.

Monitoring and Prevention

Once refactored, continuous monitoring is key. Keep the `innodb_print_all_deadlocks` setting enabled in production (or at least during periods of high load or after significant code deployments) and integrate deadlock logs into your monitoring system. Set up alerts for deadlock occurrences. Regularly review slow query logs and database performance metrics. Proactive database tuning, proper indexing, and efficient query writing are fundamental to preventing deadlocks in the first place.

Primary Sidebar

A little about the Author

Having 12+ Years of Experience in Software Development, Vinay is a principal software architect, senior systems engineer, and elite technical consultant. He specializes in bespoke PHP/WordPress development, high-performance Magento 2 & Shopify architectures, custom plugin/theme development from scratch, and legacy code modernization (including VB6, VB.NET, PyQt, and Crystal Reports). Known for solving complex database bottlenecks, speed optimization (Core Web Vitals), and advanced security code auditing, Vinay engineers production-ready systems designed to scale under heavy concurrent load conditions.



Chat on WhatsApp

Recent Posts

  • Debugging Guide: Diagnosing PHP-FPM child process pool exhaustion in multi-site network environments with modern tools
  • Debugging and Resolving complex namespace class loading collisions issues during heavy concurrent database traffic
  • Step-by-Step Guide: Offloading high-frequency customer support tickets metadata writes to a Redis KV store
  • How to refactor legacy event ticket registers queries using modern WP_Query and custom Transient caching
  • Step-by-Step Guide: Offloading high-frequency member profile directories metadata writes to a Redis KV store

Categories

  • apache (1)
  • Business & Monetization (390)
  • Centos (4)
  • Comparisons & Decision Making (55)
  • Debian (2)
  • Debugging & Troubleshooting (662)
  • Desktop Applications (14)
  • DevOps (7)
  • DevOps & Cloud Scaling (962)
  • Django (1)
  • Laravel (4)
  • Migration & Architecture (192)
  • Mobile Applications (24)
  • MySQL (1)
  • Performance & Optimization (873)
  • PHP (5)
  • PHP Development (49)
  • Plugins & Themes (244)
  • Programming Languages (9)
  • Python (20)
  • Ruby on Rails (1)
  • Security & Compliance (647)
  • SEO & Growth (492)
  • Server (118)
  • Ubuntu (9)
  • VB6 & VB.NET (8)
  • Web Applications & Frontend (19)
  • Web Assembly (Wasm) (2)
  • WordPress (22)
  • WordPress Plugin Development (726)
  • WordPress Theme Development (357)

Recent Posts

  • Debugging Guide: Diagnosing PHP-FPM child process pool exhaustion in multi-site network environments with modern tools
  • Debugging and Resolving complex namespace class loading collisions issues during heavy concurrent database traffic
  • Step-by-Step Guide: Offloading high-frequency customer support tickets metadata writes to a Redis KV store

Top Categories

  • DevOps & Cloud Scaling (962)
  • Performance & Optimization (873)
  • WordPress Plugin Development (726)
  • Debugging & Troubleshooting (662)
  • Security & Compliance (647)
  • SEO & Growth (492)

Our Products

  • ERP & LMS Systems (4)
  • Directories & Marketplaces (4)
  • Healthcare Portals (3)
  • Point of Sale (POS) (2)
  • E-Commerce Engines (2)

Our Services

  • E-Commerce Development (10)
  • WordPress Development (8)
  • Python & Desktop GUI (7)
  • General Consulting (7)
  • Legacy Modernization (5)
  • Mobile App Development (4)

Copyright © 2026 · Vinay Vengala