分区计数器模式(Slotted Counter Pattern)
在数据库中,当某些事件发生(例如下载或页面浏览)时,将一个 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
表,将数据汇总,并更新与其他数据一起存储的列。
关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台
除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接
本文链接:http://www.choupangxia.com/2025/09/07/slotted-counter-pattern/