0%

DataBase Interview I

才能一旦让懒惰支配,它就一无可为。——克雷洛夫

数据库架构设计

如果提问:如何设计一个关系型数据库?实际上是考察你对数据库模块化的划分能力,当然也包括对数据库的概念的理解程度。

要开发数据库需要以下模块:

3Ax1HO.png

  • 存储管理模块可以控制IO次数,因为读取次数会非常花时间,在实际中,一次读取多行的效率远比多次读取(每次读取一两行)要省时间的多。

  • 非常常用的一种方法,就是引入缓存机制,把取出来的数据块存放在缓存里,下次需要的时候直接从内存返回,而不用发生IO。管理缓存的方法很多,可以使用比如LRU等。注意,我们的缓存不宜过大,而且算法中需要有淘汰机制。

总的来说,要回答如何设计一个关系型数据库(RDBMS:Relational Database Management System),首先要将其划分成两大部分,一个是存储部分,该部分类似一个文件系统,将数据持久化到存储设备当中。另一个是程序实例模块来对存储进行管理。而在程序实例模块中,需要包含下面八个模块:

①. 将数据的逻辑关系转换成物理存储关系的存储管理模块;

②. 优化执行效率的缓存机制模块

③. 将SQL语句进行解析的SQL解析模块

④. 记录操作的日志管理模块

⑤. 进行多用户权限管理的权限划分模块

⑥. 灾难恢复的容灾模块

⑦. 优化查询效率的索引管理模块 (重点)

⑧. 使数据库支持并发操作的锁模块 (重点)

实际上,数据库开发设计的模块结构和我们自己设计和开发的软件系统很相似,这个架构是很经典的,对程序的开发和设计也是很有借鉴意义的。

索引相关问题

为什么要使用索引?

索引(Index)是可以帮助MYSQL高效获取数据的数据结构。如果数据量很小,那么哪怕是全表扫描,也可能比加了索引之后更快。但是如果数据量很大,那么全表扫描将是噩梦。

实际上,可以理解索引的灵感来自于”字典”,通过类似”偏旁部首”之类的”索引”可以更快速地找到要查的词。

具体作用描述:

  1. 索引能极大地减少存储引擎需要扫描的数据量
  2. 索引可以把随机IO变成顺序IO
  3. 索引可以帮助我们在进行分组、排序等操作时,避免使用临时表

什么样的信息能成为索引?

  • 主键、唯一键及普通键等,只要能让数据具备一定区分性的字段,都可以成为索引。

索引的数据结构

在选择建并且生成索引之后,可以选择不同的数据结构进行索引的查找。一般常用的是二分查找树进行二分查找、建立B-树结构查找、建立B+树查找(MySQL选择的结构)、建立Hash结构查找。下面简单介绍每种结构的细节。

回答:通常来说,索引的数据结构是B+树,比较小众的也有哈希结构、BitMap(位图)等

二叉查找树结构进行索引查找

大体的结构:

3VOG60.png

  • 二叉查找树符合左小右大的规则,查找的时候使用二分查找
  • 检索深度每增加1,就会增加1次IO。但是需要注意的是整个树是通过很多数据块组合而成的,当许多数据块组合在一起的时候,会造成二叉树的深度很大,从而基本上没法避免多次的IO,从而造成很悲剧的后果:使用了索引,速度甚至可能比不用更慢,因为IO次数太多了。

B- 树结构进行索引查找

B-树又称作”多路平衡查找树”

3VXkEF.png

定义:

  • 根节点至少包括两个孩子
  • 树中每个结点最多含有m个孩子(m>=2)
  • 除根节点和叶节点外,其他每个节点至少有ceil(m/2)个孩子(ceil为取上限,举例,1.2和1.5,都是取2)
  • 所有叶子节点都位于同一层(即叶子节点高度都相同)

B+树结构进行索引查找

3Vvr1s.png

B+树实际上是B树的变体,其定义和B树不同的地方为:

  • 非叶子节点的子树指针与关键字个数相同
  • 非叶子节点的子树指针P[i],指向关键字值[K[i],K[i+1])(左闭右开,即可以大于等于K[i],但必须大于K[i+1] )的子树
  • 非叶子节点仅用来索引,数据都保存在叶子节点中。所有的数据实际都存储在叶子节点上,所以每一次遍历都必须遍历到叶子节点上。这也使得B+树的层级可以更少,树可以更矮。
  • 所有叶子节点均有一个链指针指向下一个叶子节点。搜索的实际是上图中粉色的块的部分。这个链指针主要服务于范围统计,定位到了某个叶指针之后,可以快速横向地去做统计。比如要统计索引>10的,找到了第二个Q之后,直接统计后面所有的Q内容即可。

