Skip to content

Support Updating Counter Cache After Commit (to avoid deadlocks) #263

Closed
@jbritten

Description

Support updating the counter cache after commit (outside the primary transaction) where the SQL UPDATE calls would be less susceptible to deadlocks.

Let's say we've got a Campaign model which can have many Subscribers. We store lots of campaign metrics in a separate CampaignMetrics summary table. When a subscriber confirms their email address we may see a MySQL transaction such as the following, which illustrates counter_culture updating various counts as well as additional proprietary work being performed:

BEGIN
SELECT 1 AS one FROM `subscribers` WHERE (`subscribers`.`email` = 'user@host.com' AND `subscribers`.`campaign_id` = 1001) LIMIT 1
SELECT `campaigns`.* FROM `campaigns` WHERE `campaigns`.`id` = 1001 LIMIT 1
UPDATE `subscribers` SET `confirmed_at` = '2019-07-01 16:34:04', `status` = 'confirmed', `updated_at` = '2019-07-01 16:34:04' WHERE `subscribers`.`id` = 2001
SELECT `campaign_metrics`.* FROM `campaign_metrics` WHERE `campaign_metrics`.`campaign_id` = 1001 LIMIT 1
UPDATE `campaign_metrics` SET `confirmed_subscribers_count` = COALESCE(`confirmed_subscribers_count`, 0) + 1 WHERE `campaign_metrics`.`id` = 1001
SELECT `campaigns`.* FROM `campaigns` WHERE `campaigns`.`id` = 1001 LIMIT 1
SELECT `campaign_metrics`.* FROM `campaign_metrics` WHERE `campaign_metrics`.`campaign_id` = 1001 LIMIT 1
UPDATE `campaign_metrics` SET `unconfirmed_subscribers_count` = COALESCE(`unconfirmed_subscribers_count`, 0) - 1 WHERE `campaign_metrics`.`id` = 1001
 ... (some additional proprietary work) ...
COMMIT

There are other actions a Subscriber could do which will also update data in the CampaignMetrics table. For example, opening an email received would update the email_opens_count on the CampaignMetrics table with a transaction such as the following:

BEGIN
SELECT 1 AS one FROM `subscribers` WHERE (`subscribers`.`email` = 'user@host.com' AND `subscribers`.`campaign_id` = 1001) LIMIT 1
SELECT `campaigns`.* FROM `campaigns` WHERE `campaigns`.`id` = 1001 LIMIT 1
SELECT `campaign_metrics`.* FROM `campaign_metrics` WHERE `campaign_metrics`.`campaign_id` = 1001 LIMIT 1
UPDATE `campaign_metrics` SET `email_opens_count` = COALESCE(`email_opens_count`, 0) + 1 WHERE `campaign_metrics`.`id` = 1001
 ... (some additional proprietary work) ...
COMMIT

Now, at scale when many concurrent activities are occurring, such as many subscribers confirming their email address, opening emails, clicking emails, etc. deadlocks such as the following can occur when updating the CampaignMetrics summary table:

------------------------
LATEST DETECTED DEADLOCK
------------------------
2019-07-01 13:17:44 2b373c029700
*** (1) TRANSACTION:
TRANSACTION 2384728875, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 376, 2 row lock(s), undo log entries 2
MySQL thread id 1358, OS thread handle 0x2b34803cb700, query id 8039221 10.0.102.43 deploy updating
UPDATE `campaign_metrics` SET `confirmed_subscribers_count` = COALESCE(`confirmed_subscribers_count`, 0) + 1 WHERE `campaign_metrics`.`id` = 1001
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 663 page no 110 n bits 109 index `PRIMARY` of table `database_production`.`campaign_metrics` trx id 2384728875 lock_mode X locks rec but not gap waiting

Yes, SQL calls to update the counter cache are atomic; however, transactions must be short (lock fewer rows for the smallest amount of time as much as possible). Supporting an optional configuration flag to execute updating the counter cache after commit would yield the following SQL, which would be much less susceptible to deadlocks:

BEGIN
SELECT 1 AS one FROM `subscribers` WHERE (`subscribers`.`email` = 'user@host.com' AND `subscribers`.`campaign_id` = 1001) LIMIT 1
SELECT `campaigns`.* FROM `campaigns` WHERE `campaigns`.`id` = 1001 LIMIT 1
UPDATE `subscribers` SET `confirmed_at` = '2019-07-01 16:34:04', `status` = 'confirmed', `updated_at` = '2019-07-01 16:34:04' WHERE `subscribers`.`id` = 2001
 ... (some additional proprietary work) ...
COMMIT
BEGIN
SELECT `campaign_metrics`.* FROM `campaign_metrics` WHERE `campaign_metrics`.`campaign_id` = 1001 LIMIT 1
UPDATE `campaign_metrics` SET `confirmed_subscribers_count` = COALESCE(`confirmed_subscribers_count`, 0) + 1 WHERE `campaign_metrics`.`id` = 1001
COMMIT

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions