mysql知识点看这一篇就够了!

存储引擎

InnoDB

InnoDB 是 MySQL 默认的事务型存储引擎,只要在需要它不支持的特性时,才考虑使用其他存储引擎

InnoDB 采用 MVCC 来支持高并发,并且实现了四个标准隔离级别(未提交读、提交读、可重复读、可串行化)。其默认级别时可重复读(REPEATABLE READ),在可重复读级别下,通过 MVCC + Next-Key Locking 防止幻读。

主索引时聚簇索引,在索引中保存了数据,从而避免直接读取磁盘,因此对主键查询有很高的性能。

InnoDB 内部做了很多优化,包括从磁盘读取数据时采用的可预测性读,能够自动在内存中创建 hash 索引以加速读操作的自适应哈希索引,以及能够加速插入操作的插入缓冲区等。

InnoDB 支持真正的在线热备份,MySQL 其他的存储引擎不支持在线热备份,要获取一致性视图需要停止对所有表的写入,而在读写混合的场景中,停止写入可能也意味着停止读取。

MyISAM

设计简单,数据以紧密格式存储。对于只读数据,或者表比较小、可以容忍修复操作,则依然可以使用它。

提供了大量的特性,包括压缩表、空间数据索引等。

不支持事务。

不支持行级锁,只能对整张表加锁,读取时会对需要读到的所有表加共享锁,写入时则对表加排它锁。但在表有读取操作的同时,也可以往表中插入新的记录,这被称为并发插入(CONCURRENT INSERT)。

可以手工或者自动执行检查和修复操作,但是和事务恢复以及崩溃恢复不同,可能导致一些数据丢失,而且修复操作是非常慢的。

如果指定了 DELAY_KEY_WRITE 选项,在每次修改执行完成时,不会立即将修改的索引数据写入磁盘,而是会写到内存中的键缓冲区,只有在清理键缓冲区或者关闭表的时候才会将对应的索引块写入磁盘。这种方式可以极大的提升写入性能,但是在数据库或者主机崩溃时会造成索引损坏,需要执行修复操作。

InnoDB 和 MyISAM 的比较

  • 事务:InnoDB 是事务型的,可以使用 Commit 和 Rollback 语句。
  • 并发:MyISAM 只支持表级锁,而 InnoDB 还支持行级锁。
  • 外键:InnoDB 支持外键。
  • 备份:InnoDB 支持在线热备份。
  • 崩溃恢复:MyISAM 崩溃后发生损坏的概率比 InnoDB 高很多,而且恢复的速度也更慢。
  • 其它特性:MyISAM 支持压缩表和空间数据索引。

索引

看另一篇文章

事务

事务是指满足 ACID 特性的一组操作,可以通过 Commit 提交一个事务,也可以使用 Rollback 进行回滚。

ACID

事务最基本的莫过于 ACID 四个特性了,这四个特性分别是:

  • Atomicity:原子性
  • Consistency:一致性
  • Isolation:隔离性
  • Durability:持久性

原子性
事务被视为不可分割的最小单元,事务的所有操作要么全部成功,要么全部失败回滚。

一致性
数据库在事务执行前后都保持一致性状态,在一致性状态下,所有事务对一个数据的读取结果都是相同的。

隔离性
一个事务所做的修改在最终提交以前,对其他事务是不可见的。

持久性
一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢。

事务的脏读、幻读、不可重复读

(1)脏读:事务1更新了记录,但没有提交,事务2读取了更新后的行,然后事务T1回滚,现在T2读取无效。

(2)不可重复读:事务1读取记录时,事务2更新了记录并提交,事务1再次读取时可以看到事务2修改后的记录和之前不一致;

(3)幻读:事务1读取记录时,事务2增加了记录并提交,事务1再次读取时可以看到事务2新增的记录;

脏读

脏读指的是不同事务下,当前事务可以读取到另外事务未提交的数据。
它针对读未提交的问题 两个事务均未完成

例如:T1 修改一个数据,T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据。
mysql知识点看这一篇就够了!_第1张图片
mysql知识点看这一篇就够了!_第2张图片

不可重复读

不可重复读指的是同一事务内多次读取同一数据集合,读取到的数据是不一样的情况。
针对读已经提交的问题 一个事务已完成,一个未完成

例如:T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。

不可重复读和脏读的区别:前者是“读已提交”,后者是“读未提交”。

mysql知识点看这一篇就够了!_第3张图片
mysql知识点看这一篇就够了!_第4张图片

在 InnoDB 存储引擎中,SELECT 操作的不可重复读问题通过 MVCC 得到了解决,而 UPDATE、DELETE 的不可重复读问题是通过 Record Lock 解决的,INSERT 的不可重复读问题是通过 Next-Key Lock(Record Lock + Gap Lock)解决的。

Phantom Proble(幻影读)

幻影读是一种特殊的不可重复读问题。

Phantom Proble 是指在同一事务下,连续执行两次同样的 sql 语句可能返回不同的结果,第二次的 sql 语句可能会返回之前不存在的行

例如,一个编辑人员更改作者提交的文档,但当生产部门将其更改内容合并到该文档的主复本时,发现作者已将未编辑的新材料添加到该文档中。如果在编辑人员和生产部门完成对原始文档的处理之前,任何人都不能将新材料添加到文档中,则可以避免该问题。

幻读是指当事务不独立执行时,插入或者删除另一个事务当前影响的数据而发生的一种类似幻觉的现象。举个例子,某事务在检查表中的数据数count时,是10,过一段时间之后再查是11,这就发生了幻读,之前的检测获取到的数据如同幻觉一样。出现幻读和不可重复读的原因很像,都是在多次操作数据的时候发现结果和原来的不一样了,出现了其他事务干扰的现象。但是,幻读的偏重点是添加和删除数据,多次操作数据得到的记录数不一样;不可重复读的偏重点是修改数据,多次读取数据发现数据的值不一样了。

