likes
comments
collection
share

MySQL 避「坑」指南 —— 你能设置出正确的主键吗?

作者站长头像
站长
· 阅读数 18

前言

主键,可以唯一标识表中的某一行(记录)。合理地设置主键,可以帮助我们准确、快速地找到所需要的数据记录。但是设置出正确的主键似乎并没有那么简单,请思考如下几个问题:

  1. 表中的业务字段可以用来做主键吗?
  2. 单机系统下使用自增字段做主键,那在分布式系统下可以吗?
  3. 在分布式系统下如何保证主键的唯一性呢?

如果对以上问题感到困惑,那么我相信这篇文章可以帮助到你。

我坚信,友好的交流会让彼此快速进步!文章难免有疏漏之处,欢迎大家在评论区中批评指正。

为什么一定要给表定义主键?

提高查询效率

前言中提到,主键可以唯一标识表中的某一行(记录),合理地设置主键,可以帮助我们准确、快速找到所需要的数据记录。为什么呢?这是因为给表定义了主键,就相当于给表加了一个主键索引。索引可以帮助提高数据查询的效率,就像书的目录一样。

安全地更新或删除特定行

如果没有为表添加主键,不仅查询效率会变低,更会导致更新或删除表中的特定行很困难,因为没有安全的方法保证只更新或删除相关的行。

如果你使用的图形化管理工具是 Workbench(MySQL 官方图形化管理工具)的话,在执行 UPDATEDELETE 操作时,必须包含 WHERE 条件。而且,WHERE 条件中,必须用到主键约束或唯一性约束的字段,否则会报如下错误。

Error Code: 1175. 
You are using safe update mode and you tried to update a table without a WHERE that uses a KEY column.  
To disable safe mode, toggle the option in Preferences -> SQL Editor and reconnect.

实际案例

现在我们有一个商品信息表,表中字段和样例数据如下所示。

MySQL 避「坑」指南 —— 你能设置出正确的主键吗?

创建商品信息表 sku_info

# 创建商品信息表 sku_info
CREATE TABLE sku_info
(
    pk_id  BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    sku_name   VARCHAR(255) COMMENT '商品名',
    price      DECIMAL(10, 2) COMMENT '商品价格',
    gmt_create DATETIME COMMENT '创建时间',
    gmt_modified DATETIME COMMENT '更新时间'
);

########################

# 查看表结构
DESCRIBE sku_info;

+--------------+-----------------+------+-----+---------+----------------+
| Field        | Type            | Null | Key | Default | Extra          |
+--------------+-----------------+------+-----+---------+----------------+
| pk_id        | bigint unsigned | NO   | PRI | NULL    | auto_increment |
| sku_name     | varchar(255)    | YES  |     | NULL    |                |
| price        | decimal(10,2)   | YES  |     | NULL    |                |
| gmt_create   | datetime        | YES  |     | NULL    |                |
| gmt_modified | datetime        | YES  |     | NULL    |                |
+--------------+-----------------+------+-----+---------+----------------+
5 rows in set (0.04 sec)

《阿里巴巴 Java 开发手册 1.4.0》的部分建表规约如下:

【强制】 表名、字段名必须使用小写字母或数字,禁止出现数字开头,禁止两个下划线中间只出现数字。

【强制】 主键索引名为pk_字段名;唯一索引名为uk_字段名;普通索引名则为idx_字段名。

【强制】 小数类型为 decimal,禁止使用 float 和 double。

【强制】 表必备三字段:id,create_time,update_time。其中 id 必为主键,类型为 bigint unsigned、单表自增、步长为 1。

根据上面这个强制要求,我们也知道了数据表中一定要定义主键

想要了解更多,请参见《阿里巴巴 Java 开发手册 1.4.0》

插入样例数据

# 插入数据
INSERT INTO sku_info
(sku_name, price, gmt_create, gmt_modified) 
VALUES
('书本', 10, '2023-06-01', '2023-06-01'),
('鼠标', 199, '2023-06-01', '2023-06-01');

