有关事务的面试题
(1) 事务的特性
- 原子性:是指事务包含所有操作要么全部成功,要么全部失败回滚。
- 一致性:指事务必须使数据库从一个一致性状态变换成另一个一致性状态,也就是说一个事务执行之前和执行之后都必须处于一致性状态。
拿转账来说,假设用户 A 和用户 B 两者的钱加起来一共是 5000,那么不管 A 和 B 之间如何转账,转几次账,事务结束后两个用户的钱相加起来应该还得是 5000,这就是事务的一致性。 - 隔离性:是当多个用户并发访问数据库时,比如操作同一张表时,数据表为每个用户开启的事务,不能被其他事务所干扰,多个并发事务之间要相互隔离。
- 持久性:持久性是指一个事务一旦被提交,那么对数据库中的数据的改变就是永久的,即便是在数据库系统遇到故障的性况下也不会丢失提交事务的操作。
(2) 并发操作问题
- 脏读:脏读是指在一个事务处理过程中读取到了另外一个未提交事务中的数据。
- 不可重复读:不可重复读是指在对于数据库中的某个数据,一个事务范围内多次查询却返回了不同的数据值,这是由于在查询间隔,被另一个事务修改并提交了。
- 虚读(幻读):幻读发生在当两个完全相同的查询执行时,第二次查询所返回的结果集跟第一个查询不相同。
比如两个事务操作,A 事务查询状态为 1 的记录时,这时 B 事务插入了一条状态为 1 的记录,A 事务再次查询返回的结果不一样。
(3) 事务的隔离级别
- Serializable(串行化):可避免脏读、不可重复读、幻读。(就是串行化读数据)
- Repeatable read(可重复读):可避免脏读、不可重复读的发生。
- Read committed(读已提交):可避免脏读的发生。
- Read uncommitted(读未提交):最低级别,任何情况都无法保证。
在 MySQL 数据库中,支持上面四种隔离级别,默认的为 Repeatable read (可重复读);而在 Oracle 数据库中,只支持 Serializable (串行化)级别和 Read committed (读已提交)这两种级别,其中默认的为 Read committed 级别。
数据库索引
索引是表的目录,在查找内容之前可以先在目录中查找索引位置,以此快速定位查询数据。对于索引,会保存在额外的文件中。
索引是数据库中专门用于帮助用户快速查询数据的一种数据结构,类似于字典中的目录,查找字典内容时可以根据目录查找到数据的存放位置,然后直接获取即可。
- 索引由数据库中一列或多列组合而成,其作用是提高对表中数据的查询速度。
- 索引的优点是可以提高检索数据的速度。
- 索引的缺点是创建和维护索引需要耗费时间。
- 索引可以提高查询速度,会减慢写入速度。
索引本身也很大,不可能全部存储在内存中,因此索引往往索引文件的形式存储在磁盘上。索引查找过程中就要产生磁盘 IO 消耗,相对于内存存取,IO 存取的消耗要高几个数量级。所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘 I/O 操作次数的渐进复杂度。(换句话说,索引的结构组织要尽量减少查找过程中磁盘 I/O 的存取次数。)
(1) 索引的种类
B-tree、Hash、R-Tree、全文索引、主键索引、普通索引等。在 mysql 中索引是在存储引擎层而不是服务器层实现的,所以没有统一的索引标准。
- 普通索引:仅加速查询
- 唯一索引:加速查询+列值唯一。(可以有 null)
- 主键索引:加速查询+列值唯一+表中只有一个(不可以有 null)
- 组合索引:多列值组成一个索引。专门用于组合搜索,其效率大于索引合并。
- 全文索引:对文本的内容进行分词,进行搜索。
B-Tree 索引:
B-Tree 不管叶子节点还是非叶子节点,都会保存数据,这样导致在非叶子节点中能保存的指针数量变少,指针少的情况下要保存大量数据,只能增加树的高度,导致 IO 操作变多,查询性能变低。而每一个页的存储空间是有限的,如果 data 数据较大时将会导致每个节点(一个页)能存储的 key 的数量很小,当存储的数据量很大时同样会导致 B-Tree 的深度较大,增大查询时的磁盘 IO 次数,进而影响查询效率。
在 B+Tree 中,所有数据记录节点都是按照键值大小顺序存放在同一层的叶子节点上,而非叶子节点上只能存储 key 值信息,这样可以大大加大每个节点存储的 key 值数量,降低 B+Tree 的高度。
数据库中的 B+Tree 索引可以分为聚集索引(clustered index)和辅助索引(secondary index)。上面的 B+Tree 示例图在数据库中的实现即为聚集索引,聚集索引的 B+Tree 中的叶子节点存放的是整张表的行记录数据。辅助索引与聚集索引的区别在于辅助索引的叶子节点并不包含行记录的全部数据,而是存储相应行数据的聚集索引键,即主键。当通过辅助索引来查询数据时,InnoDB 存储引擎会遍历辅助索引找到主键,然后再通过主键在聚集索引中找到完整的行记录数据。
hash 索引
在进行查询操作时,使用 hash 索引效率很高。因此,当使用一个语句去比较字符中,然后返回结果集。这样的操作使用 hash 索引是很快的。
在字符串上创建 Hash 索引非常好,列值将插入到 Hash 表中和一个键对应,并和实际的数据行有一个映射关系,也就是该键是一个指向表中数据行的指针。Hash 表实际是基于关联数组,假如有一个这样的语句:“boyce = 0×28936”其中 0×28936 是关联到存储在内存中的 boyce。在 hash 表索引中查找 boyce 的值并返回内存中的数据,要比检索整个表的列值要快得多。
Hash 表不能进行排序的数据结构,Hash 表擅长的是键值对,也就是说,检索语句检查相等性。在 Hash 表中键值是没有排序的,在存储的时候也没有任何的排序规则。因为 hash 索引不够灵活,所以,hash 索引不是默认的索引的数据结构。
不是按照索引顺序存储,不能用于排序。
不支持部分索引列匹配查找。
支持等值查询,不支持范围查询。
哈希值冲突多时,不适用。
全文索引
全文索引是一种特殊类型的索引,它查找的是文本中的关键词,而不是直接比较索引中的值。全文索引和其他几种索引的匹配方式完全不一样,它更类似于搜索引擎做的事情,而不是简单的 where 条件匹配。可以在相同的列上,同时创建全文索引和 B-Tree 索引,全文索引适用于 Match Against 操作,而不是普通的 where 条件操作。
索引可以包含一个列(即字段)或多个列的值。如果索引包含多个列,一般会将其称作复合索引,此时,列的顺序就十分重要,因为 MySQL 只能高效的使用索引的最左前缀列。创建一个包含两个列的索引,和创建两个只包含一列的索引是大不相同的。
(2) B+Tree 索引
在 B+Tree 的每个叶子节点增加一个指向相邻叶子节点的指针,就形成了带有顺序访问指针的 B+Tree。做这个优化的目的是为了提高区间访问的性能,例如图中如果要查询 key 为从 18 到 49 的所有数据记录,当找到 18 后,只需顺着节点和指针顺序遍历就可以一次性访问到所有数据节点,极大提到了区间查询效率。
- B+Tree 它的非叶子节点不存储数据,只存储索引,而数据会存放在叶子节点中。
- B+Tree 规定:所有的叶子结点包含了全部关键字信息,以及指向含这些关键字记录的指针,且叶子结点本身依关键字的大小自小而大顺序链接。
在 InnoDB 存储引擎中,数据存放的方式是以页的方式进行存放,计算机在存储数据的时候,有最小存储单元,就是最小数据扇区,一个扇区的大小是 512 字节,而文件系统(ext4)他的最小单元是块,一个块的大小是 4k,而对于我们的 InnoDB 存储引擎也有自己的最小存储单元--页。一个页的大小是 16K。在 InnoDB 中默认的数据页的大小是 16K。
数据表中的数据都是存储在页中的,所以一个页中能存储多少行数据呢?假设一个数据的大小是 1K,那么一个页可以存放 16 行这样的数据。
- InnoDB 存储引擎的最小存储单元是页,页可以用于存放数据也可以用于存放键值+指针,**在 B+树中叶子节点存放数据,非叶子节点存放键值+指针**。
- 索引组织表通过非叶子节点的二分查找法以及指针确定数据在哪个页中,进页在云数据页中查找到需要的数据。
例如:
假设 B+树高为 2,即存在一个根节点和若干个叶子节点,那么这棵 B+树的存放总记录数为:根节点指针数*单个叶子节点记录行数。
假设一行数据大小为:1KB,那么一页(16KB)中可以存放 16 行数据。
假设主键 ID 为 bigint 类型,长度为 8 字节,而指针大小在 InnoDB 源码中设置为 6 字节,这样一共 14 个字节。(8+6 的意思是 B+Tree 有 key 和指针)
16KB * 1024(化成字节) / 14 =1170(一个节点可以存放页的指针数据)
那么可以算出一棵高度为 2 的 B+树,能存放 1170*16=18720 行 解释:1170 表示一个节点能够创建的指针数,而 16 表示,一个存放数据的页可以存放 16 行数据。*
3 阶 B+树可以存放 1170_1170_16=2 千万(左右)
所在在 InnoDb 中 B+树高度一般为 1-3 层,它就能满足千万级的数据存储,在查找数据时一次页的查找代表一次 IO,所以通过主键索引查询通常只需要 1-3 次 IO 操作即可查找到数据。
在没有加数据记录大小的情况下:
InnoDB 存储引擎中页的大小为 16KB,一般表的主键类型为 INT(占用 4 个字节)或 BIGINT(占用 8 个字节),指针类型也一般为 4 或 8 个字节,也就是说一个页(B+Tree 中的一个节点)中大概存储 16KB/(8B+8B)=1K 个键值(因为是估值,为方便计算,这里的 K 取值为〖10〗^3)。也就是说一个深度为 3 的 B+Tree 索引可以维护 10^3 10^3 10^3 = 10 亿 条记录。
(3) InnoDB 索引实现
InnoDB 的数据文件本身就是索引文件,InnoDB 中,表数据文件本身就是按 B+Tree 组织的一个索引结构,这棵树的叶节点 data 域保存了完整的数据记录,这个索引的 key 是数据表的主键。InnoDB 表都必须有主键,如果没有定义主键,会默认添加一个主键。
一级索引(主键):
- 如果一个主键被定义了,那么这个主键就是作为聚焦索引。
- 如果没有主键被定义,那么该表的第一个唯一非空索引被作为聚焦索引。
- 如果没有主键也没有合适的唯一索引,那么 InnoDB 内部会生成一个隐藏的主键作为聚焦索引,这个隐藏的主键是一个 6 个字节的列,该列的值会随着数据的插入自增。
二级索引:
- 以所在的列建立二级索引,然后二级索引的 data 域则为主键。
(4) MyISAM 索引实现
MyISAM 索引文件和数据文件是分离的,索引文件仅保存记录所在页的指针,通过这些地址来读取页,进而读取被索引的行。
(5) InnoDB 与 MyISAM 在 B+Tree 索引的区别
第一重大的区别是 InnoDB 的数据文件本身就是索引文件,MyISAM 索引文件和数据文件是分离的,索引文件仅仅保存数据记录的地址,而在 InnoDB 中,表数据文件本身就是按 B+Tree 组织的一个索引结构。这棵树的叶节点 data 域保存了完整的数据记录,这个索引的 key 是数据表的主键,因此 InnoDB 表数据文件本身就是主索引。因为 InnoDB 的数据文件本身要按主键聚集,所以 InnoDB 要求表必须有主键(MyISAM 可以没有)如果没有显示指定,则 MySQL 系统会自动选择一个可以唯一标识数据的列作为主键,如果不存在这种列,则 MySQL 自动为 InnoDB 表生成一个隐含字段作为主键,这个字段长度为 6 个字节,类型为长整型。
第二个与 MyISAM 索引的不同是 InnoDB 的辅助索引 DATA 域存储相应记录主键的值而不是地址,InnoDB 的所有辅助索引都引用主键作为 data 域。
聚集索引这种实现方式使得按主键的搜索十分高效,但是辅助索引搜索需要检索两遍索引:首先检索辅助索引获得主键,然后用主键到主索引中检索获得记录。
(6) 有一道 MySQL 的面试题,为什么 MySQL 的索引要使用 B+树而不是其它树形结构?比如 B 树?
因为 B 树不管叶子节点还是非叶子节点,都会保存数据,这样导致在非叶子节点中能保存的指针数量变少(有些资料也称为扇出),指针少的情况下要保存大量数据,只能增加树的高度,导致 IO 操作变多,查询性能变低;红黑树 BST 的时间复杂度是依赖于树的高度,但是,红黑树的高度与 Btree 相比,高度更大。
(7) 聚集索引与非聚集索引
聚集索引:也叫聚簇索引,聚集索引表记录的排列顺序和索引的排列顺序一致,所以查询效率快,只要找到第一个索引值索引,其余就连续性记录在物理也一样连续存放,聚集索引对应的缺点就是修改慢,因为为了保证表中记录的物理和索引顺序一致,在记录插入的时候会对数据页重新排序(InnoDB 的 B+树)。
非聚集索引制定了表中记录的逻辑顺序,但是记录的物理和索引不一定一致,两种索引都采用 B+Tree 结构,非聚集索引的叶子层并不和实际数据页相重叠,而采用叶子层包含一个指向表中的记录在数据页中的指针方式,非聚集索引层次多,不会造成数据重排。
Mysql 存储引擎
在 Mysql 将每个数据库(Schema)保存为数据目录下的一个子目录。创建表时,Mysql 会在数据库子目录下创建一个和表同名的.frm 文件保存表的定义。
有关:show table status like 'user'查看 user 表的相关信息:
(1) InnoDB 存储引擎:Mysql 的默认事务型引擎
InnoDB 使用的是行级锁,但实际是有限制的,只有在你增删改查时匹配的条件字段带有索引时,InnoDB 才会使用行级锁,在你增删改查时匹配的条件字段不带有索引时。InnoDB 使用的将是表级锁。
InnoDB 是新版本 mysql 的默认引擎,支持事务处理和外键,但是其缺点就是慢了些,存储方式分为两种
1、共享表空间存储。这种方式创建的表的表结构保存在.frm 文件中,数据和索引保存在 innodb_data_home_dir 和 innodb_data_file_path 定义的表空间中,可以是多个文件。
2、多表空间存储。(.frm 表结构和 idb 数据。) 这种方式创建的表的表结构仍然保存在.frm 文件中,但是每个表的数据和索引单独保存在.ibd 中。如果是个分区表,则每个分区对应单独的.ibd 文件,文件名是“表名+分区名”,可以在创建分区的时候指定每个分区的数据文件的位置,以此来将表的 IO 均匀分布在多个磁盘上。
使用 engine=innodb default charset=utf-8;
(2) MyISAM
MyISAM 存储引擎是旧版本 mysql 的默认引擎,现在默认引擎是 InnoDB,MyISAM 引擎的主要特点就是快,没有事务处理操作,也不支持外键操作。适合 insert 与 select 的操作表。MyISAM 存储引擎的表在数据库中,每一个表都被存放为三个以表名命名的物理文件。定义表结构.frm,存放表数据.myd 和索引数据.myi。使用方法:engine=myisam default charset=utf-8;
其中 MyISAM 支持以下三种类型的索引;
- B-Tree 索引,就是所有的索引节点都按照 B-Tree 的数据结构来存储,所有的索引数据节点都在叶节点。
- R-Tree 索引
- Full-Text 索引,全文索引,它的存储结构也是 B-Tree。主要是为了解决在我们需要用 like 查询的低效问题。
(3) MEMORY
存储引擎使用存在内存中的内容来创建表,每个 Memory 表只实现对应一个磁盘文件,格式是.frm(只保存表结构,不保存内容)。Memory 类型的表访问非常快,因为它的数据是放在内存中的,并且默认使用 Hash 索引,但是一旦服务关闭,表中的数据就会丢失掉。
CREATE TABLE tab_memory ENGINE=MEMORY
给 Memory 表创建索引的时候,可以指定使用 Hash 索引还是 BTree 索引。
Create index mem_hash using HASH on table_memory(city_id);
(4) InnoDB 与 MyISAM 区别
InnoDB 和 MyISAM 是许多人在使用 MySQL 时最常用的两个表类型,MyISAM 不支持事务处理等高级处理,而 InnoDB 类型支持。MyISAM 类型的强调的是性能。其执行速度比 InnoDB 类型更快,但是不提供事务类型支持。而 InnoDB 提供事务支持和外键。
联合索引
把联合索引单独拿出来是因为去滴滴面试的时候被问到过。
(1) 联合索引的规则
联合索引可以将多个字段组合创建一个索引,在查询 where 条件中使用组合索引时,它符合最左匹配规则。例如为 A,B,C 三列创建索引,则它支持 A/A,B/A,B,C 而 B,C 则无法使用组合索引。
当一个列存在联合索引和单列索引时,mysql 会根据查询语句的成本来选择走哪条索引。
(2) 查辅助索引时需要查几次索引
需要两次:在前面的索引一节我们详细的介绍过辅助索引的结构,辅助索引的 B+Tree 结点的叶子结点存放的是一级索引的值,所以查到一级索引时,它会再查询一次一级索引。
Mysql 读写分离
(1) 应用场景
读写分离主要解决的是数据库的写操作是比较耗时的,而数据库的读取则是非常快的,所以读写分离来解决_数据库的写入,影响了查询的效率。_
读写分离的好处
- 分摊服务器压力,提高机器的系统处理效率。读写分离适用于读远比写的场景,如果有一台服务器,当 select 很多时,update 和 delete 会被这些 select 访问中的数据堵塞,等待 select 结束,并发性能并不高,而主从只负责各自的写和读,极大程度的缓解 X 锁和 S 锁争用;
- 增加冗余,提高服务可用性,当一台数据库服务器宕机后可以调整另外一台从库以最快速度恢复服务
(2) 原理分析
主库将插入、更新或删除的记录写入到了 binlog 日志,然后从库连接到主库之后,从库有一个 IO 线程,将主库的 binlog 日志拷贝到自己本地,写入一个中继日志中。接着从库中有一个 SQL 线程会从中继日志读取 binlog,然后执行 binlog 日志中的内容,也就是在自己本地再次执行一遍 SQL,这样就可以保证自己跟主库的数据是一样的。
这里有一个非常重要的一点,就是从库同步主库数据的过程是串行化的,也就是说主库上并行的操作,在从库上会串行执行。所以这就是一个非常重要的点了,由于从库从主库拷贝日志以及串行执行 SQL 的特点,在高并发场景下,从库的数据一定会比主库慢一些,是有延时的。所以经常出现,刚写入主库的数据可能是读不到的,要过几十毫秒,甚至几百毫秒才能读取到。
而且这里还有另外一个问题,就是如果主库突然宕机,然后恰好数据还没同步到从库,那么有些数据可能在从库上是没有的,有些数据可能就丢失了。
mysql 半同步复制:
这个所谓半同步复制,semi-sync 复制,指的就是主库写入 binlog 日志之后,就会将强制此时立即将数据同步到从库,从库将日志写入自己本地的 relay log 之后,接着会返回回一个 ack 给主库,主库接收到至少一个从库的 ack 之后才会认为写操作完成了。
并行复制
所谓并行复制,指的是从库开启多个线程,并行读取 relay log 中不同库的日志,然后并行重放不同库的日志,这是库级别的并行。
(3) 延迟问题解决方案
在 Master 上增加一个自增表,这个表仅含有 1 个字段,当 master 接收到任何数据更新的请求时,均会触发这个触发器,该触发器更新自增表中的记录。
MySQL_Poxy 解决方案:
**写数据时:**由于 Count_table 也参与 Mysq 的主从同步,因此在 Master 上作的 Update 更新也会同步到 Slave 上。当 Client 通过 Proxy 进行数据读取时,Proxy 可以先向 Master 和 Slave 的 Count_table 表发送查询请求,当二者的数据相同时,Proxy 可以认定 Master 和 Slave 的数据状态是一致的,然后把 select 请求发送到 Slave 服务器上,否则就发送到 Master 上。如下图所示:
**读数据时:**通过这种方式,就可以比较完美的结果 MySQL 的同步延迟不可控问题。之所以所“比较完美”,是因为这种方案 double 了查询请求,对 Master 和 Slave 构成了额外的压力。不过由于 Proxy 与真实的 Mysql Server 采用连接池的方式连接,因此额外的压力还是可以接受的
Mysql 分库分表
(1) 为什么分库分表
比如一个项目单表都几千万数据了,mysql 数据库已经抗不住了,单表数据量太大,会极大影响你的 sql 执行的性能,会发遭受 sql 可能就跑的越来越慢。
分表就是把一个表的数据放到多个表中,然后项目查询数据的时候只查询一个表,比如按照用户 id 来分表,将一个用户的数据就放在一个用中。这样可以控制每个表的数据量在可控的范围内,比如每个表固定在 200 万以内。
分库就是你一个库最多支撑并发 2000,而且一个健康的单库并发值你最好保持在每秒 1000 左右,不要太大,那么你可以将一个库的数据拆分到多个库中,访问的时候访问一个库就好了。
(2) 分库分表中间件
数据库分库/分表中间件有两类:一类是 client 层,也就是直接在系统中使用的,另一类是 proxy 层,需要单独部署这种中间件。
- sharding-jdbc 这种 client 层方案的优点在于不用部署,运维成本低,不需要代理层的二次转发请求,性能很高,但是如果遇到升级啥的需要各个系统都重新升级版本再发布,各个系统都需要耦合 sharding-jdbc 的依赖;
- mycat 这种 proxy 层方案的缺点在于需要部署,自己及运维一套中间件,运维成本高,但是好处在于对于各个项目是透明的,如果遇到升级之类的都是自己中间件那里搞就行了。
(3) 垂直拆分与水平拆分
**水平拆分:**就是把一个表的数据给拆分到多个库的多个表里面去,但是每个库的表结构都一样,只不过每个库下表放的数据是不同的,所有的不同库的表数据加起来就是全部数据,水平拆分的意义,就是将数据均匀放更多的库里,然后用多个库来抗更高的并发,还有就是用多个库的存储容量来进行扩容。
**垂直拆分:**就是把一个有很多字段的表给拆分成多个表,或者是多个库上去,每个库表的结构都不一样,每个库都都包含部分字段。一般来说会将较少的访问频率很高的字段放到一个表里去,然后将较多的访问频率很低的字段放到另外一个表里去。因为数据库是有缓存的,你访问频率高的行字段越少,就可以在缓存里缓存更多的行,性能就越好。这个一般在表层面做的较多一些。
你就得考虑一下,你的项目里该如何分库分表?一般来说,垂直拆分,你可以在表层面来做,对一些字段特别多的表做一下拆分;水平拆分,你可以说是并发承载不了,或者是数据量太大,容量承载不了,你给拆了,按什么字段来拆,你自己想好;分表,你考虑一下,你如果哪怕是拆到每个库里去,并发和容量都 ok 了,但是每个库的表还是太大了,那么你就分表,将这个表分开,保证每个表的数据量并不是很大。
而且这儿还有两种分库分表的方式,一种是按照 range 来分,就是每个库一段连续的数据,这个一般是按比如时间范围来的,但是这种一般较少用,因为很容易产生热点问题,大量的流量都打在最新的数据上了;或者是按照某个字段 hash 一下均匀分散,这个较为常用。
range 来分,好处在于说,后面扩容的时候,就很容易,因为你只要预备好,给每个月都准备一个库就可以了,到了一个新的月份的时候,自然而然,就会写新的库了;缺点,但是大部分的请求,都是访问最新的数据。实际生产用 range,要看场景,你的用户不是仅仅访问最新的数据,而是均匀的访问现在的数据以及历史的数据
hash 分法,好处在于说,可以平均分配没给库的数据量和请求压力;坏处在于说扩容起来比较麻烦,会有一个数据迁移的这么一个过程。
把系统从未分库分表动态切换到分库分表上
(1) 停机迁移方案
停机迁移方案中,就是把系统在凌晨 12 点开始运维,系统停掉,然后提前写好一个导数据的一次性工具,此时直接跑起来,然后将单库单表的数据写到分库分表里面去。 导入数据完成了之后,修改系统的数据库连接配置,包括可能代码和 SQL 也许有修改,那你就用最新的代码,然后直接启动连到新的分库分表上去。
(2) 双写迁移方案
此方案不用停机,比较常用。 简单来说,就是在线上系统里面,之前所有写库的地方,增删改操作,都除了对老库增删改,都加上对新库的增删改,这就是所谓的双写。同时写两个库,老库和新库。然后系统部署之后,新库数据差太远,用之前说的导数工具,路起来读老库数据写新库,写的时候要根据 gmt_modified 这类判断这条数据最后修改时间,除非是读出来的数据在新库里没有,或者是比新库的数据新才会写。
8.分库分表之后生成全局唯一 ID
数据库经过分库分表之后,系统在向数据库中插入数据的时候,需要生成一个唯一的数据库 id,它在分库分表中必须是全局唯一的。常用的生成方法如下:
(1) 数据库自增主键
在数据库中单独创建一张表,这张表只包含一个字段 id,每次要向分库分表中插入数据时,先从这张表中获取一个 id。
缺点:这张生成 id 的表是单库单表的,要是高并发的话,就会产生瓶颈。
适合的场景:你分库分表就俩原因,要不就是单库并发太高,要不就是单库数据量太大;除非是你并发不高,但是数据量太大导致的分库分表扩容,你可以用这个方案,因为可能每秒最高并发最多就几百,那么就走单独的一个库和表生成自增主键即可。
(2) UUID
好处就是本地生成,不需要基于数据库,不好之处就是,UUID 太长了,并且是字符串,作为主键性能太差了,不适合用于主键。
适合的场景:如果你是要随机生成个什么文件名了,编号之类的,你可以用 uuid,但是作为主键是不能用 uuid 的。
(3) 获取系统当前时间
这个就是获取当前时间即可,但是问题是,并发很高的时候,比如一秒并发几千,会有重复的情况,这个是肯定不合适的,基本就不用考虑了。
适合的场景:一般如果用这个方案,是将当前时间跟很多其他的业务字段拼接起来,作为一 id,如果业务上你觉得可以接受,那么是可以的,你可以将别的业务字段值跟当前时间拼接起来,组成一个全局唯一的编号,订单编号啊:时间戳 + 用户 id + 业务含义编码。
(4) redis 生成唯一 ID
redis 是部署在数据库集群之外,它不依赖于数据库,使用 redis 能够生成唯一的 id 这主要依赖于 Redis 是单线程的,所以也可以用生成全局唯一的 ID,可以用 redis 的原子操作 incr 和 incrby 来实现。
也可以使用 redis 集群来获取更高的吞吐量,假如一个集群中有 5 台 Redis,可以初始化每台 Redis 的值分别是 1,2,3,4,5。然后步长都是 5,各个 redis 生成的 ID 为:
A:1,6,11,16,21
B:2,7,12,17,22
C:3,8,13,18,23
D:4,9,14,19,24
E:5,10,15,20,25
(5) snowflake 算法
twitter 开源的分布式 id 生成算法,就是把一个 64 位的 long 型的 id,1 个 bit 是不用的,用其中的 41 bit 作为毫秒数,用 10 bit 作为工作机器 id,12 bit 作为序列号
- 1 bit:不用,为啥呢?因为二进制里第一个 bit 为如果是 1,那么都是负数,但是我们生成的 id 都是正数,所以第一个 bit 统一都是 0
- 41 bit:表示的是时间戳,单位是毫秒。41 bit 可以表示的数字多达 2^41 - 1,也就是可以标识 2 ^ 41 - 1 个毫秒值,换算成年就是表示 69 年的时间。
- 10 bit:记录工作机器 id,代表的是这个服务最多可以部署在 2^10 台机器上哪,也就是 1024 台机器。但是 10 bit 里 5 个 bit 代表机房 id,5 个 bit 代表机器 id。意思就是最多代表 2 ^ 5 个机房(32 个机房),每个机房里可以代表 2 ^ 5 个机器(32 台机器)。
- 12 bit:这个是用来记录同一个毫秒内产生的不同 id,12 bit 可以代表的最大正整数是 2 ^ 12 - 1 = 4096,也就是说可以用这个 12bit 代表的数字来区分同一个毫秒内的 4096 个不同的 id
例如:0 1011100111101101000110100010111 10001 00001 000000000001
第 1 位 0:表示正数
接着 41 位表示:1559661847--> 2019-6-4 23:24:7
接着 5 位表示:17 机房
接着 5 位表示:17 机房下的第 1 台机器
最后 12 位表示:当前时间戳下并发的自增编号。
参考代码:
public class IdWorker{
private long workerId;
private long datacenterId;
private long sequence;
private long twepoch = 1288834974657L;
private long workerIdBits = 5L;
private long datacenterIdBits = 5L;
private long maxWorkerId = -1L ^ (-1L << workerIdBits); // 这个是二进制运算,就是5 bit最多只能有31个数字,也就是说机器id最多只能是32以内
private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits); // 这个是一个意思,就是5 bit最多只能有31个数字,机房id最多只能是32以内
private long sequenceBits = 12L;
private long workerIdShift = sequenceBits;
private long datacenterIdShift = sequenceBits + workerIdBits;
private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
private long sequenceMask = -1L ^ (-1L << sequenceBits);
private long lastTimestamp = -1L;
public IdWorker(long workerId, long datacenterId, long sequence){
// 这儿不就检查了一下,要求就是你传递进来的机房id和机器id不能超过32,不能小于0
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0",maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0",maxDatacenterId));
}
System.out.printf("worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d",
timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId);
this.workerId = workerId;
this.datacenterId = datacenterId;
this.sequence = sequence;
}
public synchronized long nextId() {
// 这儿就是获取当前时间戳,单位是毫秒
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
System.err.printf("clock is moving backwards. Rejecting requests until %d.", lastTimestamp);
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
lastTimestamp - timestamp));
}
// 在同一个毫秒内,又发送了一个请求生成一个id,0 -> 1
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask; // 这个意思是说一个毫秒内最多只能有4096个数字,无论你传递多少进来,这个位运算保证始终就是在4096这个范围内,避免你自己传递个sequence超过了4096这个范围
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0;
}
// 这儿记录一下最近一次生成id的时间戳,单位是毫秒
lastTimestamp = timestamp;
// 这儿就是将时间戳左移,放到41 bit那儿;将机房id左移放到5 bit那儿;将机器id左移放到5 bit那儿;将序号放最后10 bit;最后拼接起来成一个64 bit的二进制数字,转换成10进制就是个long型
return ((timestamp - twepoch) << timestampLeftShift) |
(datacenterId << datacenterIdShift) |
(workerId << workerIdShift) |
sequence;
}
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
private long timeGen(){
return System.currentTimeMillis();
}
public long getWorkerId(){
return workerId;
}
public long getDatacenterId(){
return datacenterId;
}
public long getTimestamp(){
return System.currentTimeMillis();
}
//---------------测试---------------
public static void main(String[] args) {
IdWorker worker = new IdWorker(1,1,1);
for (int i = 0; i < 30; i++) {
System.out.println(worker.nextId());
}
}
}
本文同步分享在 博客“Eno_Yao”(SegmentFault)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。