很多人第一次接触到数据库系统时,最容易产生一个错觉:SQL就是查数据的,会写SELECT就够了,剩下的都是运维的事。等到真正把一套业务跑起来,才发现建表策略不合理、索引乱加、JOIN写得随心所欲,线上慢查询一个一个冒出来,才回头补基础课。
这篇文章是数据库系统系列的第六篇,专门讲SQL概述。我不打算复述教材里"SQL是结构化查询语言"那种定义式开头,而是从关系模型、语句分类、查询执行逻辑、约束索引,到一套完整实战案例,把SQL从"认识它"到"用对它"的全过程梳理一遍。内容不追求大而全,但保证每个点都能落到底,特别是那些教材不会单独拎出来讲的易错环节,我会结合自己写SQL、调SQL的真实体验展开。
1. SQL统治数据库世界的原因:从关系模型到关系数据库
1.1 关系模型的提出与SQL的诞生
要理解SQL为什么长成今天这样,得先回到关系模型。上世纪七十年代,一位计算机科学家在一篇开创性论文中提出了关系模型,核心思想是把数据抽象成二维表,表由行和列组成,表与表之间通过"共同字段"建立联系。这个模型看起来平平无奇,但它解决了一个大麻烦:此前应用普遍依赖层次模型和网状模型,数据访问路径和存储结构紧密耦合,业务一改动,程序就得跟着改。
关系模型提出之后,市场上冒出了很多标榜"关系"的产品,但各家查询接口五花八门,开发人员学一个系统就要学一套新命令,迁移成本极高。于是SQL作为统一的关系数据库语言诞生了。它最初只在某实验室内部使用,后来逐渐成为国际标准,从SQL-86一路演进到SQL-92、SQL:1999、SQL:2003、SQL:2016。我个人的体会是,标准里最核心的语法在几十年前就已经定型,后续的版本更多是在补齐类型系统、窗口函数、JSON处理这类能力,底层的SELECT/INSERT/UPDATE/DELETE骨架几乎没变过。
这也是为什么你现在随便打开一本文科生教材或者理工科选修课讲义,讲SQL都是先从"SELECT * FROM"开始——因为这套语法本身就是为直观描述数据操作而设计的:你要查什么列,从哪张表取,满足什么条件,翻译过来就是SELECT、FROM、WHERE三个关键字。
1.2 标准在演进,但核心语法几十年不变
很多同学会问,既然SQL是标准,为什么我在MySQL里写的语句换到PostgreSQL就报错?标准规定了"应该有什么",但没有规定"必须怎么写死"。各数据库在日期函数、字符串拼接、分页语法上都做了自己的扩展。比如分页,MySQL用的是LIMIT,SQL Server早期用TOP,Oracle以前用ROWNUM,现在都有标准化的FETCH FIRST语法了,但老系统里依然坚挺着各家方言。
我在实际项目里的处理方式是:核心逻辑尽量用标准SQL写,只有没法绕开方言差异时才使用特定语法。因为一旦未来要换数据库底座,纯标准SQL的部分可以原样迁移,方言部分就得逐条改。这种习惯在维护一个历经三次数据库迁移的系统时帮我省了太多事。学习SQL时也建议先吃透标准,再去看目标数据库的差异点,这样底子是扎实的。
2. SQL的四大组成部分:别再以为SQL只是查询
2.1 DDL:用CREATE/ALTER/DROP管理表结构
数据定义语言DDL负责定义数据库对象,最常见的就是表。我记得刚学SQL时,以为建表就是"CREATE TABLE 表名 (列名 类型)"这么简单,真正写系统才发现,建表要纠结的事情太多了。
下面是我现在写表结构时比较基础但完整的写法:
CREATE TABLE customers ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, email VARCHAR(128) NOT NULL UNIQUE, phone VARCHAR(20), level TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );这里每一个约束都不是随便加的:主键保证每一行能被唯一标识,UNIQUE约束防止同一个邮箱注册两次,DEFAULT让插入时不用每次手写创建时间,NOT NULL避免脏数据混进来。ALTER TABLE用来调整结构,比如给表增加字段,注意生产环境操作前要做好备份或者走变更平台,不能像本地练习一样随手执行。
ALTER TABLE customers ADD COLUMN city VARCHAR(32) AFTER phone;DROP TABLE是删表,这个命令威力很大,一旦执行,表结构和数据都没了。生产环境中,绝大多数团队会禁用开发者的DROP权限,防止一条语句毁掉整个业务,这一点在数据库系统课程后面讲权限管理时也会反复强调。
2.2 DML:INSERT、UPDATE、DELETE增删改
数据操作语言是应用和数据库交互的主力。INSERT的基本用法大家都会,但有几个细节容易被忽视。第一是多行插入比逐行插入效率高得多:
INSERT INTO customers (name, email, level) VALUES ('张三', 'zhangsan@example.com', 1), ('李四', 'lisi@example.com', 2);一条语句插入多行,相当于一次网络往返处理多条数据,在批量导入场景里性能差距非常明显。
UPDATE和DELETE比较危险的地方在于,WHERE条件写错了会误伤大量数据。我自己处理过最惊险的一次:某同事在测试环境执行UPDATE语句时忘了加WHERE,整个表的数据全被改成同一个值,因为测试环境有快照才没有酿成大祸。从那以后我养成了两个习惯:第一,UPDATE和DELETE先写WHERE,再回头写表名和SET;第二,执行影响行数较多的操作前,先开启事务,确认无误后再提交。
另一个体现在实际工作中容易踩的坑是DELETE和TRUNCATE的区别。TRUNCATE是DDL,它会快速清空整表并且释放存储空间,还不会触发逐行的DELETE触发器;但正因为它不走逐行删除流程,所以无法通过WHERE指定要删除哪些行。如果你只想删部分数据,只能用DELETE;如果整个表都不要了、要重置自增ID,TRUNCATE更合适。
2.3 DQL:SELECT查询,几乎每个业务的核心
数据查询语言是SQL里知识点最多、最值得花时间研究的部分。一个典型的查询语句可以包含SELECT、FROM、WHERE、GROUP BY、HAVING、ORDER BY、LIMIT等几十个关键字组合,SELECT也可以嵌套在子查询里,甚至可以和JOIN、窗口函数混用,组合空间非常大。
关于SELECT的精讲,我在下一章单独展开。这里先建立一个认知:SQL最难的不是记住语法,而是能把业务需求翻译成查询语句的思维过程。同样是"找出未下过订单的用户",有人用NOT IN,有人用LEFT JOIN ... WHERE ... IS NULL,还有人用NOT EXISTS,三种写法结果可能相同,性能却天差地别。这个对比后面实战部分会具体分析。
2.4 DCL与TCL:权限和事务,运维才懂它们的价值
数据控制语言DCL主要包含GRANT和REVOKE,用来控制谁可以读、写、修改数据库对象。实际项目里,我们会给应用账号只读权限,给后台人员部分表的增删改权限,给DBA完整的DDL权限,这样即使某个账号泄露,损失也被限制在一定范围内。
事务控制语言TCL包含COMMIT、ROLLBACK、SAVEPOINT。事务的核心价值是"要么全部成功、要么全部失败"。转账场景是最典型的例子:扣款和入账两个操作必须同时成功,任何一个失败都要回滚,否则账就对不上了。我在写业务代码时,涉及多条写入的请求一律放进事务,并在异常分支执行ROLLBACK。SAVEPOINT稍微进阶一些,它允许在事务里设置一个回滚点,回滚时只回退到指定点,而不是撤销全部操作。这个技巧在复杂的批量处理逻辑中很好用,不过要控制使用频率,SAVEPOINT用得太多本身也是一种性能开销。
3. 深入SELECT:执行顺序决定一切
3.1 书写顺序与执行顺序的不一致,是新手第一个坎
很多初学者背SELECT语法背得很熟,但他们往往不理解为什么WHERE子句里不能用SELECT中定义的别名。这个问题的答案藏在SQL的执行顺序里。
书写一个查询时,我们习惯按SELECT -> FROM -> WHERE -> GROUP BY -> HAVING -> ORDER BY -> LIMIT来写。但数据库引擎真正执行的顺序是:FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT。
也就是说,SELECT别名是在WHERE、GROUP BY、HAVING这些子句之后才被算出来的。所以你在WHERE里写"WHERE别名 > 100",数据库根本不知道"别名"是什么,因为这一步在时间线上还没发生。相反,ORDER BY排在SELECT之后,所以ORDER BY里可以用别名。
下面用一个简单例子验证一下。假设有张订单表,我们要筛选出"下单数量大于2的用户",找出销售金额最高的三个:
SELECT user_id, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders WHERE status = 'paid' GROUP BY user_id HAVING COUNT(*) > 2 ORDER BY total_amount DESC LIMIT 3;这个语句在各子句上的执行顺序是:
- FROM orders:先确定数据从哪张表取。
- WHERE status = 'paid':过滤掉未支付的订单。
- GROUP BY user_id:把同一个用户的订单聚成一组。
- HAVING COUNT(*) > 2:分组之后,只保留下单次数大于2的组。
- SELECT user_id, COUNT(*), SUM(amount):计算各组的总金额。
- ORDER BY total_amount DESC:按总金额从高到低排序。
- LIMIT 3:只取前三行。
理解这个顺序最大的好处是排查SQL逻辑错误时会非常快。遇到"为什么WHERE里写不了别名""为什么GROUP BY之后SELECT里出现非聚合列就报错"这类问题,只要心里有一张执行顺序图,答案立刻就有了。
3.2 JOIN的取舍:内连接、左连接与表扫描的暧昧关系
多表查询是SQL进阶的分水岭。JOIN之所以让人困惑,是因为它把"行的匹配关系"和"哪些行会保留"搅在一起。我一般用集合来理解:内连接是两张表的交集,左连接是左表全集加上右表能匹配上的行,匹配不上用NULL填充。
先看一个内连接:
SELECT c.name, o.order_no, o.amount FROM customers c INNER JOIN orders o ON c.id = o.customer_id;它会返回所有下过订单的客户和对应的订单。如果一个客户从来没下过单,他就不会出现在结果里。这在"找出活跃用户"的时候非常合适。
再看一个左连接:
SELECT c.name, o.order_no, o.amount FROM customers c LEFT JOIN orders o ON c.id = o.customer_id;这次不管客户有没有订单,他都会出现在结果里,没有订单的客户,o.order_no和o.amount都是NULL。这个模式常用来找"没有订单的客户":
SELECT c.name FROM customers c LEFT JOIN orders o ON c.id = o.customer_id WHERE o.id IS NULL;WHERE o.id IS NULL就是在说:左连接之后,右表没有匹配上的行。同理,用NOT EXISTS也能实现同样的效果,而且在大表场景下性能往往更好:
SELECT name FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id );为什么NOT EXISTS更容易走好索引?因为它的逻辑是"找到一条就算数",数据库引擎往往能提前终止扫描;而LEFT JOIN + IS NULL可能要先构造出一个很大的中间结果,再过滤掉非NULL部分。这只是经验,具体哪个快还是要看执行计划和数据分布,所以我做SQL优化时不会教条地认定某一种写法"一定快"。
开发中还有一个高频问题:多张表JOIN时,ON条件里的过滤和WHERE里的过滤区别很大。ON条件决定两表匹配规则,WHERE决定JOIN之后结果集的过滤条件。左连接时如果你把左表过滤条件写进ON,很可能得到和预期不同的行数,排查时很容易一头雾水。我的经验是:JOIN关联条件放进ON,逻辑过滤条件放进WHERE,这个习惯能避免九成以上的关联查询疑难杂症。
3.3 GROUP BY与HAVING:分组统计的正确姿势
分组统计是写报表SQL最常用的能力。GROUP BY会把指定列中值相同的行归成一组,然后配合聚合函数COUNT、SUM、AVG、MAX、MIN来计算每组的信息。
这里最常见的一个误区是把WHERE和HAVING混用。WHERE是在分组之前对原始行做过滤,比如"只看已支付的订单"必须用WHERE;HAVING是在分组之后对聚合结果做过滤,比如"只看下单次数超过5次的用户"只能用HAVING。如果你把HAVING想成分组的WHERE,这两个就好区分了。
考虑这样一个需求:统计每月订单总额,只保留月销售额超过一万的月份,并按月份倒序排列。
SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, SUM(amount) AS total_amount FROM orders WHERE status = 'paid' GROUP BY DATE_FORMAT(created_at, '%Y-%m') HAVING SUM(amount) > 10000 ORDER BY month DESC;注意一个细节:SELECT里用了DATE_FORMAT(created_at, '%Y-%m')这个表达式,GROUP BY后面也必须跟同样的表达式,不能直接写"GROUP BY month"。这也是执行顺序决定的,因为GROUP BY在SELECT之前执行,那时month这个别名还不存在。很多数据库对此校验得很严格,但有些方言宽松一些,甚至能让你蒙混过关,一旦换数据库真实执行时会变得不可控。我建议在一开始就按严格的姿势写,别依赖特定数据库的宽容特性。
另外,如果字段里含有NULL,分组时NULL会被单独分到一组。这不算bug,但统计结果里猛然出现一个NULL分组时,要有这个意识,不然会以为数据出了问题。
4. 约束与索引:写在建表阶段的数据防线
4.1 主键、外键、唯一、检查与非空约束
约束是数据库自带的"数据完整性守卫"。什么数据能进表、哪些列必须填、哪些值不能重复,这些规则在建表阶段就应该明确,而不是等应用代码来兜底。
- PRIMARY KEY:每个表一个主键,唯一标识一行。主键列默认非空且唯一,索引也自动建立。
- UNIQUE:保证列或列组合的值唯一。比如用户的邮箱、手机号,通常都加UNIQUE约束。
- FOREIGN KEY:外键约束关联到另一张表的主键,它保证你引用的是一个真实存在的记录。比如订单表的customer_id引用customers表的id,如果没有这个约束,业务代码写得再小心,也可能插入一个不存在的customer_id。
- NOT NULL:禁止该列为NULL。业务含义上"必须有值"的字段就应该加上,比如订单金额、用户姓名。
- CHECK:定义取值范围或格式约束,比如金额必须大于0。早期的MySQL对CHECK约束执行不力,导致很多人以为它没用,其实在功能完善的数据库里CHECK是很有价值的前置防线。
外键约束是双刃剑。它可以防止脏数据,也会拖慢插入和更新速度,因为每写一条数据都要去父表校验一次。某些高并发核心链路会刻意不用外键,把完整性校验交给应用层处理,换取写入性能。我个人偏好在"数据一致性要求极高且写入并发又不疯狂"的表上使用外键;对于日志类、流水类大表,一般不用外键,改用索引和程序校验。
4.2 索引不是创建得越多越好
索引本质上是数据库为了加速数据检索而维护的一种额外数据结构,可以类比成书末的目录。没有索引时,查询要逐行扫描整张表,数据量一大就慢到不可接受;有索引后,数据库可以直接定位到匹配行所在的存储位置,查询速度快几个数量级。
但索引不是免费的。每增加一个索引,INSERT、UPDATE、DELETE时都要更新它,写入性能会下降;索引本身也会占用磁盘空间。有些开发者为了查询快,把常用WHERE条件列都建了索引,结果单表十几个索引,写入链路慢得离谱。我常建议的取舍方式是:
- 优先给WHERE、JOIN、ORDER BY频繁使用的列建索引。
- 区分度低的列,比如性别、状态字段,索引价值很低。
- 组合索引要考虑最左前缀原则,查询条件里没有包含组合索引最左侧的列,索引往往用不上。
- 不要给大文本字段直接建普通索引,必要时用前缀索引。
还有一类非常经典的索引失效问题:在索引列上做函数运算。比如WHERE YEAR(created_at) = 2024,即使created_at有索引,因为包了一层函数,数据库也无法直接用索引快速定位,只能退化为全表扫描。正确的姿势是用范围条件:WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'。这个坑几乎每个做过性能优化的人都遇到过,写SQL时多一点意识就能避免。
5. 把SQL串起来:一个在线订单系统的完整实战
5.1 表结构设计与建表语句
理论知识铺得差不多了,下面用一个虚构的在线商店场景把前面提到的东西串起来。这个场景我有意控制得简单,但足够演示建表、关联、分组、子查询这些核心能力。
假设我们要支撑一个包含用户、订单、订单明细的在线商店。用户信息放customers表,订单主档放orders表,每张订单买了哪些商品、数量多少、单价多少,放在order_items表。三段式结构是订单系统最常见的建模方式。
建表语句如下:
CREATE TABLE customers ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, email VARCHAR(128) NOT NULL UNIQUE, city VARCHAR(32), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, customer_id INT NOT NULL, order_no VARCHAR(32) NOT NULL UNIQUE, status VARCHAR(16) NOT NULL DEFAULT 'created', amount DECIMAL(10,2) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers(id) ); CREATE TABLE order_items ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, product_name VARCHAR(64) NOT NULL, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL, CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES orders(id) );amount字段用DECIMAL而不是FLOAT,是一个非常重要的细节。金额计算如果用FLOAT,可能会因为浮点误差出现0.30000000000000004这种结果,DECIMAL是精确小数类型,账务场景里是底线要求。quantity用INT,price用DECIMAL,为什么数量不用DECIMAL?因为商品数量不参与除法计算,整数足够,而且可以避免小数带来的歧义。
5.2 业务数据插入与更新
模拟几条数据。先插入两个客户,再插入两张订单,一张已支付、一张待支付,最后给订单加上明细。
INSERT INTO customers (name, email, city) VALUES ('张三', 'zhangsan@example.com', '上海'), ('李四', 'lisi@example.com', '北京'); INSERT INTO orders (customer_id, order_no, status, amount) VALUES (1, 'ORD202406001', 'paid', 199.00), (1, 'ORD202406002', 'created', 59.90), (2, 'ORD202406003', 'paid', 399.00); INSERT INTO order_items (order_id, product_name, quantity, price) VALUES (1, '机械键盘', 1, 199.00), (2, '鼠标垫', 1, 59.90), (3, '显示器', 1, 399.00);注意orders表有amount字段,order_items里也有price字段,这两处金额存在事实上的冗余。在这个设计里,订单金额是明细的总和快照,这样报表查询不用每次重新聚合明细。冗余本身不一定是坏的,关键是更新时维护好一致性:修改明细必须同步重算订单金额,否则账会不平。实际系统里还会在订单表上记录下单时的商品名称,防止商品改名后历史订单失真,这就是经典的"快照冗余"思路。
5.3 四个常用统计查询的SQL拆解
第一个需求:找出所有下过订单的用户及其订单金额。
SELECT c.id, c.name, o.order_no, o.amount FROM customers c INNER JOIN orders o ON c.id = o.customer_id;第二个需求:统计每个用户的累计消费金额,只看已支付订单,并按金额倒序。
SELECT c.name, SUM(o.amount) AS total_spent FROM customers c INNER JOIN orders o ON c.id = o.customer_id WHERE o.status = 'paid' GROUP BY c.id, c.name ORDER BY total_spent DESC;GROUP BY c.id之后为什么还要在SELECT里保留c.name?这涉及SQL的"分组列之外的字段必须被聚合"规则,不同数据库宽松度不一样,但标准的做法是把name也放进GROUP BY,或者用ANY_VALUE之类的聚合函数。为了兼容性和清晰的语义,我会把非聚合字段都放进GROUP BY。
第三个需求:找出从未下过单的用户。
SELECT c.name, c.email FROM customers c LEFT JOIN orders o ON c.id = o.customer_id WHERE o.id IS NULL;如果当前有2个用户,但只有1个用户下过单,这个查询会返回另一个没下单的用户。这种写法在业务里非常常用,理解它的关键是左连接后右表主键为NULL即代表无关联记录。
第四个需求:统计商品销量排行,找出被购买数量最多的商品。
SELECT product_name, SUM(quantity) AS total_qty FROM order_items GROUP BY product_name ORDER BY total_qty DESC;这四个查询覆盖了单表查询、多表关联、聚合统计、连接后过滤四类典型场景,基本把入门SQL的核心操作都练到了。建议你看着表结构,先自己写一遍,再对照我这里的写法,看差异在哪里。尤其注意执行顺序对WHERE和HAVING位置的影响。
6. 我踩过的SQL坑,以及给初学者的4条建议
6.1 三个容易翻车的SQL细节
第一个是COUNT()和COUNT(column)的区别。COUNT()统计结果集的总行数,包括NULL行;COUNT(column)只统计该列非NULL的行数。有时候你只想统计有效订单数,却写了COUNT(amount),恰好有些订单的amount是NULL,表面对不上账,半天排查不出来。按我现在的习惯,统计总行数一律用COUNT(*),统计非空值数才用COUNT(column)。
第二个是比较日期时忽略时间部分。一个DATETIME列存的是"2024-06-01 12:30:00",你写WHERE created_at = '2024-06-01',在严格模式下是匹配不到的。正确做法是用范围条件:created_at >= '2024-06-01' AND created_at < '2024-06-02'。同理,统计某天数据时,只对日期列做函数转换再比较,往往会让索引失效,能不用函数就尽量不用。
第三个是整数除法的精度问题。在部分数据库里,SELECT 5 / 2返回2而不是2.5,因为两个整数相除结果还是整数。金额计算里这种问题尤其危险,单价、数量的除法一不小心就丢精度。解决办法是让至少一个操作数是小数,比如5 / 2.0,或者用CAST(col AS DECIMAL(10,2))。这个坑在写次数统计、平均值计算时特别常见,我在评审同事的SQL时已经不止一次抓到过。
6.2 学习SQL的正确路径,和避免的低效方法
我给初学者的建议一直很固定:不要只看教程,要上手做。SQL是一门需要肌肉记忆的技能,看十遍不如写一遍。搭建一个数据库,准备一组模拟数据,把增删改查、关联、分组、嵌套查询全部实际操作一遍,这个过程走完,基础就算扎实了。
之后务必学会看执行计划。大多数数据库都提供了EXPLAIN命令,它会告诉你一条SQL走了什么索引、扫描了多少行、有没有文件排序。我第一次意识到"我的SQL还能再快十倍"就是从看执行计划开始的。不看执行计划写SQL,就像不看仪表盘开车,全凭感觉。
还要刻意练习把业务需求翻译成SQL。这里有个很核心的能力,就是先画数据流:先了解涉及哪些表、表之间怎么关联、结果需要怎么聚合,再落笔写SQL。很多复杂需求其实可以拆成几个子查询串起来,不用非得一个嵌套套到底。如果一条SQL写了特别多层嵌套、代码难以阅读,我的建议是拆开,用临时表或公共表表达式,可读性和可维护性反而更好。
这里多说一句,公共表表达式是JOIN和子查询之外很值得掌握的一个能力。它可以把一段复杂的查询块先命名,再在后续查询里引用,逻辑清晰且便于调试。虽然本文是概述篇,没有大篇幅展开,但这个点确实值得你在学完基础语法后尽早接触。
至于要避免的低效方法,第一是死记硬背方言细节。不同数据库的日期函数、分页语法各不相同,背得再熟换个数据库就废了,不如把标准SQL的执行逻辑和业务建模思路吃透。第二是迷信"一条SQL解决所有问题"。有些需求用一条嵌套到天荒地老的SQL硬写出来,执行效率低,还很难维护,不如拆成多条简单SQL在应用层做组合,整体会清晰很多。
SQL这门语言最迷人的地方在于,它让你用声明的思维操作数据:你告诉数据库"我要什么",而不是"怎么去取"。入门时抓住这个思维转变,后面所有深入学习都会顺理成章。数据库系统的后续内容里,事务隔离级别、存储引擎、执行计划这些主题都会频繁用到本文的基础概念,把SQL这一锤子敲实,后面听课和实战都会轻松很多。