结论:B+树相比B树更适合用来做存储索引

  • B+树的磁盘读写代价更低。B+树内部只存储索引(或者说叶子节点的指针),B树需要不断在父节点和叶子节点之间来回移动,所以B+树的高度能降低。
  • B+树查询效率更加稳定。因为所有实质内容都存储在根节点上,所以几乎所有数据的查询的时间都是稳定的:O(n)
  • B+树更有利于对数据库的扫描。B+树只需要遍历叶子节点就可以实现全部关键字信息的扫描。比如之前提到的,数据库中频繁使用的范围查询,使用B+树查询能够大大增加效率。

Hash结构进行索引查找

和哈希表的原理类似,通过一个哈希函数,将不同的key通过某种哈希映射得到value,然后存储在不同的链表节点上。如图:

3ZCI3D.png

哈希索引在理论上性能优于B+树。

但是哈希表相比B+树有一些缺点,却是更需要重视的:

  • 仅仅能满足”=”,”IN”,不能使用范围查询。因为哈希运算后得到的值之间的大小关系是根本不能保证和哈希运算前一样的,所以不能进行范围比较,只能进行具体的值的大小的比较。
  • 无法被用来避免数据的排序操作
  • 不能利用部分索引键查询
  • 不能避免表扫描
  • 遇到大量Hash值相等的情况后性能并不一定就会比B树索引高

BitMap索引

需要注意,目前比较少的主流数据库支持位图索引,比较出名的是Oracl。位图索引不是主流索引。

3ZP7GT.png

这个索引可以算是一个”神器”。当表中的某个字段只有几种可能值的时候,比如性别,此时如果要实现性别统计,使用位图索引可以说是最佳选择。

位图索引比较类似B+树。

  • 锁的力度大,不适合高并发的联机数据处理系统(OLTP系统),适合并发较少,且统计较多的OLAP系统

密集索引和稀疏索引的区别

  • 密集索引文件中的每个搜索码值都对应一个索引值
  • 稀疏索引文件只为索引码的某些值建立索引项

密集索引和稀疏索引如下图所示:

3ZY1eA.png

一个表只能创建一个密集索引。

针对Mysql进行分析,其主要有两种存储引擎:MyISAM和InnoDB:

  • MyISAM(数据和索引是分开的,文件后缀为.MYI、.MYD,前者存储索引,后者存储数据):主键索引、唯一建索引和普通索引,全部都是稀疏索引。
  • InnoDB(数据和索引是合在一起的,文件后缀为.idb)则有且仅有一个密集索引。

对于InnoDB的索引的额外知识:

  • 若一个主键被定义,该主键则作为密集索引
  • 若没有主键被定义,该表的第一个唯一非空索引则作为密集索引
  • 若没有主键,也没有合适的唯一索引,innodb内部会生成一个隐藏主键(是一个6字节的列,该列的值会随着数据的插入而自增)(密集索引)
  • 非主键索引存储相关键位和其对应的主键值,包含两次查找(一次查找次级索引自身,一次查找主键)

3ZagE9.png

索引额外的问题,以mysql为例

如何定位并优化慢查询Sql

这个问题主要考察有没有做过sql优化,需要具体场景具体分析,这里给出大致思路

  • 根据慢日志定位慢查询sql
  • 使用explain等工具分析sql
  • 修改sql或尽量让sql走索引

下面具体分析:

  • 根据慢日志定位慢查询sql

什么是慢日志?——用来记录一些执行比较慢的sql

show variables like '%quer%';//查看相关变量

查询结果:

3ZXX0f.png

其中需要重点关注三个变量:

show_query_log(慢日志是否打开),

slow_query_log_file(日志写入的文件路径),

long_query_time(多少秒以上的sql语句会被当做慢语句记录到文件中,一般设置为1秒)

一般来说可以用set设置慢查询开关和阈值时间(路径一般不迫切更改)

set global slow_query_log = on;

set global long_query_time = 1;//必须重连才能生效