**之所以要讲幻读从不可重复读中独立出来,是因为行锁无法解决此类问题。**所以这也是那帮数据库设计者搞出来了幻读和不可重复读的概念,因为这俩解决方案不一样

幻读图示如下:
mysql知识点看这一篇就够了!_第5张图片
幻读和不可重复读都是读取了另一条已经提交的事务(这点就脏读不同),所不同的是不可重复读查询的都是同一个数据项,而幻读针对的是一批数据整体(比如数据的个数)。

丢失更新

一个事务的更新操作会被另一个事务的更新操作所覆盖。

例如:

T1 和 T2 两个事务都对一个数据进行修改,T1 先修改,T2 随后修改,T2 的修改覆盖了 T1 的修改。
mysql知识点看这一篇就够了!_第6张图片
这类型问题可以通过给 SELECT 操作加上排他锁来解决,不过这可能会引入性能问题,具体使用要视业务场景而定。

隔离级别

读未提交(READ UNCOMMITTED)

事务中的修改,即使没有提交,对其他事务也是可见的。
在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。本隔离级别很少用于实际应用,因为它的性能也不比其他级别好多少。读取未提交的数据,也被称之为脏读(Dirty Read)
这种隔离级别不会对select默认加锁。

读已提交(READ COMMITTED)

一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其他事务是不可见的。
这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这种隔离级别也支持所谓的不可重复读(NonrepeatableRead),因为同一事务的其他实例在该实例处理其间可能会有新的commit,所以同一select可能返回不同结果

这种隔离级别能够有效的避免脏读。对读已提交来说,事务中的每次读操作都会生成一个新的ReadView,也就是说,如果这期间某个事务提交了,那么它就会从ReadView中移除。这样确保事务每次读操作都能读到相对比较新的数据

可重复读(REPEATABLE READ)

保证在同一个事务中多次读取同样数据的结果是一样的。
这是MySQL的默认事务隔离级别,同一事务的多个实例在并发读取数据时,会看到同样的数据。
不过理论上,这会导致另一个棘手的问题:幻读(Phantom Read)。简单的说,幻读指当用户读取某一范围的数据行时,另一个事务又在该范围内插入了新行,当用户再读取该范围的数据行时,会发现有新的“幻影” 行。

对可重复读来说,事务只有在第一次进行读操作时才会生成一个ReadView,后续的读操作都会重复使用这个ReadView。也就是说,如果在此期间有其他事务提交了,那么对于可重复读来说也是不可见的,因为对它来说,事务活跃状态在第一次进行读操作时就已经确定下来,后面不会修改了。即读取的是第一次生成的快照。

总的来说,解决不可重复读的方法是 锁行(但是mysql好像通过上面说的方法不用锁也行?)

注意,如果采取读已提交的隔离级别,除非在查询中显式的加锁,不然,普通的查询是不会加锁的,这样就不能在事务中实现可重复读。
而使用可重复读的级别就不需要了。

可串行化(SERIALIZABLE)

强制事务串行执行。
需要加锁实现,而其它隔离级别通常不需要。这是最高的隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争
该级别是最高级别的隔离级。它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简而言之,SERIALIZABLE是在每个读的数据行上加锁。在这个级别,可能导致大量的超时Timeout和锁竞争Lock Contention现象,实际应用中很少使用到这个级别,但如果用户的应用为了数据的稳定性,需要强制减少并发的话,也可以选择这种隔离级

总的来说,解决幻读的方式是 锁表。

隔离级别对脏读、不可重复读、幻读的影响情况

这四种隔离级别越往后越影响性能,如何选取根据业务需求而定。以下是四种隔离级别中对脏读、不可重复读、幻读的影响情况。

InnoDB 在 RR 的级别就解决了幻读的问题。也就是说在 MySQL 的可重复读隔离级别下,不存在幻读问题。

隔离级别 脏读 不可重复读 幻影读
未提交读
提交读 ×
可重复读 × ×
可串行化 × × ×

通常,除了mysql数据库的隔离级别仅为2 Read Committed ,因而,仍然需要我们进行手动加锁!

解决方案总结

事务的隔离级别实际上都是定义的当前读的级别,MySQL为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得select不用加锁。而update、insert这些“当前读”(需要获取最新的状态)的隔离性,就需要通过加锁来实现了。

在 InnoDB 存储引擎中:

  • SELECT 操作的不可重复读问题通过 MVCC 得到了解决,
  • 而 UPDATE、DELETE 的不可重复读问题通过 Record Lock 解决,
  • INSERT 的不可重复读问题是通过 Next-Key Lock(Record Lock + Gap Lock)解决的。

多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别
而未提交读隔离级别总是读取最新的数据行,无需使用 MVCC。
可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。

后面会具体介绍这些概念

看我的另一篇博客

LBCC与MVCC

第一种,我既然要保证前后两次读取数据一致,那么我读取数据的时候,锁定我要操作的数据,不允许其他的事务修改就行了。这种方案我们叫做基于锁的并发控制 Lock Based Concurrency Control(LBCC)。

如果仅仅是基于锁来实现事务隔离,一个事务读取的时候不允许其他时候修改,那就意味着不支持并发的读写操作,而我们的大多数应用都是读多写少的,这样会极大地 影响操作数据的效率。

多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别

而未提交读隔离级别总是读取最新的数据行,无需使用 MVCC。
可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。

所以我们还有另一种解决方案,如果要让一个事务前后两次读取的数据保持一致, 那么我们可以在修改数据的时候给它建立一个备份或者叫快照,后面再来读取这个快照就行了。这种方案我们叫做多版本的并发控制 Multi Version Concurrency Control (MVCC)。

MVCC 的核心思想是: 我可以查到在我这个事务开始之前已经存在的数据,即使它在后面被修改或者删除了。在我这个事务之后新增的数据,我是查不到的。

