在数据库中,当某些事件发生(例如下载或页面浏览)时,将一个 INT 列计数器递增是一个常见的模式。这个模式可以支持你的需求,直到这些事件并发地爆发发生,你开始在单一行上体验到竞争问题。当多个事务试图更新计数器时,实际上是强制这些事务串行化执行,这对并发性非常不利,并可能导致死锁。此外,在事件大规模爆发时,你可能会看到查询时间显著增加。


检查是否存在竞争

可以通过运行以下命令检查是否存在竞争:

SHOW ENGINE INNODB STATUS\G

输出中会显示一些关于授予锁的信息:

---TRANSACTION 79853106, ACTIVE 5 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1128, 1 row lock(s)
MySQL thread id 24, OS thread handle 6281670656, query id 107 localhost root updating
UPDATE slotted_counters SET count = count + 1 WHERE id = 1
------- TRX HAS BEEN WAITING 5 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 4 n bits 184 index PRIMARY of table `github`.`downloads` trx id 79853106 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 7; compact format; info bits 0
 0: len 4; hex 80000001; asc     ;;
 1: len 6; hex 000004c27630; asc     v0;;
 2: len 7; hex 020000017d0ce9; asc     }  ;;
 3: len 4; hex 8000007b; asc    {;;
 4: len 4; hex 800001c8; asc     ;;
 5: len 4; hex 80000019; asc     ;;
 6: len 4; hex 8230df9b; asc  0  ;;

例如,在以上输出中,你可以看到该事务为了递增单行上的计数器,已经等待了很长时间来获得锁,它与其他竞争事务发生了冲突。


MySQL 在 GitHub 的实践

MySQL 是 GitHub.com 的主要数据库,早些时候,许多 PlanetScale 的工作人员在 GitHub 工作时不得不以不同方式进行这种计数操作。我们决定使用一个额外的表,其表结构类似于下面的示例:

CREATE TABLE `slotted_counters` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `record_type` int(11) NOT NULL,
  `record_id` int(11) NOT NULL,
  `slot` int(11) NOT NULL DEFAULT '0',
  `count` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `records_and_slots` (`record_type`,`record_id`,`slot`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

表字段解释

  • record_type:计数器的类型(目的是让表具有通用性)。
  • record_id:标识需要计数的内容,可以映射到比如仓库 ID。
  • slot:分区(slot),我们将在该分区内递增。
  • count:每个分区的计数值。

常规递增查询示例

我们通常会运行以下查询:

INSERT INTO slotted_counters(record_type, record_id, slot, count)
VALUES (123, 456, RAND() * 100, 1)
ON DUPLICATE KEY UPDATE count = count + 1;

这个想法是与其递增某个计数器的单一行,我们改为选择一个分区,并在该分区中递增计数器值。这意味着我们不再猛烈更新单一行,而是将更新分布到 100 行,从而降低了竞争的可能性。
运行以上 INSERT 几次后,我们可以查看计数器表数据:

SELECT * FROM slotted_counters;

示例输出

+----+-------------+-----------+------+-------+
| id | record_type | record_id | slot | count |
+----+-------------+-----------+------+-------+
|  1 | 123         |       456 |    2 |    21 |
|  2 | 123         |       456 |   52 |    99 |
|  3 | 123         |       456 |   55 |   321 |
|  4 | 123         |       456 |    0 |   442 |
|  7 | 123         |       456 |   48 |    69 |
|  8 | 123         |       456 |   20 |   661 |
|  9 | 123         |       456 |   56 |    62 |
| 10 | 123         |       456 |   18 |   371 |
| 11 | 123         |       456 |   22 |   127 |
| 12 | 123         |       456 |   58 |    33  |
| 13 | 123         |       456 |   23 |   322 |
+----+-------------+-----------+------+-------+
11 rows in set (0.00 sec)

获取计数值

获取 record_id = 456 的计数值可以通过以下简单查询实现:

SELECT SUM(count) as count FROM slotted_counters
WHERE (record_type = 123 AND record_id = 456);

解决竞争问题

通过这种方式,你现在可以并行执行计数器递增的请求,而不会引起竞争或影响并发性。


模式实现的可选方式

这种模式有几种不同的实现方式,但具体取决于你的应用架构。其中一种方式是查询 slotted_counters 表,将数据汇总,并更新与其他数据一起存储的列。



分区计数器模式(Slotted Counter Pattern)插图

关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台

除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接

本文链接:http://www.choupangxia.com/2025/09/07/slotted-counter-pattern/