但是如果想要永久生效,最好是去配置文件里面配好,而不是用set的方式。

查看系统慢查询的数量的语句:show status like '%slow_queries%',能够把运行下来执行慢的语句数量统计出来。

通过查看慢日志的具体值,可以在分析之后进行调优。

  • 使用explain等工具分析sql

在分析查询性能的时候explain关键字非常管用,一般放在select语句前面,用于描述mysql如何执行查询操作。以及mysql返回的结果和需要查询的行数。

3er9iQ.png

重点介绍看两个字段:type和extra

  1. type:如果出现了index或all,表示语句进行了全表扫描,需要优化
  2. extra:类型很多,其中using filesort/Using temporary出现的话,会对性能产生较大影响,必须优化:

3erzOx.png

  • 修改sql或者尽量让sql走索引

当我们进行查询的时候,如果一开始用的属性是没有索引的,可以更改成有索引的对象,可以减少查询时间。

但是有时候我们就是不能用带索引的属性,那么只能在新的需要查询的属性中加上索引了。

Mysql的索引优化器本质上也是一个软件,在不同的查询条件下可能就算你用了主键,查询效果也不够好。这个时候可以利用关键字force index(primary)进行对比,综合判断哪个索引的值最适合做此次的查询。

举一个调优的最简单的例子:如果我们是用户表,其中有用户账号account和用户名name,其中account有索引,则如果我们用name查询用了10s,用account查询用了6秒,那么实际我们将此次mysql查询的性能优化了40%

联合索引的最左匹配原则的成因

什么是联合索引?——由多列组成的索引

假设将数据库某张表中的A列和B列设置成联合索引,查询的时候在where语句中调用(where A=** and B=**)的时候会走A和B这个组合索引;而且如果单独用A查询,也会走AB联合索引。但是如果直接select B,则会走”ALL”,即不走索引,全表扫描。

  • 最左匹配原则本身很重要。简单理解,是Mysql会从左一直向右匹配直到遇到范围查询(>,<,between,like)就停止匹配。比如a=3 and b=4 and c>5 and d=6,如果建立(a,b,c,d)顺序的索引,d是用不到索引的,如果建立(a,b,d,c)的索引则都可以用到。其中a,b,d的顺序可以任意调整,比如(b,a,d,c),(a,d,b,c),结果都是一样的。注意这里不是没有用到索引,而是最多只用到了(a)(a,b),(a,b,c)索引,没有能够用到(a,b,c,d)索引
  • =和in可以乱序的。比如a = 1 and b = 2 and c = 3建立(a,b,c)索引可以任意顺序,mysql的查询优化器会帮你优化成索引可以识别的形式。

对于(a,b,c,d)这个例子,mysql创建复合索引的规则是首先会对复合索引的最左边的,也就是第一个a字段的数据进行排序,在第一个字段的排序基础上,然后再对后面第二个的b字段进行排序,如果此时c是范围查询的话,则没法获取到有序的d,因此是不行的。

索引是建立得越多越好么?

当然不是:

  • 数据量小的表不需要建立索引,增加额外的索引开销(比如看一两页的宣传手册,谁会需要索引呢?)
  • 数据变更需要维护索引,因此更多的索引意味着更多的维护成本
  • 更多的索引以为这也需要更多的空间(一本100页的书,目录不能是50页呀)

锁相关问题

MyISAM与InnoDB关于锁方面的区别是什么

  • MyISAM默认用的是表级锁,不支持行级锁

  • InnoDB默认用的是行级锁,但是也支持表级锁

分别来说:

MyISAM默认用的是表级锁,不支持行级锁

当使用MyISAM作为Mysql引擎时,对一个表进行操作时,会锁住整张表,其他session不能操作

读锁(共享锁)不释放,无法增加写锁

读锁(共享锁)不释放,可以增加读锁

写锁(排他锁)不释放,无法增加读锁

写锁 (排它锁) 不释放,无法增加写锁

给表加上读锁写锁或者读锁的写法:

lock tables *** read | write;
unlock tables;

InnoDB默认用的是行级锁,但是也支持表级锁

InnoDB使用的是二段锁,也就是加锁和解锁分成两个步骤进行,就像军训走正步一样,一个动作全部加锁,一个动作全部解锁。

InnoDB的行级锁会把某张表中的某一行锁住,这样如果你锁住了第3行,那么操作第4行是不会受影响的。