数据库并发场景有三种,分别为:
1、读读:不存在任何问题,也不需要并发控制
2、读写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读、幻读、不可重复读
3、写写:有线程安全问题,可能存在更新丢失问题
MVCC是一种用来解决读写冲突的无锁并发控制,也就是为事务分配单项增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照,所以MVCC可以为数据库解决以下问题:
1、在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
2、解决脏读、幻读、不可重复读等事务隔离问题,但是不能解决更新丢失问题

问题:这个快照什么时候创建?读取数据的时候,怎么保证能读取到这个快照而不是最新的数据?这个怎么实现呢?
MVCC模块在MySQL中的具体实现是由三个隐式字段,undo日志、read view三个组件来实现的。

版本号

  • 系统版本号:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
  • 事务版本号:事务开始时的系统版本号。

隐藏字段

InnoDB 为每行记录都实现了两个隐藏字段:

  • 创建版本号:指示创建一个数据行的快照时的系统版本号;DB_TRX_ID,6 字节:插入或更新行的最后一个事务的事务 ID,事务编号是自动递增的(我们把它理解为创建版本号,在数据新增或者修改为新数据的时候,记录当前事 务 ID)。

  • 删除版本号(也叫回滚指针):DB_ROLL_PTR,7 字节:回滚指针(我们把它理解为删除版本号,数据将被删除或记录为旧数据的时候,记录当前事务 ID(或者说修改前的id))。
    如果该快照的删除版本号(不存在)或者大于当前事务版本号表示该快照有效(在当前事务进行的时候还没被删除呢),否则(包括没有的情况?)表示该快照已经被删除了。

  • DB_ROW_ID : 6 byte,隐含的自增ID(隐藏主键),如果数据表没有主键, InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引

  • 补充:实际还有一个删除flag隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag变了。

规则

MVCC 的查找规则:只能查找创建时间小于等于当前事务 ID 和删除时间大于当前事务 ID(或未删除undefined) 的行。

在 InnoDB 中,MVCC 是通过 Undo log 实现的。
Oracle、Postgres 等等其他数据库都有 MVCC 的实现。
需要注意,在 InnoDB 中,MVCC 和锁是协同使用的,这两种方案并不是互斥的。

Undo 日志

我们之前所讲的所有机制:索引,事务,隔离性,日志等,都是在内存中完成的,即在 MySQL 内部的相关缓冲区中,保存相关数据,完成各种判断操作。然后在合适的时候,将相关数据刷新到磁盘当中的。
我们这里理解undo log,简单理解成,就是 MySQL 中的一段内存缓冲区,用来保存日志数据的就行。

MVCC 使用到的快照存储在 Undo 日志中,该日志通过回滚指针把一个数据行(Record)的所有快照连接起来。

mysql知识点看这一篇就够了!_第7张图片

当事务对数据行进行一次更新操作时,会把旧数据行记录在一个叫做undo log的记录中,在undo log中除了记录数据行,还会记录下该行数据的对应的创建版本号,也就是生成这行数据的事务id嘛~
然后将原来数据行中的回滚指针指向undo log记录的这行数据(这就和上面版本号的内容联系起来了)。然后再在原来数据表中进行一次更新操作,如果这次更新操作回滚了,那么就可以根据回滚指针去undo log中查找之前的数据进行复原。如果后续还有更新操作的话,就会在undo log中和之前的数据行形成一条链表,链表头就是最新的数据,这条链表就叫做版本链
mysql知识点看这一篇就够了!_第8张图片
(ps:数据本来是刘备,然后事务id为100的事务先修改成了关羽,再修改成了张飞,后面事务id为200的事务先修改成了赵云,再修改成了诸葛亮)

简单来说 undo log 存的就是“还没有提交的undo的”修改

事务的可见性都是基于这个undo log来实现的

以下实现过程针对可重复读隔离级别。

实现过程

当开始一个事务时,该事务的版本号肯定大于当前所有数据行快照的创建版本号,理解这一点很关键。数据行快照的创建版本号是创建数据行快照时的系统版本号,系统版本号随着创建事务而递增,因此新创建一个事务时,这个事务的系统版本号比之前的系统版本号都大,也就是比所有数据行快照的创建版本号都大。

  • INSERT
    会将当前系统版本号作为数据行快照的创建版本号(在哪个事务最新被创建的)

  • DELETE
    将当前系统版本号作为数据行快照的删除版本号。(在哪个事务最新被删除的)

  • UPDATE
    将当前系统版本号作为更新前的数据行快照的删除版本号,并将当前系统版本号作为更新后的数据行快照的创建版本号。可以理解为先执行 DELETE 后执行 INSERT。

  • SELECT
    多个事务必须读取到同一个数据行的快照,并且这个快照是距离现在最近的一个有效快照。(但是也有例外,如果有一个事务正在修改该数据行,那么它可以读取事务本身所做的修改,而不用和其它事务的读取结果一致。)

update

(1)现在有一个事务 1 ,对 student 表中记录进行修改 (update) :将 name( 张三 ) 改成 name( 李四 ) 。

  1. 事务1,因为要修改,所以要先给该记录加行锁。
  2. 修改前,现将该行记录拷贝到undo log中,所以,undo log中就有了一行副本数据。(原理就是写时拷贝),现在 MySQL 中有两行同样的记录。
  3. 修改原始记录中的name,改成 ‘李四’。
    修改原始记录的隐藏字段 :DB_TRX_ID 为当前 事务1 的ID, 我们默认从 1 开始,之后递增。
    原始记录的回滚指针 DB_ROLL_PTR 列,里面写入undo log中副本数据的地址,从而指向副本记录,既表示我的上一个版本就是它。
  4. 事务1提交,释放锁。
    最新的记录是 ’李四‘ 那条记录

之后,又有一个事务2,进行同样的操作,修改年龄
mysql知识点看这一篇就够了!_第9张图片
我们就有了一个基于链表记录的历史版本链。

