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

前言

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

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

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

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

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

提高查询效率

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

安全地更新或删除特定行

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

如果你使用的图形化管理工具是 Workbench(MySQL 官方图形化管理工具)的话,在执行 UPDATE 或 DELETE 操作时,必须包含 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.

实际案例

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

创建商品信息表 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;

报错情况如下:

具体报错信息如下:

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 的安全模式予以禁止。

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

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

主键值必须是的唯一的

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

不要修改主键列中的值

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

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

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

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

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

用案例说明

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

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

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

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

创建会员信息表 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 元,系统会记录该订单信息,如下所示:

创建订单信息表 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 当天的会员销售记录,具体形式如下:

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

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 值。

起初,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 值是有序递增的,占用的存储空间少

缺点

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

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

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

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

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

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

总结

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

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

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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值