Query OK, 2 rows affected (0.02 sec)
Records: 2  Duplicates: 0  Warnings: 0

########################

# 查看表数据
SELECT * FROM sku_info;

+-------+----------+----------+---------------------+---------------------+
| pk_id | sku_name | price    | gmt_create          | gmt_modified        |
+-------+----------+----------+---------------------+---------------------+
|     1 | 书本     |  10.00 | 2023-06-01 00:00:00 | 2023-06-01 00:00:00 |
|     2 | 鼠标     | 199.00 | 2023-06-01 00:00:00 | 2023-06-01 00:00:00 |
+-------+----------+----------+---------------------+---------------------+
2 rows in set (0.00 sec)

谨慎使用通配符

除非你明确需要表中所有的列,否则最好别使用 * 通配符。虽然通配符挺省事的,不用明确列出所需要的列,但检索不需要的列通常会降低检索和应用程序的性能。

当然,使用通配符有一个大优点。由于不明确指定列名(通配符 * 检索每个列),所以能检索出名字未知的列

安全模式下特定删除语句报错

在 Workbench 中执行下面任意一条命令都会报错:

# 没有添加 WHERE 条件
DELETE FROM sku_info; 

# WHERE 条件中的字段没有主键约束或唯一性约束
DELETE FROM sku_info WHERE price = 10; 

报错情况如下: MySQL 避「坑」指南 —— 你能设置出正确的主键吗?

具体报错信息如下:

Error Code: 1175. 
You are using safe update mode and you tried to update a table without a WHERE that uses a KEY column.  
To disable safe mode, toggle the option in Preferences -> SQL Editor and reconnect.

第一条语句会删除 sku_info 表中的所有数据记录,第二条语句会删除 sku_info 表中所有价格为 10 的数据记录。这两种操作都会删除大量数据,可能错误地包括不相关的数据,因此被 MySQL 的安全模式予以禁止。

当然,如果你在非安全模式下进行上述两种删除操作是没问题的,比如你直接使用终端去执行删除操作是可以通过的,但是请务必确保不要删除了不相关的数据。

MySQL 避「坑」指南 —— 你能设置出正确的主键吗?

使用主键要注意哪些事情?

主键值必须是的唯一的

表中每行的主键值必须唯一(主键列不允许 NULL 值)。如果主键使用单个列,值必须唯一。如果使用多个列,则这些列的组合值必须唯一。

不要修改主键列中的值

主键值是一条数据记录的唯一标识,如果修改了主键的值,就可能破坏数据的完整性。

如果你需要去修改主键的值,那么很有可能是你的主键设置得不合理。

为什么尽量不要用业务字段做主键?

使用业务字段做主键,由于无法预测项目在整个生命周期中,哪个业务字段会因为业务需求而有重复,或者重用之类的情况,此时需要变更主键,这往往是不能被接受的。

我们举一个实际案例,来看看使用业务字段做主键,当需求发生变更时将会发生什么样的事情。

用案例说明

使用业务字段「会员卡号」做主键

现在有一家零售店,需要进行会员营销,会员信息表字段和样例数据如下所示。

MySQL 避「坑」指南 —— 你能设置出正确的主键吗?

在这里,我们将业务字段「会员卡号」设置成了主键,「会员卡号」本身不能为空,而且具有唯一性,可以用来唯一标识一名会员。

下面我们创建出来这张会员信息表,并把上面的数据插入表中。

创建会员信息表 member_info

CREATE TABLE member_info
(
    pk_card_no CHAR(8) PRIMARY KEY COMMENT '会员卡号', # 会员卡号为主键
    member_name VARCHAR(20) COMMENT '姓名',
    gender CHAR(1) COMMENT '性别',
    pid CHAR(18) COMMENT '身份证号',
    gmt_create DATETIME COMMENT '注册时间',
    gmt_modified DATETIME COMMENT '更新时间'
);

