MySQL:如何使用 InnoDB 锁定来增加非主值,同时防止该值重复?

MySQL: How can I use InnoDB locking to increment a non-primary value while preventing duplication of that value?

如果两个查询同时检查非主键的 MAX(),我想增加一个非主键值而不可能创建重复值。我发现实现此目的的一个好方法是使用 InnoDB 的锁定机制。

我有一个 table 这样的:

tbl
-----------------------
groupID | msgNum | msg
-----------------------
1       | 1      | text
1       | 2      | text
1       | 3      | text
2       | 1      | text
2       | 2      | text

我想插入一个新行并增加该行的 msgNum。我担心的是,如果我使用 MAX(msgNum) 来计算下一个数字,那么两个近乎同时的查询将同时计算 MAX(msgNum) 然后插入相同的 msgNum 两次.所以,我想锁定 table,但只特别锁定可能的最小值,这将锁定计算特定 groupIDMAX(msgNum),同时锁定插入 a 的能力指定 groupID 的新行。理想情况下,我想避免从 table 锁定读取。

一个可能的解决方案是这样的 (SQL Fiddle):

START TRANSACTION;
SELECT * FROM tbl WHERE groupID=1 FOR UPDATE;
INSERT INTO tbl
(groupID,msgNum,msg) VALUES
(1,(SELECT IFNULL(MAX(msgNum)+1,0) FROM (SELECT * FROM tbl WHERE groupID=1) AS a),"text");
COMMIT;

我认为这个解决方案应该可行,但我不确定,在测试之后我 运行 遇到了问题。此外,这是一个很难测试的概念,我想确定一下,这样会更好。我不确定的是锁是否会阻止 INSERT 查询开始,从而阻止其计算 MAX(msgNum).

我确实进行了初步测试:

package main

import (
    "database/sql"
    "fmt"

    _ "github.com/go-sql-driver/mysql"
)

func runTest(sqlCon *sql.DB) {
    _, err := sqlCon.Exec(
        "START TRANSACTION",
    )
    if err != nil {
        fmt.Println(err.Error())
    }
    _, err = sqlCon.Exec(
        "SELECT * FROM tbl WHERE groupID=1 FOR UPDATE",
    )
    if err != nil {
        fmt.Println(err.Error())
    }
    _, err = sqlCon.Exec(
        "INSERT INTO tbl " +
            "(groupID,msgNum,msg) VALUES " +
            "(1,(SELECT IFNULL(MAX(msgNum)+1,0) FROM (SELECT * FROM tbl WHERE groupID=1) AS a),\"text\")",
    )
    if err != nil {
        fmt.Println(err.Error())
    }
    _, err = sqlCon.Exec(
        "COMMIT",
    )
    if err != nil {
        fmt.Println(err.Error())
    }
}

func main() {
    sqlCon, err := sql.Open("mysql", "user1:password@tcp(127.0.0.1:3306)/Tests")
    if err != nil {
        panic(err.Error())
    }
    sqlCon2, err := sql.Open("mysql", "user1:password@tcp(127.0.0.1:3306)/Tests")
    if err != nil {
        panic(err.Error())
    }

    for i := 0; i < 40; i++ {
        fmt.Println(i)
        go runTest(sqlCon)
        go runTest(sqlCon2)
    }

}

我插入了 7 到 52 行,没有重复,但测试没有完成(有 80 行),说 Error 1213: Deadlock found when trying to get lock; try restarting transaction:

$ go run main.go
0
1
2
3
4
5
6
7
8
9
10
11
12
13
Error 1213: Deadlock found when trying to get lock; try restarting transaction
Error 1213: Deadlock found when trying to get lock; try restarting transaction
Error 1213: Deadlock found when trying to get lock; try restarting transaction
Error 1213: Deadlock found when trying to get lock; try restarting transaction
Error 1213: Deadlock found when trying to get lock; try restarting transaction
Error 1213: Deadlock found when trying to get lock; try restarting transaction
Error 1213: Deadlock found when trying to get lock; try restarting transaction
Error 1213: Deadlock found when trying to get lock; try restarting transaction
Error 1213: Deadlock found when trying to get lock; try restarting transaction
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

我不认为您需要事务和显式锁定才能正确 运行。我建议有一个查询 select 是最后一个值,递增它并立即 inserts 一个新行。

我会将查询表述为 insert into ... select 语句:

insert into tbl(groupID, msgNum, msg)
select 1, coalesce(max(msgNum), 0) + 1, 'text'
from tbl
where groupID = 1

您将 运行 此查询启用自动提交。数据库应该能够通过在引擎盖下排队 insert 来为您处理并发,因此这不应产生死锁。


作为一个更普遍的想法:我实际上不会尝试存储 msgNum。这实际上是 派生信息 ,可以在需要时即时计算。您可以只在 table 上有一个自动递增的主键,以及一个计算 msgNum 的视图,使用 window 函数(在 MySQL 8.0 中可用)

create table tbl (
    id int auto_increment primary key,
    groupID int
    msg varchar(50)
);

create view myview as
select 
    groupID,
    row_number() over(partition by groupID order by id) msgNum,
    msg
from tbl

然后您可以使用常规 insert 语句:

    insert into tbl(groupID, msg) values(1, 'text');

优点:

  • 数据库在后台为您管理主键

  • 您的 insert 查询尽可能简单高效(它不需要像其他解决方案那样扫描 table)

  • 该视图为您提供始终最新的数据视角,包括派生信息 (msgNum),维护成本为 0