所谓的回滚,无非就是用历史数据,覆盖当前数据上面的一个一个版本,我们可以称之为一个一个的快照。
历史版本数据不光光是要形成版本,将来可能增删改访问原始数据,查的时候可能访问历史版本

delete

上面是以更新( upadte )主讲的 , 如果是 delete 呢?
一样的,删数据不是清空,而是设置flag为删除即可。也可以形成版本。

insert

因为insert是插入,也就是之前没有数据,那么insert也就没有历史版本。但是一般为了回滚操作,insert的数据也是要被放入undo log中,如果当前事务commit了,那么这个undo log 的历史insert记录就可以被清空了。
也就是我们可以理解成, updatedelete 可以形成版本链, insert 暂时不考虑

select :当前读与快照读

刚才undolog说了更新操作,那查询操作呢?这才是实现不同隔离级别的关键地方

首先,select不会对数据做任何修改,所以,为select维护多版本,没有意义。不过,此时有个问题,就是:select读取,是读取最新的版本呢?还是读取历史版本?

  • 当前读:读取最新的记录,就是当前读。增删改,都叫做当前读,select也有可能当前读,比如:select lock in share mode(共享锁), select for update
  • 快照读:读取历史版本(一般而言),就叫做快照读。

我们可以看到,在多个事务同时删改查的时候,都是当前读(增改的前提是找到这个数据),是要加锁的。那同时有select过来,如果也要读取最新版(当前读),那么也就需要加锁,这就是串行化。

但如果是快照读,读取历史版本的话,是不受加锁限制的。也就是可以并行执行!换言之,提高了效率,即MVCC的意义所在。快照读就不受加锁的限制,因为是历史数据,历史版本不会被修改,被修改的永远是最新版本,只要读取历史版本就不需要加锁!

把没有对一个数据行做修改的事务称为 T

  • T 所要读取的数据行快照的创建版本号必须小于等于 T 的版本号,因为如果大于 T 的版本号,那么表示该数据行快照是其它事务的最新修改,因此不能去读取它。
  • 除此之外,T 所要读取的数据行快照的删除版本号必须是未定义或者大于 T 的版本号,因为如果小于等于 T 的版本号,那么表示该数据行快照是已经被删除的,不应该去读取它。

ReadView 事务快照

Read View就是事务进行 快照读 操作的时候生产的 读视图 (Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)
Read View 在 MySQL 源码中,就是一个类,本质是用来进行可见性判断的。 即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件,用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。

当进行查询操作时,事务会生成一个ReadView,在该事务执行快照读的那一刻,会生成一个数据系统当前的快照,记录并维护系统当前活跃事务的id列表(事务的id值是递增的)

  • trx_list:一个数值列表,用来维护Read View生成时刻系统正活跃的事务ID
  • up_limit_id:记录trx_list列表中事务ID最小的ID
  • low_limit_id:Read View生成时刻系统尚未分配的下一个事务ID

查询一条数据时,事务会拿到这个ReadView,去到undo log中进行判断。若查询到某一条数据:

  1. 先去查看undo log中的最新数据行,如果undolog数据行的版本号小于ReadView记录的事务id最小值,就说明这条数据对当前数据库是可见的,可以直接作为结果集返回
  2. 若undo log数据行版本号大于ReadView记录最大值,说明这条数据是由一个新的事务修改的,对当前事务不可见,那么就顺着版本链继续往下寻找第一条满足条件的
  3. 若数据行版本号在ReadView最小值和最大值之间,那么就需要进行遍历了整个ReadView了,如果undolog 数据行版本号等于ReadView的某个值,说说明该行数据仍然处于活跃状态,那么对当前事务不可见

mysql知识点看这一篇就够了!_第10张图片

RC RR本质区别

正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同
在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事记录起来
此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见;
即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见

而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因
总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。
正是RC每次快照读,都会形成Read View,所以,RC才会有不可重复读问题。

mysql知识点看这一篇就够了!_第11张图片

如何解决幻读

在 MySQL 的可重复读隔离级别下,不存在幻读问题。
具体还可看https://blog.csdn.net/m0_62436868/article/details/127202062

现在你知道了,产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,操作的是锁住的行之间的 “间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。

这样,当你执行 select * from user where name = ‘Jack’ for update 的时候,就不止是给数据库中已有的 n 个记录加上了行锁,还同时加了 n + 1 个间隙锁(这两个合起来也成为 Next-Key Lock 临键锁)。也就是说,在数据库一行行扫描的过程中,不仅扫描到的行加上了行锁,还给行两边的空隙也加上了锁。这样就确保了无法再插入新的记录。

这里多提一嘴,update、delete 语句用不上索引是很恐怖的。
对非索引字段进行 select … for update、update 或者 delete 操作,由于没有索引,走全表查询,就会对所有行记录 以及 所有间隔 都进行上锁。而对于索引字段进行上述操作,只有索引字段本身和附近的间隔会被加锁。

undo log 里面的内容什么时候会被清除?

当前最新的记录没有人修改,而且被提交了 且 mysql所有事物当前没有人再和undo log里面的数据有关了

分库分表分区

见另一篇博客

binlog使用教程

见另一篇博客

主从复制 读写分离

主从复制

主要涉及三个线程:binlog 线程、I/O 线程和 SQL 线程。

  • binlog 线程 :负责将主服务器上的数据更改写入二进制日志(Binary log)中。
  • I/O 线程 :负责从主服务器上读取- 二进制日志,并写入从服务器的中继日志(Relay log)。
  • SQL 线程 :负责读取中继日志,解析出主服务器已经执行的数据更改并在从服务器中重放(Replay)。

mysql知识点看这一篇就够了!_第12张图片

读写分离

主服务器处理写操作以及实时性要求比较高的读操作,而从服务器处理读操作。

读写分离能提高性能的原因在于:

  • 主从服务器负责各自的读和写,极大程度缓解了锁的争用;
  • 从服务器可以使用 MyISAM,提升查询性能以及节约系统开销;
  • 增加冗余,提高可用性。