Query OK, 0 rows affected (0.86 sec)

插入样例数据

# 插入样例数据
INSERT INTO member_info
(pk_card_no, member_name, gender, pid, gmt_create, gmt_modified)
VALUES
('11000001', '柿子先生', '男', '123456199902107891', '2023-06-01', '2023-06-01'),
('11000002', '可可小姐', '女', '123456199809077891', '2023-06-01', '2023-06-01');

Query OK, 2 rows affected (0.00 sec)
Records: 2  Duplicates: 0  Warnings: 0

########################

# 查看表中数据
SELECT * FROM member_info;

+------------+--------------+--------+---------------------+---------------------+
| pk_card_no | member_name  | gender | gmt_create          | gmt_modified        |
+------------+--------------+--------+---------------------+---------------------+
| 11000001   | 柿子先生      | 男      | 2023-06-01 00:00:00 | 2023-06-01 00:00:00 |
| 11000002   | 可可小姐      | 女      | 2023-06-01 00:00:00 | 2023-06-01 00:00:00 |
+------------+--------------+--------+---------------------+---------------------+
2 rows in set (0.00 sec)

可以看到,「会员卡号」pk_card_no 可以唯一地标识某个会员,系统可以正常运行。

但是,系统上线后发生了一件事,导致「会员卡号」无法再唯一识别某个会员了。

退卡引发的危机

现在柿子先生搬家了,不再前往该商家消费,于是他退还了会员卡。商家没有扔掉这张会员卡(毕竟有制卡成本),而是把这张卡号为 11000001 的会员卡发给了下一个办理会员卡的用户,六一。

如果只单看这一张表,的确是没有什么影响的,我们只要把会员信息表中卡号为 11000001 的会员信息修改一下不就可以啦,但是从整个系统的业务层面来看,将会出现大问题!

下面,我们来一起看看这个问题是如何发生的?

现在我们有一张订单信息表,上面记录了所有的订单信息。2023-06-02,柿子先生购买了一本书,消费了 10 元,系统会记录该订单信息,如下所示:

MySQL 避「坑」指南 —— 你能设置出正确的主键吗?

创建订单信息表 order_info

订单信息表 order_info 引入了之前的商品信息表 sku_info商品 id,以及会员信息表 member_info会员卡号

我们先把订单信息表创建出来,并插入上面的数据。

# 创建订单信息表
CREATE TABLE order_info
(
    pk_id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    card_no CHAR(8) COMMENT '会员卡号', # 与会员信息表建立联系
    sku_id BIGINT UNSIGNED COMMENT '商品 id', # 与商品信息表建立联系
    sku_price DECIMAL(10,2) COMMENT '商品价格',
    sale_quantity INT UNSIGNED COMMENT '销售数量',
    sale_amount DECIMAL(10,2) COMMENT '销售金额',
    gmt_create DATETIME COMMENT '交易时间',
    gmt_modified DATETIME COMMENT '更新时间'
);

Query OK, 0 rows affected (0.06 sec)

插入样例数据

# 插入样例数据
INSERT INTO order_info
(card_no, sku_id, sku_price, sale_quantity, sale_amount, gmt_create, gmt_modified)
VALUES
('11000001', 1, 10, 1, 10, '2023-06-02', '2023-06-02');

Query OK, 1 row affected (0.00 sec)

########################

# 查看表中的数据
SELECT * FROM order_info;

+-------+----------+--------+-----------+---------------+-------------+---------------------+---------------------+
| pk_id | card_no  | sku_id | sku_price | sale_quantity | sale_amount | gmt_create          | gmt_modified        |
+-------+----------+--------+-----------+---------------+-------------+---------------------+---------------------+
|     1 | 11000001 |      1 |     10.00 |             1 |       10.00 | 2023-06-02 00:00:00 | 2023-06-02 00:00:00 |
+-------+----------+--------+-----------+---------------+-------------+---------------------+---------------------+
1 row in set (0.01 sec)

