【问题标题】:Mysql: lock only one row for selectMysql:只为选择锁定一行
【发布时间】:2017-04-13 11:10:44
【问题描述】:

我有一种“彩票分配”的问题。每个来找我的用户都需要从可用代码表中获取一个唯一代码。每个用户必须获得一个且仅一个代码,并且每个代码必须提供给一个且仅一个用户。将提供给用户的代码是表中第一个可用的、未被标记为“已使用”的代码。

这个问题和这个很相似:mysql - Locking rows for select query?

但有一个巨大的不同:到目前为止,我已经测量了多达 18 个用户/秒的访问,因此我只需要锁定每个用户的一行(我正在为每个用户处理的行)。锁定整个表可能是个问题。

我读到“READ COMMITTED”可能有用:https://www.percona.com/blog/2012/08/28/differences-between-read-committed-and-repeatable-read-transaction-isolation-levels/

但这是我第一次做这样的事情,我有点迷失在如何准确和热地测试它,然后在将代码投入生产之前模拟这个巨大的负载。

【问题讨论】:

    标签: mysql


    【解决方案1】:

    您永远不需要锁定行

    在处理唯一性时,您放置UNIQUE 约束。 始终。永远也不例外。

    关键不是性能,而是数据完整性。这就是数据库的用途 - 包含有效数据,以便您可以创建预测、计划等。

    让它变快并不难。 不要为了性能而牺牲数据完整性

    免责声明:我没有在这里测试任何 SQL。我发布的所有内容都是一个教育示例。如果您复制粘贴它可能会起作用,我不保证此处显示的任何 SQL 的语法正确性。

    问题

    每个用户必须得到一个且只有一个代码,并且必须给出每个代码 一个且只有一个用户

    这定义了唯一性。有一个代码。一个用户只能拥有一个代码。让我们设计模型。

    解决办法

    • 我们将代码保存到一个表中
    • 我们将使用联结表将用户与代码联系起来。使用了联结表,因此我们不必alterusers 表来使用这种方法
    • 代码将具有唯一值(由UNIQUE 约束处理)
    • 联结表将只允许每个用户使用 1 个代码,但如果以后需要,同一用户仍可以分配其他代码

    使用这种方法:

    • 我们收到关于重复条目的错误
    • 我们处理这些错误

    为什么我们会出错?因为我们会遇到独特的约束,而数据库将简单地拒绝该条目。但这就是我们想要的,这就是我们将得到的。锁定行不是解决这类问题的方法。它更慢。您需要在某个时候解锁行。它容易出现并发问题。数据库对唯一性的处理比您或任何其他程序员都好很多倍。因此,我们将使用数据库的机制。

    代码表

    代码表将包含代码。您没有定义 如何 代码的外观,所以我将假设它是一个字符串。我会选择varchar(255),因为我喜欢它。由于代码必须是唯一的,我将帮助自己一点点触发器和一个唯一的约束。我将使用sha1 对代码的值进行哈希处理,将其保存到binary(20) 中并使binary 列唯一。

    我从中得到了什么: - 我的代码现在可以看起来像任何东西,可以是任何类型字符的组合 - 我总是有固定的索引长度,所以我可以放置unique 约束而不用担心 - 我可以使用哈希搜索代码,但我可以向客户显示更友好的版本 - 由于unique约束,数据库中只能有一个code值

    CREATE TABLE codes (
        id INT UNSIGNED NOT NULL AUTO_INCREMENT,
        code_value VARCHAR(255) NOT NULL, -- this is the "friendly" value that customers get
        code_hash binary(16) default null, -- this is the hash of the above value
        is_used tinyint not null default '0', -- a small helper field when querying for which codes are "free"
        primary key(id),
        unique(code_hash)
    ) ENGINE = InnoDB;
    

    处理散列的触发器,所以我们不必提供值:

    DELIMITER $$
    
    CREATE
    TRIGGER `codes_before_insert` BEFORE INSERT
    ON `codes`
        FOR EACH ROW BEGIN
            SET NEW.code_hash = UNHEX(SHA1(NEW.code_value));
    END$$
    
    DELIMITER ;
    

    联结表

    1. 已使用,因此我们不必更改任何现有的与用户相关的表
    2. 允许每个用户使用一个代码
    3. 允许一个用户使用多个代码
    4. 包含强制数据完整性的外键

    桌子:

    CREATE TABLE user2code (
        id int unsigned not null auto_increment,
        user_id int unsigned not null,
        code_id int unsigned not null,
        unique(code_id), -- this part allows for only 1 code to be used, ever
        foreign key(user_id) references users(id) on delete cascade,
        foreign key(code_id) references codes(id) on delete cascade
    ) ENGINE = InnoDB;
    

    我们将在联结表上放置一个实用程序触发器。使用代码时,我们将更新codes 表并将is_used 设置为1。这样做是为了在空闲/占用代码之间更容易导航。我们可以通过JOIN-ing 将user2code 表放到codes 表上来做到这一点,但我们希望性能更高。

    DELIMITER $$
    
    CREATE
    TRIGGER `user2code_after_insert` AFTER INSERT
    ON `user2code`
        FOR EACH ROW BEGIN
            UPDATE `codes` SET is_used = 1 WHERE id = NEW.code_id;
    END$$
    
    DELIMITER ;
    

    如何使用

    INSERT INTO user2code 
    (user_id, code_id) 
    VALUES 
    (1, (SELECT id FROM codes WHERE is_used = 0 LIMIT 1));
    

    结果:

    1. 成功后,就是这样!
    2. 如果出现错误,您可以通过重复事务来处理错误。您可以将事务重复几次 (3-4) 次,如果失败,请让用户稍后再试。这与 UX 相关,我对你的情况一无所知。

    关键是要知道失败的交易也不错。这是数据库告诉我们的方式嘿,你不能这样做,这就是原因

    放置锁是危险的 - 它不能保证唯一性。只有唯一的约束可以。

    祝你好运!

    【讨论】:

    • 你真的练习过这个解决方案吗?这是我在插入 user2code 表时得到的结果,触发错误如下: INSERT INTO user2code (code_id) SELECT id FROM codes WHERE is_used = 0 LIMIT 1 Error Code: 1442. Can't update table 'codes' in stored function/trigger因为它已经被调用这个存储的函数/触发器的语句使用了 0.021 秒
    • INSERT SELECT 锁定选择表,因此您将永远没有机会在触发器函数中更新选择表。还必须注意 INSERT SELECT 不是一个有效的操作,检查stackoverflow.com/questions/2640898/…
    【解决方案2】:

    由于您只需要表格中的一个字段,也许您可​​以尝试一些比参考问题建议的更简单的方法。您实际上并不需要一组语句,您需要对一个表进行更新,该表将能够从它更新的行中返回一个值。所以,这实际上可以工作:

    CREATE TABLE codes (code CHAR(16) PRIMARY KEY, used BOOL DEFAULT 0, INDEX(used));
    CREATE TRIGGER pick_code AFTER UPDATE ON codes FOR EACH ROW SET @your_code = OLD.code;
    --populate the table
    

    现在,连接中的每个用户都运行

    UPDATE codes SET used = 1 WHERE used = 0 LIMIT 1;
    

    然后这应该返回选择的代码:

    SELECT @your_code;
    

    它是原子的,因此您不需要为此执行事务或显式表锁定。是否制作表格InnoDBMyISAM 应根据您的环境中的比较性能凭经验决定,因为它可能取决于许多超出此处范围的因素。


    完整性说明

    请注意,这只是一个存根,而不是完整的解决方案。实际上,您需要更多逻辑来确保您的所有 4 个要求:

    • 只有一个用户获得代码;
    • 至少一个用户获得了一个代码;
    • 只给一个用户一个代码;
    • 至少向用户提供了一个代码。

    存根解决了最后一点,第一个和第二个是崩溃安全问题(您应该能够确保使用适当的 InnoDB 设置,即使在其他方面 InnoDB 在此流程中将不如 MyISAM) ,最后对于第三点,您还需要存储用户已获得代码的信息,但这取决于您的用户是如何被识别的。例如。可能是这样的

    CREATE TABLE codes (code CHAR(16) PRIMARY KEY, used BOOL DEFAULT 0, assignee VARCHAR(128), UNIQUE(assignee), INDEX(used));
    CREATE TRIGGER pick_code BEFORE UPDATE ON codes FOR EACH ROW SET @your_code = OLD.code, NEW.assignee = CURRENT_USER();
    

    (只是另一个存根——它可以以完全不同的方式完成)。


    更新(关于为used 列建立索引的说明

    自从 cmets 提出关于 used 索引的问题以来,我已经运行了一个快速的非正式基准测试。它基于上述解决方案,但也值得考虑使用任何其他使用类似结构和 DML 的解决方案。

    免责声明:

    • 结果中的绝对值完全不相关,测试是在常规 Debian 桌面安装上执行的,没有针对基准测试进行任何调整;
    • 结果并不是为了证明建议的解决方案是好的,只是为了检查讨论的一些要点;
    • 服务器没有经过 InnoDB 调优,使用适当配置的 InnoDB 表可能会获得更好的性能,这只是一个非常粗略的比较。

    测试设置

    • MySQL server 5.6.34,官网64位二进制压缩包
    • 选项:--innodb-buffer-pool-size=4G --innodb-flush-log-at-trx-commit=2,所有其他选项都是服务器默认值;
    • 客户端工具:mysqlslap 来自同一个包

    创建了四个表。除了引擎(MyISAM vs InnoDB)和used 列上的索引(索引 vs 无索引)之外,结构是相同的。

    MySQL [test]> show create table codes_innodb \G
    *************************** 1. row ***************************
           Table: codes_innodb
    Create Table: CREATE TABLE `codes_innodb` (
      `code` char(17) NOT NULL,
      `used` tinyint(1) DEFAULT '0',
      PRIMARY KEY (`code`)
    ) ENGINE=InnoDB DEFAULT CHARSET=latin1
    1 row in set (0.00 sec)
    
    MySQL [test]> show create table codes_innodb_i \G
    *************************** 1. row ***************************
           Table: codes_innodb_i
    Create Table: CREATE TABLE `codes_innodb_i` (
      `code` char(17) NOT NULL,
      `used` tinyint(1) DEFAULT '0',
      PRIMARY KEY (`code`),
      KEY `used` (`used`)
    ) ENGINE=InnoDB DEFAULT CHARSET=latin1
    1 row in set (0.00 sec)
    
    MySQL [test]> show create table codes_myisam \G
    *************************** 1. row ***************************
           Table: codes_myisam
    Create Table: CREATE TABLE `codes_myisam` (
      `code` char(17) NOT NULL,
      `used` tinyint(1) DEFAULT '0',
      PRIMARY KEY (`code`)
    ) ENGINE=MyISAM DEFAULT CHARSET=latin1
    1 row in set (0.00 sec)
    
    MySQL [test]> show create table codes_myisam_i \G
    *************************** 1. row ***************************
           Table: codes_myisam_i
    Create Table: CREATE TABLE `codes_myisam_i` (
      `code` char(17) NOT NULL DEFAULT '',
      `used` tinyint(1) DEFAULT '0',
      PRIMARY KEY (`code`),
      KEY `used` (`used`)
    ) ENGINE=MyISAM DEFAULT CHARSET=latin1
    1 row in set (0.01 sec)
    

    每个表都填充了 50,000,000 行相同的数据(不相似,但实际上相同)。

    测试流程

    • 对每个表执行两个测试。
    • 每个测试使用 20 个并发线程运行,所有线程都执行相同的更新,每个线程 250 次,总共 5000 次:

    UPDATE codes_innodb SET used = 1 WHERE used = 0 LIMIT 1

    • 当所有行的used=0 时开始第一个测试(“初始”状态)。
    • used=1 1,005,000 行时开始第二次测试。

    测试测量执行所有查询的总时间。

    结果(以秒为单位)

    | Table              |  Test 1  |  Test 2  |
    |--------------------|----------|----------|
    | MyISAM with index  |   0.459  |    0.333 |
    | MyISAM, no index   |   3.425  |  801.383 |
    | InnoDB with index  |  11.529  |    8.205 |
    | InnoDB, no index   |  19.646  | 2403.297 |
    

    因此,在开始时,有或没有索引的结果是可比较的,即使有索引它们会更好一些。 但是,当我们必须深入研究数据时,结果会发生本质上的变化。使用索引,它们保持大致相同(忽略低值的波动),但没有索引,数据越深入,所需的时间就越长。

    这是意料之中的,这就是原因。 有了索引,无论我们在哪里,UPDATE 仍然只执行一次 key 读取和一次 rnd 读取:

    MySQL [test]> select used, count(*) from codes_myisam_i group by used;
    +------+----------+
    | used | count(*) |
    +------+----------+
    |    0 | 48990000 |
    |    1 |  1010000 |
    +------+----------+
    2 rows in set (12.08 sec)
    
    MySQL [test]> flush status;
    Query OK, 0 rows affected (0.00 sec)
    
    MySQL [test]> update codes_myisam_i set used=1 where used=0 limit 1;
    Query OK, 1 row affected (0.00 sec)
    Rows matched: 1  Changed: 1  Warnings: 0
    
    MySQL [test]> select * from information_schema.session_status where variable_name like 'Handler_read%' and variable_value > 0;
    +------------------+----------------+
    | VARIABLE_NAME    | VARIABLE_VALUE |
    +------------------+----------------+
    | HANDLER_READ_KEY | 1              |
    | HANDLER_READ_RND | 1              |
    +------------------+----------------+
    2 rows in set (0.00 sec)
    

    但是没有索引,它执行的 rnd 读取次数与已更新的行数一样多:

    MySQL [test]> select used, count(*) from codes_myisam group by used;
    +------+----------+
    | used | count(*) |
    +------+----------+
    |    0 | 48990000 |
    |    1 |  1010000 |
    +------+----------+
    Query OK, 0 rows affected (0.00 sec)
    
    MySQL [test]> flush status;
    Query OK, 0 rows affected (0.00 sec)
    
    MySQL [test]> update codes_myisam set used=1 where used=0 limit 1;
    Query OK, 1 row affected (0.09 sec)
    Rows matched: 1  Changed: 1  Warnings: 0
    
    MySQL [test]> select * from information_schema.session_status where variable_name like 'Handler_read%' and variable_value > 0;
    +-----------------------+----------------+
    | VARIABLE_NAME         | VARIABLE_VALUE |
    +-----------------------+----------------+
    | HANDLER_READ_RND_NEXT | 1010001        |
    +-----------------------+----------------+
    1 row in set (0.00 sec)
    

    当然,当我们执行大量单行更新并且每次都必须搜索一行时,这些结果是非常特定于这个特定流程的。因此,显然查找的惩罚超过了更新索引的惩罚。如果我们执行批量更新,情况会完全不同:

    MySQL [test]> update codes_innodb set used = 1 where used = 0 limit 1000000;
    Query OK, 1000000 rows affected (7.80 sec)
    Rows matched: 1000000  Changed: 1000000  Warnings: 0
    
    MySQL [test]> update codes_innodb_i set used = 1 where used = 0 limit 1000000;
    Query OK, 1000000 rows affected (56.91 sec)
    Rows matched: 1000000  Changed: 1000000  Warnings: 0
    
    MySQL [test]> update codes_myisam set used = 1 where used = 0 limit 1000000;
    Query OK, 1000000 rows affected (1.21 sec)
    Rows matched: 1000000  Changed: 1000000  Warnings: 0
    
    MySQL [test]> update codes_myisam_i set used = 1 where used = 0 limit 1000000;
    Query OK, 1000000 rows affected (14.56 sec)
    Rows matched: 1000000  Changed: 1000000  Warnings: 0
    

    当然,使用额外索引更新表比不使用索引更新表要慢很多倍。我认为这就是 cmets 中的混乱的来源。


    更新 2 (关于使用自然 PK 与替代 PK 的说明)

    在 cmets 中提出的另一个反对意见是使用自然主键,而不是代理主键,担心它会影响 InnoDB 的性能。 这是一个类似的快速基准测试。

    测试设置

    与之前的测试相同的环境和服务器。两个InnoDB 表正在使用中。

    第一个和之前一样,自然是PK

           Table: codes_innodb_i
    Create Table: CREATE TABLE `codes_innodb_i` (
      `code` char(17) NOT NULL,
      `used` tinyint(1) DEFAULT '0',
      PRIMARY KEY (`code`),
      KEY `used` (`used`)
    ) ENGINE=InnoDB DEFAULT CHARSET=latin1
    

    另一个带有代理 PK(并且在 code 上具有唯一索引,因为我们仍然希望确保它是唯一的——在第一个表中,PK 本身确保了这一点):

           Table: codes_innodb
    Create Table: CREATE TABLE `codes_innodb` (
      `code` char(17) NOT NULL,
      `used` tinyint(1) DEFAULT '0',
      `pk` int(11) NOT NULL AUTO_INCREMENT,
      PRIMARY KEY (`pk`),
      UNIQUE KEY `code` (`code`),
      KEY `used` (`used`)
    ) ENGINE=InnoDB AUTO_INCREMENT=50000001 DEFAULT CHARSET=latin1
    

    每个表中有 50,000,000 行,相同的数据。

    测试流程

    • 当所有行的used=0 时开始测试;
    • 对每个表执行 10 次后续测试运行。
    • 每次运行是 20 个并发线程,都执行相同的更新,每个线程 250 次,总共 5000 次:

    UPDATE codes_innodb SET used = 1 WHERE used = 0 LIMIT 1

    • 运行之间不更新表,即第一个以所有used=0开头,第二个以表中的5000个used=1开头,以此类推

    每个测试都会测量执行所有查询的总时间。

    结果(以秒为单位)

    |              | Individual results                                           |    Avg |
    |--------------|--------------------------------------------------------------|--------|
    | natural PK   | 8.061,6.782,5.712, 5.524,7.854,6.166,6.095,4.911,4.435,4.784 | 6.0324 |
    | surrogate PK | 9.659,8.981,8.080,11.257,9.621,6.722,6.457,5.937,6.308,6.624 | 7.9646 |
    

    尽管自然的PK 显示了更好的结果,但由于环境没有调整,我不会说自然的PK 在这里更胜一筹,但很可能通过适当的调整服务器并使用更好的环境它会改变。但我们可以看到,使用自然PK 与代理PK 相比,性能没有下降此工作流程。所以,这是个人喜好的问题。

    【讨论】:

    • 更新语句是锁定所有 used = 0 的记录,还是只锁定使用 Limit 1 选择的记录?
    • 不正确。你需要一个唯一的约束。还有为什么要索引一个布尔字段?这完全没有意义。
    • @N.B. code 上的主键是唯一约束。关于索引——在 1000 次更新后,第 1001 次有索引的更新仍需要 1 次索引查找,第 1001 次没有索引的更新将需要 1001 次 rnd 查找。尽管如此,我同意无论该指数是否使其变得更好,都需要对其进行基准测试。我希望它可能会有所帮助。
    • @Terix 更新本身在运行时会占用足够的锁以防止其他更新同时运行(锁会有所不同,具体取决于使用的引擎和表上的索引)。但关键是,它是快速更新和快速查找。行锁有时被高估了,我认为你在追逐它们时会得到更多的开销。即使MySQL suggests 在您的特定情况下,表锁可能更可取(查找“通常,表锁是优越的”,第二点)
    • @N.B.正如您所建议的,我在 50M 表上添加了一个简单测试的结果。如果你保持开放的心态,你可能会发现它们很有趣。此添加仅解决有关索引的问题,并不是要争辩建议的解决方案比另一个更好-很可能不是,至少它显然不是崩溃安全的(它不在不过最初的要求)。
    猜你喜欢
    • 1970-01-01
    • 2016-03-24
    • 1970-01-01
    • 2014-11-28
    • 1970-01-01
    • 2020-07-26
    • 2013-03-05
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多