读写分离常用代理方式来实现,代理服务器接收应用层传来的读写请求,然后决定转发到哪个服务器。
mysql知识点看这一篇就够了!_第13张图片

JSON

JSON Path语法

// 比较简单,举两个例子
set @jsonSource = {
	"aaa": [
		{"ccc":"1"},
		{"ccc":"2"},
	],
	"bbb":"bbb的值"
};
@jsonSource-> "$.bbb"   // 结果为 bbb的值
@jsonSource-> "$.aaa[*].ccc" // 结果为 ["1","2"]

使用场景

在实际业务中经常会使用到 JSON 数据类型,在查询过程中主要有两种使用需求:

  • 在 where 条件中有通过 json 中的某个字段去过滤返回结果的需求
  • 查询 json 字段中的部分字段作为返回结果(减少内存占用)
  • 自动验证存储在JSON列中的JSON文档 。无效的文档会产生错误。

性能分析

有一个表tmp_test_course大概有10万条记录,有个json字段叫outline,存了一对多关系(保存了多个编码,例如jy1577683381775),我们需要在这10万条数据中检索特定类型的数据,目标总数据量:2931条

以下是4种情况的执行结果

  • 全文索引: 11.6ms
  • json函数查询:82.6ms
  • like查询: 136ms

由于json的字段太大,无法加索引,所以上面的json和like结果是没加索引的情况。但是好像新版 对于JSON类型的列,可以使用MySQL的JSON函数来创建索引。通过创建索引可以大大提高查询效率。可以使用以下语句创建JSON类型的索引:

ame (JSON_EXTRACT(JSON_COLUMN, ‘$.key’));
全文索引只支持CHAR、VARCHAR和TEXT,我们需要把JSON字段定义改一下

结论:全文索引 > json函数查询 > like查询

数据量越大,全文索引速度越明显,就10万的量,查询速度大概比直接查询快了20倍左右,如果是百万或千万级别的表,提升差距会更加大,所以有条件还是老老实实用全文索引吧

函数

JSON_CONTAINS

JSON_CONTAINS(target, candidate[, path])

如果在 json 字段 target 指定的位置 path,找到了目标值 condidate,返回 1,否则返回 0

如果只是检查在指定的路径是否存在数据,使用JSON_CONTAINS_PATH()

mysql> SET @j = '{"a": 1, "b": 2, "c": {"d": 4}}';
mysql> SET @j2 = '1';

mysql> SELECT JSON_CONTAINS(@j, @j2, '$.a');
+-------------------------------+
| JSON_CONTAINS(@j, @j2, '$.a') |
+-------------------------------+
|                             1 |
+-------------------------------+

mysql> SELECT JSON_CONTAINS(@j, @j2, '$.b');
+-------------------------------+
| JSON_CONTAINS(@j, @j2, '$.b') |
+-------------------------------+
|                             0 |
+-------------------------------+

mysql> SET @j2 = '{"d": 4}';
mysql> SELECT JSON_CONTAINS(@j, @j2, '$.a');
+-------------------------------+
| JSON_CONTAINS(@j, @j2, '$.a') |
+-------------------------------+
|                             0 |
+-------------------------------+

mysql> SELECT JSON_CONTAINS(@j, @j2, '$.c');
+-------------------------------+
| JSON_CONTAINS(@j, @j2, '$.c') |
+-------------------------------+
|                             1 |
+-------------------------------+

JSON_CONTAINS_PATH

如果在指定的路径存在数据返回 1,否则返回 0
JSON_CONTAINS_PATH(json_doc, one_or_all, path[, path] ...)
mysql> SET @j = '{"a": 1, "b": 2, "c": {"d": 4}}';

mysql> SELECT JSON_CONTAINS_PATH(@j, 'one', '$.a', '$.e');
+---------------------------------------------+
| JSON_CONTAINS_PATH(@j, 'one', '$.a', '$.e') |
+---------------------------------------------+
|                                           1 |
+---------------------------------------------+

mysql> SELECT JSON_CONTAINS_PATH(@j, 'all', '$.a', '$.e');
+---------------------------------------------+
| JSON_CONTAINS_PATH(@j, 'all', '$.a', '$.e') |
+---------------------------------------------+
|                                           0 |
+---------------------------------------------+

mysql> SELECT JSON_CONTAINS_PATH(@j, 'one', '$.c.d');
+----------------------------------------+
| JSON_CONTAINS_PATH(@j, 'one', '$.c.d') |
+----------------------------------------+
|                                      1 |
+----------------------------------------+

mysql> SELECT JSON_CONTAINS_PATH(@j, 'one', '$.a.d');
+----------------------------------------+
| JSON_CONTAINS_PATH(@j, 'one', '$.a.d') |
+----------------------------------------+
|                                      0 |
+----------------------------------------+

实际使用:

$conds = new Criteria();
$conds->andWhere('dept_code', 'in', $deptCodes);
if (!empty($aoiAreaId)) {
    $aoiAreaIdCond = new Criteria();
    $aoiAreaIdCond->orWhere("JSON_CONTAINS_PATH(new_aoi_area_ids,'one', '$.\"$aoiAreaId\"')", '=', 1);
    $aoiAreaIdCond->orWhere("JSON_CONTAINS_PATH(old_aoi_area_ids,'one', '$.\"$aoiAreaId\"')", '=', 1);
    $conds->andWhere($aoiAreaIdCond);
}

获取指定路径的值

用->、->>的查询效率比json_extract、json_unquote慢很多,虽然它们好像是一样的,不知道什么原因。

column->path、column->>path

-> vs ->> Whereas the -> operator simply extracts a value, the ->> operator in addition unquotes the extracted result.
->在field中使用的时候结果带引号,->>的结果不带引号

mysql> SELECT * FROM jemp WHERE g > 2;
+-------------------------------+------+
| c                             | g    |
+-------------------------------+------+
| {"id": "3", "name": "Barney"} |    3 |
| {"id": "4", "name": "Betty"}  |    4 |
+-------------------------------+------+
2 rows in set (0.01 sec)