查询会员销售记录

现在,我们要完成这样的需求,根据商品信息表 sku_info、会员信息表 member_info、订单信息表 order_info 查询出 2023-06-02 当天的会员销售记录,具体形式如下:

MySQL 避「坑」指南 —— 你能设置出正确的主键吗?

为了实现这个查询,我们需要使用关联查询,具体执行语句如下:

SELECT m.member_name, s.sku_name, o.sale_quantity, o.sale_amount, o.gmt_create AS trade_time
FROM order_info AS o  
JOIN sku_info AS s 
JOIN member_info AS m
ON (o.card_no = m.pk_card_no AND o.sku_id = s.pk_id);

+--------------+----------+---------------+-------------+---------------------+
| member_name  | sku_name | sale_quantity | sale_amount | trade_time          |
+--------------+----------+---------------+-------------+---------------------+
| 柿子先生      | 书本      |             1 |       10.00 | 2023-06-02 00:00:00 |
+--------------+----------+---------------+-------------+---------------------+
1 row in set (0.02 sec)

我们查询得到的结果是柿子先生在 2023-06-02 这一天,买了一本书,消费了 10 元

退卡

然后,2023-06-03,柿子先生退还了会员卡,商家又把这张卡发给了六一。那么,我们是不是要修改会员信息表 member_info 中的会员信息。

UPDATE member_info
SET member_name = '六一',
gender = '男',
pid = '123456202203017891',
gmt_modified = '2023-06-03'
WHERE pk_card_no = '11000001';