需要注意,InnoDB在sql没有用到索引的时候,会自动走表级锁。但是如果sql用到了索引,就会转为使用行级锁。

MyISAM和InnoDB各自适合的场景

  • MyISAM适合的场景:

    • 频繁执行全表count语句(InnoDB不保存表的具体行数,但MyISAM会使用一个变量保存整张表的行数,执行count的时候可以调用之前保存过的变量,)
    • 对数据进行增删改的频率不高,查询非常频繁(增删改设计锁表操作)
    • 没有事务
  • InnoDB适合的场景:

    • 数据增删改查都频繁(每次只锁行)
    • 可靠性要求高
    • 需要支持事务

数据库锁的分类

  • 按锁的粒度划分,可分为表级锁、行级锁、页级锁。InnoDB默认支持行级锁,同时支持表级锁。MyISAM只支持表级锁。
  • 按锁的级别划分,可分为共享锁、排它锁
  • 按加锁方式划分,可分为自动锁(mysql自动加上的锁)、显式锁(用语句加上的锁)
  • 按操作划分,可分为DML锁(对数据操作)、DDL锁(对表结构操作)
  • 按使用方式划分,可分为乐观锁(基于数据版本)、悲观锁(利用数据库提供的功能,对外界修改保守态度)

其中,乐观锁和悲观锁不仅在数据库中会使用,在程序中也经常用到。悲观锁实际上会拒绝所有外部的修改。对于并发,悲观锁实际上也是先取锁,后访问的保守策略。为数据处理的安全提供了保证。但是对于数据库,处理过多的锁可能会给数据库造成额外的开销,并且增加产生死锁的机会,所以对于事务要不要加锁需要谨慎。

乐观锁认为数据一般不会造成冲突,所以只会在数据提交更新的时候才会检测数据是否冲突。如果发现冲突,则返回用户错误的信息。悲观锁一般需要使用数据库提供的锁机制,但是乐观锁一般不用。乐观锁一般记录数据库版本。记录版本的方式一般也有两种,一个是使用版本号,另一个是使用时间戳。添加版本号,可以通过添加一个int类型的version变量实现。

数据库事务的四大特性

ACID

  • Atomicity:原子性,要么都做,要么都不做。比如银行取钱,我这边还没取完,你就不能操作我的账户。比较相似的是,如果一个用户正在操作他的账户,而且他的账户里面的钱的金额很大,可能出现的情况就是他点确认了,但是数据要处理几秒,那么这段时间这个账户也是不能被操作的。
  • Consistency,一致性,一个事务要持续做完。这个事务一般情况下需要满足既定好的假设,满足各种条件约束。
  • Isolation,隔离性,事务之间是互相独立的。一个事务的执行不能影响其他事务。
  • Durability,持久性,事务需要是持久的,比如介质受损了,比如断电之后,数据也还能保存。

其中,Isolation,隔离性,是最关键的一个属性。

事务隔离级别有四个。事务会先begin transaction,然后开始做。

  • Read uncommitted,事务的隔离级别非常低,别的事务完成到了一半还没committ的时候,就能够被我读出来。(不能避免脏读)
  • Read Committed,顾名思义,只能读取到别人已经Committed的内容。但是如果你已经读取了,在你读取之后别人又有了修改,别人修改之后你再读,读取到的是别人修改之后的内容,隔离性相对来说也不是很强。(可以避免脏读)
  • Repeatable Reads,这个针对上一个,这种读法始终只能读取到我自己begin transaction时候的值。
  • Serializable, 两个事务同时发生的时候,一定只会读取到其中一个的结果。

事务隔离级别以及各级别下的并发访问问题

事务并发访问引起的问题以及如何避免

  • 更新丢失(lost update),即数据库的一个更新覆盖掉了另一个更新的内容。但是现在Mysql所有事务隔离级别在数据库层面上均可避免这个问题。

举一个银行取款存款事务的例子:

3lQIZ4.png

  • 脏读(Dirty Read),指一个事务读到了另一个事务未提交的更新数据。这个问题可以在READ-COMMITTED事务隔离级别以上才能避免。

查看事务隔离级别:

select @@tx_isolation

NOTE:tx就是事务(transaction)缩写

设置事务隔离级别:

set session transaction isolation level read committed;

开启事务:

start tansaction;

  • 不可重复读(None repeatable read),事务A多次读取了数据,但是事务B在事务A读取的过程中有操作数据,导致事务A多次读取出来的数据不一致。把事务隔离级别修改成REPEATABLE-READ级别以上可以避免这个问题。

InnoDB默认的隔离级别就是REPEATABLE-READ,支持多次读也
能读取到相同结果。

设置事务隔离级别为REPEATABLE-READ:

set session transaction isolation level repeatable read;

事务的隔离级别设置到REPEATABLE-READ以上,就可以避免不可重复度的问题。

实际上,InnoDB引擎默认的事务隔离级别就是REPEATABLE-READ。

  • 幻读(Phantom read):事务A读取与搜索条件相匹配的若干行,而事务B同时进行了添加、删除的操作,导致A每次结果都不一样,貌似出现了幻觉。设置事务隔离级别为SERIALIZABLE可以解决。

总结

  • 更新丢失——Mysql所有事务隔离级别在数据库层面上均可避免。
  • 脏读——READ-COMMITTED事务隔离级别以上可以避免
  • 不可重复读——REPEATABLE-READ事务隔离级别以上可以避免
  • 幻读——SERIALIZABLE事务隔离级别可避免

如下图:

33EMr9.png

感觉上不可重复度和幻读这两个问题很相似。实际上,不可重复读侧重对同一数据的修改。幻读则侧重添加和删除。

数据库事务隔离级别越高,串行度越高。虽然可以避免错误的发生,但其并发性能会降低,效率会降低。Oracle默认为Read Committed、MySQL默认为REPETABLE READ。

InnoDB可重复读隔离级别下如何避免幻读

表象:快照读(非阻塞读)——伪MVCC(多版本并发控制)

表象不是真正原因。

当前读有以下操作:

select … lock in share mode, select … for update

update, delete, insert

当前读是加了锁的增删改查语句,不论是加了共享锁还是排它锁,都是当前读。当前读的特点是读取当前最新的记录,并且锁住当前数据使得其他事务不能修改数据。

当前读的操作原理:

33JNzd.png

  • 快照读:简单的select操作,不加锁的非阻塞读。当然,“不加锁”的前提是事务隔离级别不为Serializable,否则都是串行执行的,快照读就退化为当前读了。

RC、RR级别下的InnoDB的非阻塞读如何实现

  • 数据行里DB_TRX_ID, DB_ROLL_PTR, DB_ROW_ID字段

    • DB_TRX_ID:最后一次对本行数据做修改的事务ID
    • DB_ROLL_PTR:回滚指针,undo日志记录
    • DB_ROW_ID:行号,单调递增。
  • undo日志:当用户对数据产生变更时,就会产生undo记录。undo记录中存储的是老版数据。

undo log包含两种:Insert undo log和update undo log。其中Insert undo log会记录Insert的操作,用于回滚,并且在commit只后可以立即丢弃,它不是重点。重点是update undo log。 事务对数据进行delete、update的时候会产生update undo log,它不仅在事务回滚的时候需要,快照读也需要,它不能随便删除,只有当数据库不需要此类日志的时候才会删除。

事务对某行的数据更新的过程:

33tSun.png

流程:

  1. 锁定该行
  2. 将该行的数据拷贝一份到undo log中
  3. 修改当前行的值
  4. 使用回滚指针指向undo log中修改前的行

后面如果要对历史数据操作,可以去undo log里面找。

  • read view:用来做可见性判断。当我们执行快照读select的时候,会针对查询的数据创建特定的read view来决定可以查看当前哪一个版本的数据。

MVCC(Multi Version Concurrency Control):多版本并发控制,读写不冲突。

Gap锁

  • Gap锁会用在非唯一索引或者不走索引的当前读中

  • 走非唯一索引

33I4ns.png

官方文档有介绍,给出需要修改的索引,会对对象的周边进行上锁,上锁的范围会是左开右闭的区间。

33oVud.png

  • 不走索引

若当前读不走索引,它会对所有的Gap都上锁,就类似锁表了,同样可以达到防止幻读的效果。

38SGWj.png

但是相比表锁,这样上锁的代价更大,会降低数据库的效率。

锁模块之RR如何避免幻读

内在:next-key锁(行锁+gap锁)

  • 行锁:对单个行记录上的锁
  • Gap锁:防止同一事务两次读,防止出现幻读