mysql> SELECT c->'$.name' AS name
    ->     FROM jemp WHERE g > 2;
+----------+
| name     |
+----------+
| "Barney" |
| "Betty"  |
+----------+
2 rows in set (0.00 sec)

mysql> SELECT JSON_UNQUOTE(c->'$.name') AS name
    ->     FROM jemp WHERE g > 2;
+--------+
| name   |
+--------+
| Barney |
| Betty  |
+--------+
2 rows in set (0.00 sec)

mysql> SELECT c->>'$.name' AS name
    ->     FROM jemp WHERE g > 2;
+--------+
| name   |
+--------+
| Barney |
| Betty  |
+--------+
2 rows in set (0.00 sec)
#特别注意:->当做where查询是要注意类型的,->>是不用注意类型的
#下面二者结果不一样
select * from member where info->"$.id" = 1;
select * from member where info->"$.id" = "1";

实际使用:

$retTask = AoiAreaTaskOrm::findRows(['status', 'extra_info->>"$.new_aoi_area_infos" as new_aoi_area_infos', 'extra_info->>"$.old_aoi_area_infos" as old_aoi_area_infos'], $cond);

update

该UPDATE语句使用任何的三个功能 JSON_SET(), JSON_REPLACE()或 JSON_REMOVE()更新列。列值的直接赋值(例如,UPDATE mytable SET jcol='{“A”:10,“b”:25}’)不能作为部分更新执行。

给JSON字段添加索引

生成列虚拟索引方式

说明:8.0和5.7都支持在生成列上添加索引

JSON 不能直接对列进行索引。要创建间接引用此类列的索引,可以定义一个生成列,该列提取应建立索引的信息,然后在生成的列上创建索引,如下所示:


mysql>CREATE TABLE jemp (
    ->     c JSON,
    ->     g INT GENERATED ALWAYS AS (c->"$.id"),
    ->     INDEX i (g)
    -> );
Query OK, 0 rows affected (0.01 sec)
 
#查看表结构:

#8.0表结构:
*************************** 1. row ***************************
       Table: jemp
Create Table: CREATE TABLE `jemp` (
  `c` json DEFAULT NULL,
  `g` int GENERATED ALWAYS AS (json_extract(`c`,_utf8mb3'$.id')) VIRTUAL,
  KEY `i` (`g`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.00 sec)
 
#5.7表结构:
*************************** 1. row ***************************
       Table: jemp
Create Table: CREATE TABLE `jemp` (
  `c` json DEFAULT NULL,
  `g` int(11) GENERATED ALWAYS AS (json_extract(`c`,'$.id')) VIRTUAL,
  KEY `i` (`g`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.00 sec)
 
# 插入数据
mysql >INSERT INTO jemp (c) VALUES
    -> ('{"id": "1", "name": "Fred"}'), ('{"id": "2", "name": "Wilma"}'),
    -> ('{"id": "3", "name": "Barney"}'), ('{"id": "4", "name": "Betty"}');
Query OK, 4 rows affected (0.00 sec)
Records: 4  Duplicates: 0  Warnings: 0
 
# 查看数据
mysql >select * from jemp;
+-------------------------------+------+
| c                             | g    |
+-------------------------------+------+
| {"id": "1", "name": "Fred"}   |    1 |
| {"id": "2", "name": "Wilma"}  |    2 |
| {"id": "3", "name": "Barney"} |    3 |
| {"id": "4", "name": "Betty"}  |    4 |
+-------------------------------+------+
4 rows in set (0.00 sec)
 
 #如何选择数据
mysql >SELECT c->>"$.name" AS name FROM jemp WHERE g > 2;
+--------+
| name   |
+--------+
| Barney |
| Betty  |
+--------+
2 rows in set (0.00 sec)
 
查看执行计划:
mysql >EXPLAIN SELECT c->>"$.name" AS name FROM jemp WHERE g > 2 \G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: jemp
   partitions: NULL
         type: range
possible_keys: i
          key: i
      key_len: 5
          ref: NULL
         rows: 2
     filtered: 100.00
        Extra: Using where
1 row in set, 1 warning (0.00 sec)

通过上述查看执行计划,可以看到使用到了我们在生成列上创建的索引;

当EXPLAIN在SELECT包含一个或多个使用->or->> 运算符的一个 或其他SQL语句上使用时 ,这些表达式将使用JSON_EXTRACT()和(如果需要)转换为它们的等效项JSON_UNQUOTE(),如SHOW WARNINGS输出所示:

mysql>EXPLAIN SELECT c->>"$.name" FROM jemp WHERE g > 2 ORDER BY c->"$.name"\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: jemp
   partitions: NULL
         type: range
possible_keys: i
          key: i
      key_len: 5
          ref: NULL
         rows: 2
     filtered: 100.00
        Extra: Using where; Using filesort
1 row in set, 1 warning (0.00 sec)
 
mysql >SHOW WARNINGS\G
*************************** 1. row ***************************
  Level: Note
   Code: 1003
Message: /* select#1 */ select json_unquote(json_extract(`wjqdb`.`jemp`.`c`,'$.name')) AS `c->>"$.name"` from `wjqdb`.`jemp` where (`wjqdb`.`jemp`.`g` > 2) order by json_extract(`wjqdb`.`jemp`.`c`,'$.name')
1 row in set (0.00 sec)

在MySQL 8.0.21和更高版本中,还可以JSON使用JSON_VALUE()带有表达式的函数在列上创建索引,该表达式可用于优化使用该表达式的查询;

8.0新特性 直接creat index与多值索引

MySQL 8.0新增的一种索引类型:多值索引;从MySQL 8.0.17开始,InnoDB支持多值索引。多值索引是在存储值数组的列上定义的二级索引。“普通”索引对每个数据记录有一个索引记录(1:1)。对于单个数据记录(N:1),多值索引可以有多个索引记录。多值索引旨在为JSON数组建立索引。

多值索引可以在CREATE TABLEALTER TABLECREATE INDEX语句中创建多值索引。
这要求使用CAST(… AS … ARRAY)索引定义,该定义将JSON数组中相同类型的标量值转换为SQL数据类型数组。

然后,使用SQL数据类型数组中的值透明地生成一个虚拟列。最后,在虚拟列上创建一个功能索引(也称为虚拟索引)。是在SQL数据类型数组的值的虚拟列上定义的功能索引,该索引构成了多值索引。

Mysql 8.0.x版本后,支持对json字段创建索引,直接 create index 即可,需要使用cast方法将json目标字段转换成可以创建索引的类型。有两种情况:

  • 普通索引:每条记录和json字段为1对1关系

  • 多值索引:每条记录和json字段为1对多关系(对应array,或是jsonPath取值结果是array情况)

#普通索引
ADD INDEX index_modify_user((CAST(permission_json->'$.aaa' AS CHAR(64))))
#多值索引 (区别仅在于用cast 方法转化成一个 ARRAY)
ADD INDEX index_modify_user((CAST(permission_json->'$.modify[*]' AS CHAR(64) ARRAY )))

根据JSON索引查询

只有少数语句支持JSON字段索引。在WHERE子句中使用以下函数时,优化程序将使用多值索引来获取记录 :(当然 你需要先新建索引)

  • MEMBER OF()

  • JSON_CONTAINS()

  • JSON_OVERLAPS()

关系数据库设计理论

名词解释

函数依赖

记 A->B 表示 A 函数决定 B,也可以说 B 函数依赖于 A。

对于 A->B,如果能找到 A 的真子集 A’,使得 A’-> B,那么 A->B 就是部分函数依赖,否则就是完全函数依赖。

对于 A->B,B->C,则 A->C 是一个传递函数依赖

键码、键

候选键:如果 {A1,A2,… ,An} 是关系的一个或多个属性的集合,该集合函数决定了关系的其它所有属性

最小的候选键,那么该集合就称为键码。

非主属性

包含在任何一个码中的属性成为主属性。

异常

以下的学生课程关系的函数依赖为 {Sno, Cname} -> {Sname, Sdept, Mname, Grade},键码为 {Sno, Cname}。也就是说,确定学生和课程之后,就能确定其它信息。

Sno Sname Sdept Mname Cname Grade
1 学生-1 学院-1 院长-1 课程-1 90
2 学生-2 学院-2 院长-2 课程-2 80
2 学生-2 学院-2 院长-2 课程-1 100
3 学生-3 学院-2 院长-2 课程-2 95

不符合范式的关系,会产生很多异常,主要有以下四种异常:

  • 冗余数据:例如 学生-2 出现了两次。
  • 修改异常:修改了一个记录中的信息,但是另一个记录中相同的信息却没有被修改。
  • 删除异常:删除一个信息,那么也会丢失其它信息。例如删除了 课程-1 需要删除第一行和第三行,那么 学生-1 的信息就会丢失。
  • 插入异常:例如想要插入一个学生的信息,如果这个学生还没选课,那么就无法插入。

范式

范式理论是为了解决以上提到四种异常。

高级别范式的依赖于低级别的范式,1NF 是最低级别的范式。

数据库范式也分为1NF,2NF,3NF,BCNF,4NF,5NF。一般在我们设计关系型数据库的时候,最多考虑到BCNF就够。符合高一级范式的设计,必定符合低一级范式,例如符合2NF的关系模式,必定符合1NF。

第一范式 (1NF)

属性不可分。
mysql知识点看这一篇就够了!_第14张图片、比如这个就不符合第一范式

第二范式 (2NF)

每个非主属性完全函数依赖于键码。

可以通过分解来满足。

分解前

Sno Sname Sdept Mname Cname Grade
1 学生-1 学院-1 院长-1 课程-1 90
2 学生-2 学院-2 院长-2 课程-2 80
2 学生-2 学院-2 院长-2 课程-1 100
3 学生-3 学院-2 院长-2 课程-2 95

以上学生课程关系中,{Sno, Cname} 为键码,有如下函数依赖:

  • Sno -> Sname, Sdept
  • Sdept -> Mname
  • Sno, Cname-> Grade
  • Grade 完全函数依赖于键码,它没有任何冗余数据,每个学生的每门课都有特定的成绩。

Sname, Sdept 和 Mname 都部分依赖于键码,当一个学生选修了多门课时,这些数据就会出现多次,造成大量冗余数据。

分解后

关系-1

Sno Sname Sdept Mname
1 学生-1 学院-1 院长-1
2 学生-2 学院-2 院长-2
3 学生-3 学院-2 院长-2

有以下函数依赖:

  • Sno -> Sname, Sdept(这里是分别决定的意思)
  • Sdept -> Mname(实际上也是依赖于sno,但是是传递依赖)

关系-2

Sno Cname Grade
1 课程-1 90
2 课程-2 80
2 课程-1 100
3 课程-2 95

有以下函数依赖:

  • Sno, Cname -> Grade

第三范式 (3NF)

非主属性不传递函数依赖于键码。

上面的 关系-1 中存在以下传递函数依赖:

Sno -> Sdept -> Mname

可以进行以下分解:

关系-11

Sno Sname Sdept
1 学生-1 学院-1
2 学生-2 学院-2
3 学生-3 学院-2

关系-12

Sdept Mname
学院-1 院长-1
学院-2 院长-2

BCNF

消除候选键中的列对其它任一候选键的传递依赖或部份依赖

假定

  • 一个教师只会教一门课程
  • 一门课可以有很多老师教
  • 当学生选定某门课,就对应一个固定的教师

举例:学生id 课程id 教师id

那我们可以 确定两个候选键(学生id,课程id),(学生id,教师id)
但我们发现 一个教师只会教一门课程,那么 教师id就决定了课程id 可以认为课程id依赖于教师id 那么这个表就不符合 bcnf

ER 图

Entity-Relationship,有三个组成部分:实体、属性、联系。

用来进行关系型数据库系统的概念设计。

实体的三种联系
包含一对一,一对多,多对多三种。

如果 A 到 B 是一对多关系,那么画个带箭头的线段指向 B;
如果是一对一,画两个带箭头的线段;
如果是多对多,画两个不带箭头的线段。
下图的 Course 和 Student 是一对多的关系。

较为零碎

DDL详解与8.0对DDL的优化(后续项目推荐新版本)

随着业务的发展,用户对系统需求变得越来越多,这就要求系统能够快速更新迭代以满足业务需求,通常系统版本发布时,都要先执行数据库的DDL变更,包括创建表、添加字段、添加索引、修改字段属性等。

在早期的MySQL版本,DDL变更都会导致全表被锁,阻塞表上的DML操作,影响业务正常运行,好的一点就是,随着MySQL版本的迭代,DDL的执行方式也在变化。

在数据量大不大的情况下,执行DDL都很快,对业务基本没啥影响,但是数据量大的情况,而且我们业务做了读写分离,接入了实时数仓,这时DDL变更就是一个的难题,需要综合各方业务全盘考虑。

例如上面提到了,目前我在大数据团队,我们的业务都做了读写分离,同时接入实时数仓,数仓不支持rename操作,这时就可以选择在业务低峰期使用ONLINE DDL的方式执行,对业务系统影响最小,同时不影响数仓。

移除的查询缓存

适合QueryCache的场景
首先,查询缓存QC的大小只有几MB,不适合将缓存设置得太大,由于在更新过程中需要线程锁定QueryCache,因此对于非常大的缓存,可能会看到锁争用问题。那么,哪些情况有助于从查询缓存中获益呢?以下是理想条件:

相同的查询是由相同或多个客户机重复发出的。
被访问的底层数据本质上是静态或半静态的。
查询有可能是资源密集型和/或构建简短但计算复杂的结果集,同时结果集比较小。
并发性和查询QPS都不高。
这4种情况只是理想情况下,实际的业务系统都是有CRUD操作的,数据更新比较频繁,查询接口的QPS比较高,所以能满足上面的理想情况下的业务场景实在很少,我能想到就是配置表,数据字典表这些基本都是静态或半静态的,可以时通过QC来提高查询效率。

不适合QueryCache的场景
如果表数据变化很快,则查询缓存将失效,并且由于不断从缓存中删除查询,从而使服务器负载升高,处理速度变得更慢,如果数据每隔几秒钟更新一次或更加频繁,则查询缓存不太可能合适。

同时,查询缓存使用单个互斥体来控制对缓存的访问,实际上是给服务器SQL处理引擎强加了一个单线程网关,在查询QPS比较高的情况下,可能成为一个性能瓶颈,会严重降低查询的处理速度。因此,MySQL 5.6中默认禁用了查询缓存。

上面为大家介绍了MySQL QueryCache从推出->禁用->废弃->删除的心路历程,设计之初是为了减少重复SQL查询带来的硬解析开销,同时将物理IO转化为逻辑IO,来提高SQL的执行效率,但是MySQL经过了多个版本的迭代,同时在硬件存储发展之快的今天,QC几乎没有任何收益,而且还会降低数据库并发处理能力,最终在8.0版本直接Removd掉了。

缓存的失效很容易,只要对表有任何的更新,这个表的所有查询缓存就会全部被清空,就会出现缓存还没使用,就直接被清空了,或者积累了很多缓存准备用来着,但是一个更新打回原形。

这就导致查询的命中率低的可怕,只有那种只查询不更新的表适用缓存,但是这样的表往往很少存在,一般都是什么配置表之类的。

连接超时——每天要重启下的有意思小错误

这里需要注意的是,我们数据库的客户端太久没响应,连接器就会自动断开了,这个时间参数是wait_timeout控制住的,默认时长为8小时。

断开后重连的时候会报错,如果你想再继续操作,你就需要重连了。

这个有个我看过的书本的案例:

一个在政府里的朋友说,他们的系统很奇怪,每天早上都得重启一下应用程序,否则就提示连接数据库失败,他们都不知道该怎么办。

按照这个错误提示,应该就是连接时间过长了,断开了连接。

数据库默认的超时时间是8小时,而他们平时六点下班,下班之后系统就没有人用了,等到第二天早上九点甚至十点才上班,这中间的时间已经超过10个小时了,数据库的连接肯定就会断开了。

是的,就是超出了超时时间,然后写代码的人也没注意到这个细节,所以才会出现这个问题。

把超时时间改得长一点,问题就解决了。

这种参数其实我们平时不一定能接触到,但是真的遇到问题的时候,知道每个参数的大概用法,不至于让你变成无头苍蝇。

那除了重新链接,还有别的方式么?因为建立链接还是比较麻烦的。

使用长连接。

但是这里有个缺点,使用长连接之后,内存会飙得很快,我们知道MySQL在执行过程中临时使用的内存是管理在连接对象里面的。

只有在链接断开的时候才能得到释放,那如果一直使用长连接,那就会导致OOM(Out Of Memory),会导致MySQL重启,在JVM里面就会导致频繁的Full GC。

那你会怎么解决?

我一般会定期断开长连接,使用一段时间后,或者程序里面判断执行过一个占用内存比较大的查询后就断开连接,需要的时候重连就好了。

还有别的方法么?你这种感觉不优雅呀小老弟。

执行比较大的一个查询后,执行mysql_reset_connection可以重新初始化连接资源。这个过程相比上面一种会好点,不需要重连,但是会初始化连接的状态。

进一步阅读

里面有比如权限系统之类的
https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MzA3ODUxNjk0OQ==&action=getalbum&album_id=1760277454188789768

mysql实战45讲

你可能感兴趣的:(mysql,数据库,哈希算法)