Query OK, 1 row affected (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 0

查询会员销售记录出现异常

现在我们再查询一下 2023-06-02 当天的会员销售记录:

SELECT m.member_name, s.sku_name, o.sale_quantity, o.sale_amount, o.gmt_create AS trade_time
FROM order_info AS o  
JOIN sku_info AS s 
JOIN member_info AS m
ON (o.card_no = m.pk_card_no AND o.sku_id = s.pk_id);

+-------------+----------+---------------+-------------+---------------------+
| member_name | sku_name | sale_quantity | sale_amount | trade_time          |
+-------------+----------+---------------+-------------+---------------------+
| 六一        | 书本     |             1 |       10.00 | 2023-06-02 00:00:00 |
+-------------+----------+---------------+-------------+---------------------+
1 row in set (0.01 sec)

出现大问题了!查询得到的结果是 2023-06-02,六一买了一本书,消费 10 元。可是,六一 2023-06-03 才拿到会员卡。

很明显,因为退卡重发,柿子先生的消费行为转移到了六一身上,这肯定是无法接受的。

发生这个问题的原因就是,我们把会员卡号是 11000001 的会员信息修改了,而会员卡号是主键,会员消费查询通过会员卡号关联到会员信息,得到了完全错误的结果。

所以,我们千万不可把会员卡号这种业务字段当做主键

身份证号可以当会员信息表的主键吗

那可不可以使用身份证号来做主键呢?身份证号绝不会重复,而且可以与一个人一一对应起来,看起来很适合做主键呢。

但实际上,这种选择是不合适的。一方面,身份证号属于个人隐私,客户不一定会把身份证号给你,所以我们在设计会员信息表时,会允许身份证号这个字段为空。另一方面,身份证号来做主键实在是太长了,过长的主键不仅会占用过多的空间,还会导致查询性能降低,我们会在后面展开来说。

小结

在建表的时候尽量不要用业务字段做主键。毕竟,作为项目设计的技术人员,我们谁都无法预测在项目的整个生命周期中,哪个业务字段会因为项目的业务需求而有重复,或者重用之类的情况出现。

那么,应该使用什么来做主键呢?如果你有仔细观察我定义的商品信息表 sku_info 和订单信息表 order_info的话,你会发现我在这两张表中都定义了自增主键 pk_id在单机系统中,推荐使用自增字段做主键

单机系统推荐使用自增字段做主键

不仅是《阿里巴巴 Java 开发手册》中提到建表语句里一定要使用自增主键,在很多建表规范中都有提及。下面,我们就来看看给表加上自增字段是如何解决上一节中出现的问题的。

修改表结构

对于上一小节中出现的问题,我们只要在会员信息表 member_info中添加一个自增字段 pk_id 来做主键就可以解决了。

修改会员信息表 member_info

首先,我们要修改一下会员信息表的结构,添加自增字段 pk_id 做主键。

第一步,先删除会员信息表的主键约束(删除主键约束,并不会删除字段)。

ALTER TABLE member_info
DROP PRIMARY KEY;

Query OK, 2 rows affected (0.13 sec)
Records: 2  Duplicates: 0  Warnings: 0

第二步,重新命名会员卡号,取消 pk_ 前缀。

ALTER TABLE member_info
CHANGE pk_card_no card_no CHAR(8) NOT NULL COMMENT '会员卡号';

Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

第三步,添加自增字段 pk_id 为主键。

ALTER TABLE member_info
ADD pk_id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT;

Query OK, 0 rows affected (0.11 sec)
Records: 0  Duplicates: 0  Warnings: 0

将自增字段定义为无符号,不仅可以避免误存负数,还扩大了表示范围。

现在我们来查看一下会员信息表的表结构。

DESCRIBE member_info;

+--------------+-----------------+------+-----+---------+----------------+
| Field        | Type            | Null | Key | Default | Extra          |
+--------------+-----------------+------+-----+---------+----------------+
| card_no      | char(8)         | NO   |     | NULL    |                |
| member_name  | varchar(20)     | YES  |     | NULL    |                |
| gender       | char(1)         | YES  |     | NULL    |                |
| pid          | char(18)        | YES  |     | NULL    |                |
| gmt_create   | datetime        | YES  |     | NULL    |                |
| gmt_modified | datetime        | YES  |     | NULL    |                |
| pk_id        | bigint unsigned | NO   | PRI | NULL    | auto_increment |
+--------------+-----------------+------+-----+---------+----------------+
7 rows in set (0.00 sec)

修改订单信息表 order_info

接下来,我们要给订单信息表 order_info,添加一个新的字段 member_id,用于对应会员信息表 member_info 中的主键。

ALTER TABLE order_info
ADD member_id BIGINT UNSIGNED;

Query OK, 0 rows affected (0.15 sec)
Records: 0  Duplicates: 0  Warnings: 0

########################

# 查看修改后的表结构
DESCRIBE order_info;

+---------------+-----------------+------+-----+---------+----------------+
| Field         | Type            | Null | Key | Default | Extra          |
+---------------+-----------------+------+-----+---------+----------------+
| pk_id         | bigint unsigned | NO   | PRI | NULL    | auto_increment |
| card_no       | char(8)         | YES  |     | NULL    |                |
| sku_id        | bigint unsigned | YES  |     | NULL    |                |
| sku_price     | decimal(10,2)   | YES  |     | NULL    |                |
| sale_quantity | int unsigned    | YES  |     | NULL    |                |
| sale_amount   | decimal(10,2)   | YES  |     | NULL    |                |
| gmt_create    | datetime        | YES  |     | NULL    |                |
| gmt_modified  | datetime        | YES  |     | NULL    |                |
| member_id     | bigint unsigned | YES  |     | NULL    |                |
+---------------+-----------------+------+-----+---------+----------------+
9 rows in set (0.06 sec)

最后,我们还要再对订单信息表 order_info 进行修改,给新添加的 member_id 字段赋值,让它指向对应的会员信息。

# 给新添加的 member_id 字段赋值,让它指向对应的会员信息
UPDATE order_info AS o, member_info AS m
SET o.member_id = m.pk_id
WHERE o.pk_id > 0
AND o.card_no = m.card_no;

Query OK, 1 row affected (0.05 sec)
Rows matched: 1  Changed: 1  Warnings: 0

########

# 查看更新后的订单信息表 `order_info` 内的数据
SELECT * FROM order_info;

+-------+----------+--------+-----------+---------------+-------------+---------------------+---------------------+-----------+
| pk_id | card_no  | sku_id | sku_price | sale_quantity | sale_amount | gmt_create          | gmt_modified        | member_id |
+-------+----------+--------+-----------+---------------+-------------+---------------------+---------------------+-----------+
|     1 | 11000001 |      1 |     10.00 |             1 |       10.00 | 2023-06-02 00:00:00 | 2023-06-02 00:00:00 |         1 |
+-------+----------+--------+-----------+---------------+-------------+---------------------+---------------------+-----------+
1 row in set (0.01 sec)

看到这里,可能有读者会问,可以先删除订单信息表 order_info,重建表,再插入数据吗?不建议这样做,虽然这样可以达到同样的目的。

考虑这样的情况,order_info 表中存储了大量重要的数据,此时是不能去删除表的。最好的方法就是用 WHERE 条件去更新指定的记录。

解决退卡危机

恢复会员信息表 member_info 数据

为了复现这种情况,我们先恢复会员信息表中会员卡号 11000001 为柿子先生的个人信息。

# 恢复会员信息表中会员卡号 11000001 为柿子先生的个人信息
UPDATE member_info
SET member_name = '柿子先生',
gender = '男',
pid = '123456199902107891',
gmt_create = '2023-06-01',
gmt_modified = '2023-06-01'
WHERE card_no = '11000001';

Query OK, 1 row affected (0.04 sec)
Rows matched: 1  Changed: 1  Warnings: 0

########################

# 查看恢复后的 member_info 表中的数据
SELECT * FROM member_info;

+----------+--------------+--------+--------------------+---------------------+---------------------+-------+
| card_no  | member_name  | gender | pid                | gmt_create          | gmt_modified        | pk_id |
+----------+--------------+--------+--------------------+---------------------+---------------------+-------+
| 11000001 | 柿子先生     | 男     | 123456199902107891 | 2023-06-01 00:00:00 | 2023-06-01 00:00:00 |     1 |
| 11000002 | 可可小姐     | 女     | 123456199809077891 | 2023-06-01 00:00:00 | 2023-06-01 00:00:00 |     2 |
+----------+--------------+--------+--------------------+---------------------+---------------------+-------+
2 rows in set (0.00 sec)

再一次退卡

这次,柿子先生退换会员卡 11000001,商家再把这张卡发给六一,我们只要在会员信息表 member_info 中添加一条记录就可以了。

# 在会员信息表 member_info 中添加一条记录
INSERT INTO member_info
(card_no, member_name, gender, pid, gmt_create, gmt_modified)
VALUES
('11000001', '六一', '男', '123456202203017891', '2023-06-01', '2023-06-01');

Query OK, 1 row affected (0.03 sec)

########################

# 查看会员信息表中的信息
SELECT * FROM member_info;

+----------+--------------+--------+--------------------+---------------------+---------------------+-------+
| card_no  | member_name  | gender | pid                | gmt_create          | gmt_modified        | pk_id |
+----------+--------------+--------+--------------------+---------------------+---------------------+-------+
| 11000001 | 柿子先生     | 男     | 123456199902107891 | 2023-06-01 00:00:00 | 2023-06-01 00:00:00 |     1 |
| 11000002 | 可可小姐     | 女     | 123456199809077891 | 2023-06-01 00:00:00 | 2023-06-01 00:00:00 |     2 |
| 11000001 | 六一         | 男     | 123456202203017891 | 2023-06-01 00:00:00 | 2023-06-01 00:00:00 |     3 |
+----------+--------------+--------+--------------------+---------------------+---------------------+-------+
3 rows in set (0.04 sec)

因为会员卡号 card_no 不再是主键了,可以允许重复,因此,我们就可以在保留会员「柿子先生」信息的同时,添加使用同一会员卡号的「六一」的信息。

查询会员销售记录正常

现在,我们再来查询会员的销售记录,就会发现一切正常啦。

SELECT m.member_name, s.sku_name, o.sale_quantity, o.sale_amount, o.gmt_create AS trade_time
FROM order_info AS o
JOIN member_info AS m
JOIN sku_info AS s
ON (o.member_id = m.pk_id AND o.sku_id = s.pk_id);

+--------------+----------+---------------+-------------+---------------------+
| member_name  | sku_name | sale_quantity | sale_amount | trade_time          |
+--------------+----------+---------------+-------------+---------------------+
| 柿子先生     | 书本     |             1 |       10.00 | 2023-06-02 00:00:00 |
+--------------+----------+---------------+-------------+---------------------+
1 row in set (0.01 sec)

可以看到,查询结果是 2023-06-02 柿子先生买了一本书,消费 10 元,是正确的。

新的挑战:更多的连锁店

我们的超市经过一段时间发展变得越来越好了,接下来我们要开几家连锁店。

发展新会员的过程一般是在门店进行的,人们总是习惯在购物结账时申请会员。因此,连锁店的信息系统需要添加新会员的功能,把新会员信息先存放到本地 MySQL 数据库中,再上传到总部进行汇总。

可是问题来了,我们的会员信息表的主键都是自增的,那么各个门店新加的会员就会出现「id」冲突的可能

比如,A 店和 B 店的会员信息表最大的 pk_id 都是 100,各自新增了一个会员,pk_id 都变成了 101。然后 A 店和 B 店把新会员信息都上传到了总部,此时麻烦出现了,两个 pk_id 都是 101,但却是不同的会员。要如何处理这个问题呢?

分布式系统应使用全局唯一 ID

上一节的最后提出的问题,其实就是在分布式系统中如何保证数据记录具有唯一标识,这就不得不提到分布式 ID 了。分布式 ID 可以为不同数据节点的数据记录生成全局唯一标识

本小节会先介绍一种基于数据库主键自增的分布式 ID 生成方案,更多的分布式 ID 解决方案的介绍,我将在另一篇文章详细展开。

总部数据库自增生成分布式 ID

我们可以取消会员信息表 member_info 的主键 pk_id 的自增属性,改成在添加新会员时对 pk_id 赋值。

然后,在总部数据库系统中,专门准备一张表 sequence_id_generator 用于生成全局唯一 ID。当门店需要添加会员的时候,要先到总部的这张表中,更新 pk_id 的值为最大值 + 1,并将新的值作为新会员的 pk_id 值。

如此一来,各个门店添加会员的时候,都从同一个总部的 sequence_id_generator 表中获取新会员的 pk_id 值,解决了各门店添加会员时会员编号冲突的问题,同时也避免了使用业务字段导致数据错误的问题。

创建生成分布式 ID 的表 sequence_id_generator

# 创建表
CREATE TABLE `sequence_id_generator`
(
    `pk_id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    `cert` VARCHAR(255) NOT NULL UNIQUE KEY DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

########################

# 查看表结构
DESCRIBE sequence_id_generator;
+-------+-----------------+------+-----+---------+----------------+
| Field | Type            | Null | Key | Default | Extra          |
+-------+-----------------+------+-----+---------+----------------+
| pk_id | bigint unsigned | NO   | PRI | NULL    | auto_increment |
| cert  | varchar(255)    | NO   | UNI |         |                |
+-------+-----------------+------+-----+---------+----------------+
2 rows in set (0.01 sec)

pk_id 是自增主键,用于指明当前最大的会员编号。cert是业务凭证字段,创建了唯一索引,保证其唯一性,用于不同业务插入或修改数据。

获取新会员的 id 值

当我们想要获取新会员的 id 值时,需要执行显式事务(事务中的语句必须全部执行,一条失败则全部回滚)来获取,具体如下所示:

BEGIN;
REPLACE INTO sequence_id_generator (cert) VALUES ('member_info');
SELECT LAST_INSERT_ID(); # 获取表中最大的自增值
COMMIT;

使用 REPLACE INTO 插入数据的流程如下:

第一步:尝试将数据插入到表中;

第二步:如果主键或唯一索引字段出现重复数据错误而插入失败时,先从表中删除含有重复关键字值的冲突行,然后再次尝试把数据插入到表中。

模拟获取新会员的 id 值

下面的动图是使用该解决方案模拟两家门店添加会员时,从总部获取新会员 id 值。

MySQL 避「坑」指南 —— 你能设置出正确的主键吗?

起初,sequence_id_generator 表中 pk_id 的最大值为 5,表明现在所有门店中最大的会员 id 值为 5。

现在 A 门店(左边)要获取新会员的 id 值,开始执行事务 A,与此同时 B 门店(右边)也要获取新会员的 id 值,开始执行事务 B。

由于事务 A 先执行了 REPLACE INTO 的插入操作,事务 B 只能阻塞等待。

事务 A 执行 SELECT LAST_INSERT_ID(); 成功获取到了新会员的 id 值为 6,然后执行 COMMIT; 提交事务。

然后事务 B 开始执行 REPLACE INTO 操作与 SELECT LAST_INSERT_ID(); 成功获取到了新会员的 id 值为 7,然后执行 COMMIT; 提交事务。

如此一来,A 门店和 B 门店新添加的会员 id 值都是唯一的,成功解决了会员 id 值冲突的问题。

方案的优缺点

优点

实现简单,会员的 id 值是有序递增的,占用的存储空间少

缺点

  1. 一个非常明显的缺点,并发能力很差。一个事务执行时,另一个事务会被阻塞。

  2. 存在安全问题,试想一下如果这个 id 唯一标注的是订单呢?根据 id 的递增规律就可以推算出每天的订单量,会泄露商业机密!

  3. 每次获取 id 都要访问一次数据库,增加了对数据库的压力,获取速度也慢。

  4. 存在数据库单点宕机风险。所有门店添加新会员都要访问总部的数据库,当门店数量很多时,总部数据库很容易就宕机了。

  5. 后期可以采用水平扩展的数据库集群,并通过规定 id 的起始值和自增步长的方式来解决数据库单点压力问题。虽然这种解决方案解决了单点问题,但仍然存在缺陷,不利于后续扩容,直接使用数据库抗流量,无法满足高并发场景。

我们已经通过一个简单的解决方案初步了解了分布式 ID,下面开始正式介绍它。

总结

今天,和大家一起探讨了如何给数据表设置出正确的主键,介绍了给表定义主键的好处,使用主键要注意的事情。

重点强调了尽量不要使用业务字段做主键,因为无法预测未来会不会因为业务需要,而出现业务字段重复或者重用的情况。

在单机系统中,推荐使用自增字段做主键。但是,如果有多台服务器(分布式系统),各自录入数据,那就无法适用了。因为如果需要合并每台服务器录入的数据,可能出现主键重复的问题。

分布式 ID 可以很好地解决这个问题,我们介绍了一种简单的方案,在总部的数据库中专门有一个负责生成全局唯一 ID 的表,所有门店想要添加新会员的时候要从总部的这张表中获取 ID 值,这样就可以保证所有门店新添加的会员都有全局唯一的 ID 了。

如果以上内容有帮助到你,希望点赞收藏加关注,您的鼓励和帮助是我更新的动力!以上就是全部内容,我们下篇文章再会!

参考资料

  1. 《MySQL 必知必会》 —— Ben Forta
  2. 《MySQL 必知必会》 —— 朱晓峰
  3. 《MySQL 45 讲》 —— 林晓斌
  4. 《阿里巴巴 Java 开发手册 1.4.0》
  5. MySQL 官方文档
  6. JavaGuide
  7. 廖雪峰的官方网站
转载自:https://juejin.cn/post/7240872478780178491
评论
请登录