主键或者唯一索引会用Gap锁么?——视情况而定

  • 如果where条件全部命中,则不用Gap锁,只会加记录锁
  • 如果where条件部分命中或者全不命中,则会加Gap锁。Gap锁用在非唯一索引或不走索引的当前读中。

需要注意,加锁的时候,如果我们走的时候主键之外的索引,那么我们需要对当前索引以及主键索引对应的记录都上锁。

加锁的具体情况举例:

当where条件全部命中的时候,不会加gap锁,只加记录锁。

33gGuV.png

锁模块小结

常见问题:

  • MyISAM与InnoDB关于锁方面的区别是什么
  • 数据库事务的四大特性
  • 事务隔离级别以及各级别下的并发访问问题
  • InnoDB可重复读隔离级别下如何避免幻读
  • RC、RR级别下的InnoDB的非阻塞读如何实现

关键语法

很多复杂的SQL都和查询、筛选相关。

  • GROUP BY
  • HAVING
  • 统计相关:COUNT, SUM, MAX, MIN, AVG

和他们相关的,都是完成一些统计的任务。

GROUP BY

作用:根据给定数据列的每个成员,对查询结果进行分组统计,最终得到一个分组汇总表。

条件:

  • 满足”SELECT子句中的列明必须为分组列或列函数”。说白了意思就是,如果你用了GROUP BY,那么查询的结果要么包含GRAOUP BY使用的列,要么带有统计函数(COUNT, SUM, MAX ,MIN ,AVG等)相关的列。而且这个条件只针对同一张表成立。
  • 列函数对于GROUP BY子句定义的每个组返回一个结果

举一个例子,首先看举例的三张表之间的关系

38uMnS.png

其中学生通过主键student_id与score连接,course通过主键course_id和score连接。

不同表的建立语句:

student表:
38voG9.png

course表:
38v7x1.png

score表:
38vT2R.png

完成查询的功能:

查询所有同学的学号、选课数、总成绩

解决这类写SQL问题的技巧之一,就是根据题目描述,写出所有可能的子SQL,然后再把这些子SQL拼接起来,达到要求。

比如这里要查询所有同学,所以要用学生表的主键: group by student_id

然后需要查询学生的学号、选课数、总成绩,所以需要select:
select student_id,count(course_id),sum(score);

而同时出现了学生、课程和分数的表,只有score表:from score

整合上面这些子SQL,可以写出:

select student_id,count(course_id),sum(score)

from score

group by student_id;

group by 会按照student_id把学生进行分组,然后count()和sum()函数会针对每个组进行聚合计算。

列函数会对于group by子句定义的每个组各返回一个结果。

如果选了group by,那么你的select 语句中选出的列要么是你group by里用到的列,要么就是带有之前sum,count等列函数的列。但是这个理论只针对同一张表有效。

比如要解决这个查询问题:

查询所有同学的学号、姓名、选课数、总成绩

相比之前的查询,多了一个姓名。此时只从score中查询就不够了,需要联合student表取出列名。

select s.studnet_id, stu.name, count(s.course_id),sum(s.score)

from

score s, student stu

where

s.studnet_id = stu.student_id

group by s.student_id;

注意,where一定要写在group by前面,

结合这个新的例子,我们可以知道:group by里面出现某个表的字段,select里面的列要么是该group by里出现的列,要么是别的表的列或者带有函数的列。

HAVING

如果没有group by子句,那么having和where是可以互换的。

  • 通常与group by一起使用。当它与group by一起使用时,可以放在group by 后面作为过滤的条件。HAVING相当于所有where的操作符
  • WHERE过滤行,HAVING过滤组
  • 出现在同一sql的顺序:WHERE > GROUP BY > HAVING

还是以之前三张表为例,完成这个查询:查询平均成绩大于60分的同学的学号和平均成绩

select student_id,avg(score)

from score

group by student_id

having avg(score)>60

取出studnet_id为1的学生的成绩情况

select * from score where student_ir=1;

下面这个写法结果相同:

select * from score having student_id=1;

查询没有学全所有课的同学的学号、姓名

select studnet_id,name

from

studnet stu,

score s

where stu.studnet_id = s.student_id

group by s.student_id

having count( * )<(select count( * ) from course)

最后需要结合大量实战题目,才能提升编写SQL的能力和基本功。