前言
主键,可以唯一标识表中的某一行(记录)。合理地设置主键,可以帮助我们准确、快速地找到所需要的数据记录。但是设置出正确的主键似乎并没有那么简单,请思考如下几个问题:
- 表中的业务字段可以用来做主键吗?
- 单机系统下使用自增字段做主键,那在分布式系统下可以吗?
- 在分布式系统下如何保证主键的唯一性呢?
如果对以上问题感到困惑,那么我相信这篇文章可以帮助到你。
我坚信,友好的交流会让彼此快速进步!文章难免有疏漏之处,欢迎大家在评论区中批评指正。
为什么一定要给表定义主键?
提高查询效率
前言中提到,主键可以唯一标识表中的某一行(记录),合理地设置主键,可以帮助我们准确、快速找到所需要的数据记录。为什么呢?这是因为给表定义了主键,就相当于给表加了一个主键索引。索引可以帮助提高数据查询的效率,就像书的目录一样。
安全地更新或删除特定行
如果没有不为表添加主键,不仅查询效率会变低,更会导致更新或删除表中的特定行很困难,因为没有安全的方法保证只更新或删除相关的行。
如果你使用的图形化管理工具是 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 了。