
SQL数据库学习:从基础查询到多表关联的实用入门
SQL 是操作关系型数据库的常用语言。学会写查询,不只是记住几条语句,还要理解数据如何组织、结果集如何形成,以及查询在真实项目中的边界。本文使用 SQLite 作为示例数据库,逐步介绍基础查询、条件过滤、分组统计和多表关联,并说明参数绑定、空值、分页与索引等容易出错的地方。
示例代码可以在 SQLite 命令行、图形化数据库客户端或 Python 的 sqlite3 模块中运行。SQLite 适合本地工具、原型、小型应用和学习;生产系统也常使用 MySQL、PostgreSQL、SQL Server 等数据库。它们共享许多 SQL 基础语法,但数据类型、函数、分页方式、并发行为和管理能力并不完全相同。迁移到其他数据库时,应以目标数据库文档为准,而不是假定所有 SQL 都可直接通用。
一、先建立一组可练习的数据
关系型数据库通常把数据放在表中。每张表由行和列组成:行代表一条记录,列代表记录的属性。下面创建顾客、商品、订单和订单明细四张表。订单明细单独存放,是因为一张订单可能包含多个商品,一个商品也可能出现在多张订单中。
PRAGMA foreign_keys = ON;
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS customers;
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
city TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
price REAL NOT NULL CHECK (price >= 0)
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
order_date TEXT NOT NULL,
status TEXT NOT NULL,
FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
);
CREATE TABLE order_items (
order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price REAL NOT NULL CHECK (unit_price >= 0),
PRIMARY KEY (order_id, product_id),
FOREIGN KEY (order_id) REFERENCES orders(order_id),
FOREIGN KEY (product_id) REFERENCES products(product_id)
);
INSERT INTO customers (customer_id, name, city, created_at) VALUES
(1, '林晓', '上海', '2024-01-10'),
(2, '周宁', '杭州', '2024-02-03'),
(3, '陈可', NULL, '2024-03-18');
INSERT INTO products (product_id, name, category, price) VALUES
(101, '键盘', '外设', 299.00),
(102, '鼠标', '外设', 129.00),
(103, '显示器', '显示设备', 1499.00),
(104, '支架', '配件', 89.00);
INSERT INTO orders (order_id, customer_id, order_date, status) VALUES
(1001, 1, '2024-04-01', 'paid'),
(1002, 1, '2024-04-08', 'shipped'),
(1003, 2, '2024-04-10', 'cancelled');
INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES
(1001, 101, 1, 299.00),
(1001, 102, 2, 129.00),
(1002, 103, 1, 1499.00),
(1003, 104, 1, 89.00);
示例用 INTEGER PRIMARY KEY 作为整数主键。在 SQLite 中,这种写法具有特殊的行标识行为;其他数据库的自增主键定义可能不同。NOT NULL 表示必须提供值,CHECK 用于限制列值,外键则描述表之间的引用关系。SQLite 默认并不总是启用外键检查,因此示例显式执行了 PRAGMA foreign_keys = ON;使用其他数据库时,外键的启用和配置方式应查看对应文档。
二、用 SELECT 读取需要的数据
最基本的查询由 SELECT 和 FROM 组成。正式业务代码一般应明确列名,而不是长期依赖星号。
SELECT customer_id, name, city
FROM customers;
SELECT * 会返回当前表中的所有列,适合快速查看数据,但在应用代码中有几个隐患:表新增列后返回结构可能改变;无用的大字段会增加传输和处理成本;关联多张表时,同名列也容易造成歧义。选择实际需要的列,通常更清晰,也更利于维护。
可以用 AS 给列起一个结果别名。别名只影响查询结果中的显示名称,不会修改表结构。
SELECT
product_id,
name AS product_name,
price AS unit_price
FROM products;
查询结果本身不保证顺序。即便多次运行看起来顺序相同,只要 SQL 没有明确排序条件,就不应依赖这个顺序。
三、用 WHERE 筛选行
WHERE 用于筛选符合条件的行。常用比较运算符包括 =、<>、>、>=、< 和 <=。字符串值需要用单引号括起来。
SELECT product_id, name, price
FROM products
WHERE price >= 100
AND category = '外设';
AND 表示条件同时成立,OR 表示至少一个条件成立。混用时,建议用括号明确意图,避免读者误解运算优先级。
SELECT order_id, customer_id, status
FROM orders
WHERE (status = 'paid' OR status = 'shipped')
AND order_date >= '2024-04-01';
IN 可以判断某个值是否属于一组候选值,BETWEEN 用于闭区间判断,也就是包含两端值。
SELECT order_id, status
FROM orders
WHERE status IN ('paid', 'shipped');
SELECT product_id, name, price
FROM products
WHERE price BETWEEN 100 AND 500;
LIKE 用于简单的模式匹配。在 SQLite 中,% 表示任意长度字符,_ 表示一个字符;大小写匹配规则可能受数据库和排序规则影响,不应把它当成统一的全文搜索方案。
SELECT customer_id, name
FROM customers
WHERE name LIKE '林%';
四、NULL 不是普通的空值
NULL 表示缺失、未知或未提供的数据。它不是空字符串,也不等于数字 0。不能用 city = NULL 判断空值,而应使用 IS NULL 或 IS NOT NULL。
SELECT customer_id, name
FROM customers
WHERE city IS NULL;
SELECT customer_id, name
FROM customers
WHERE city IS NOT NULL;
涉及 NULL 的比较通常会得到“未知”,而不是简单的真或假。这会影响 WHERE 条件:只有结果为真的行才会保留。例如 city <> '上海' 不会选出城市为 NULL 的顾客。若业务要求把缺失值也纳入,应明确补充条件,例如:
SELECT customer_id, name
FROM customers
WHERE city <> '上海'
OR city IS NULL;
可以用 COALESCE 将空值替换成备用值,但替换结果只影响表达式,不会修改表里的原始数据。
SELECT name, COALESCE(city, '未填写') AS city_label
FROM customers;
五、排序、去重与分页
ORDER BY 对结果排序。默认是升序,可以使用 DESC 指定降序。分页查询时,排序尤其重要:没有稳定排序,翻页过程中可能出现重复记录或遗漏记录。
SELECT product_id, name, price
FROM products
ORDER BY price DESC, product_id ASC;
第二个排序字段 product_id 用来打破价格相同的并列关系。可靠的分页排序最好包含一个能让顺序稳定的唯一键,或其他足以确定顺序的字段组合。
DISTINCT 会去除选定列组合中重复的结果行。它不是“随便取一条重复记录”的指令;如果选了多列,只有这些列的组合完全相同才会被视为重复。
SELECT DISTINCT category
FROM products
ORDER BY category;
SQLite 使用 LIMIT 限制结果数量,也可配合 OFFSET 跳过前面的行。
SELECT product_id, name, price
FROM products
ORDER BY product_id
LIMIT 2 OFFSET 1;
大页码分页时,数据库仍可能需要扫描或跳过大量记录,具体成本取决于查询、索引和执行计划。数据量较大时,可以考虑基于上一页最后一条记录的位置继续查询,即游标式分页。其实现取决于排序规则和字段组合,不能简单把所有场景都替换成固定模板。
传给 LIMIT 或 OFFSET 的值应是非负整数。SQLite 支持在这些位置使用参数占位符;其他数据库对语法及参数类型的支持可能不同。若用户输入页码,应在应用层校验范围并转换成整数,不能把未经校验的文本拼接进 SQL。
六、聚合函数与分组统计
聚合函数可以对多行数据进行计算。常见函数有 COUNT、SUM、AVG、MIN 和 MAX。
SELECT
COUNT(*) AS product_count,
AVG(price) AS average_price,
MIN(price) AS lowest_price,
MAX(price) AS highest_price
FROM products;
COUNT(*) 统计符合条件的行数;COUNT(column) 只统计该列非 NULL 的行。因此,如果某列允许为空,两者结果可能不同。SUM、AVG 等函数通常会忽略空值;具体边界应按目标数据库文档确认。
使用 GROUP BY 可以按一个或多个字段分组,再对每组计算统计值。
SELECT category, COUNT(*) AS product_count, AVG(price) AS average_price
FROM products
GROUP BY category
ORDER BY product_count DESC;
WHERE 在分组前筛选原始行;HAVING 在分组后筛选聚合结果。下面的查询先限定商品类别,再只保留商品数量至少为两件的类别。
SELECT category, COUNT(*) AS product_count
FROM products
WHERE price > 0
GROUP BY category
HAVING COUNT(*) >= 2;
一个常见错误是在含有聚合的查询中,随意选择未分组、也未聚合的列。不同数据库对这类写法的接受程度和返回含义可能不同。稳妥做法是:结果中的普通列放进 GROUP BY,或者明确使用合适的聚合函数。也要注意,给聚合列起的别名是否能在 HAVING 中使用,可能因数据库而异;直接写聚合表达式更可移植。
七、理解 INNER JOIN 与 LEFT JOIN
多表关联的关键是确定两张表通过什么字段匹配。订单表的 customer_id 引用顾客表,订单明细的 product_id 引用商品表。JOIN 条件写在 ON 后面。
INNER JOIN 只返回两边都匹配的行。下面查询每张订单及其顾客姓名:
SELECT
o.order_id,
o.order_date,
o.status,
c.name AS customer_name
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
ORDER BY o.order_date, o.order_id;
表别名 o 和 c 可以缩短字段引用。多表查询中,最好始终写出字段所属的表,例如 o.status,避免同名字段造成歧义。
LEFT JOIN 保留左表中的所有行;如果右表没有匹配项,右表字段会以 NULL 返回。它适合查找“有订单和没有订单的顾客”等场景。
SELECT
c.customer_id,
c.name,
o.order_id
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
ORDER BY c.customer_id, o.order_id;
要找出没有订单的顾客,可以检查右表中一个不允许为空的主键是否为 NULL:
SELECT c.customer_id, c.name
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;
这里有一个容易忽略的边界:对 LEFT JOIN 右表的筛选条件放在 WHERE 中,可能会把未匹配行过滤掉,从而让结果表现得像内连接。例如,如果把 WHERE o.status = 'paid' 加到上一个顾客查询中,没有订单的顾客会因 NULL 比较而被排除。若目标是“保留所有顾客,只关联已付款订单”,应把状态条件放进 ON:
SELECT c.customer_id, c.name, o.order_id
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
AND o.status = 'paid';
关联多张表时,行数可能因为一对多关系而增加。下面把订单、明细和商品连接起来,列出订单中的商品:
SELECT
o.order_id,
p.name AS product_name,
oi.quantity,
oi.unit_price,
oi.quantity * oi.unit_price AS line_total
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
JOIN products AS p
ON oi.product_id = p.product_id
ORDER BY o.order_id, p.product_id;
在订单与订单明细关联后,一张包含多个商品的订单会出现多行,这是关系本身的结果,不是重复数据。如果把订单总价直接写成 SUM(o.some_amount),而订单金额在关联后重复出现,就可能被重复累计。汇总前要先确认当前结果粒度:是一行代表一张订单、一件明细,还是一个顾客。需要统计订单金额时,可从明细表按订单分组计算,再与订单表关联,或确保聚合基于正确的明细列。
八、用参数化查询处理外部输入
应用程序通常需要按用户输入查询数据。不要把输入直接拼接进 SQL 字符串。应使用数据库驱动提供的参数绑定接口:SQL 结构与参数值分开传递,驱动负责按规则处理参数,可避免把参数值误当成 SQL 代码执行。这是防止 SQL 注入的重要措施,但不能替代权限控制、输入校验、错误处理和安全的数据库配置。
以下是可运行的 Python 示例,使用标准库 sqlite3 查询商品价格下限:
import sqlite3
connection = sqlite3.connect("shop.db")
connection.row_factory = sqlite3.Row
minimum_price = 100
rows = connection.execute(
"""
SELECT product_id, name, price
FROM products
WHERE price >= ?
ORDER BY price DESC, product_id
""",
(minimum_price,)
).fetchall()
for row in rows:
print(row["product_id"], row["name"], row["price"])
connection.close()
这里的 ? 是 SQLite 的位置参数占位符,参数以序列传入。单个参数也要写成只有一个元素的元组,因此末尾的逗号不能省略。Python 的 SQLite 驱动还支持命名参数等形式;其他语言和数据库驱动可能使用不同占位符,例如 %s、:name 或编号占位符,应按实际驱动文档使用,不能将一种写法机械移植到所有环境。
参数通常用于传递数据值,不用于动态替换表名、列名、关键字或排序方向。下面这种需求不能依靠把列名当成普通值参数来解决:
SELECT name, price
FROM products
ORDER BY ?;
在支持这种语法的数据库里,占位符通常表示一个常量值,而不是列名,因此它不能实现预期的动态排序。若要允许用户选择排序字段,应在应用代码中将输入映射到固定的白名单标识符,再生成 SQL;排序方向也应限制为允许的选项。不要直接把用户提供的列名或 SQL 片段拼接进去。
参数绑定也不是所有输入校验的替代品。例如,搜索框仍可能需要长度限制和业务规则校验;分页数量仍应校验为合理的正整数;用户能访问哪些记录,则必须在查询条件和权限设计中明确控制。
九、常见错误与排查顺序
- 忘记写关联条件。 多表查询若没有正确的
ON条件,可能形成大量行的组合,结果数量异常增大。逐一检查关联字段是否正确、数据类型是否匹配,并确认关联关系的方向。 - 把 NULL 当成普通值。 使用
= NULL通常得不到预期结果。判断缺失值要用IS NULL;还要检查聚合和LEFT JOIN是否产生了空值。 - 分页却没有排序。 数据库不承诺未排序结果的顺序。分页查询需要明确的、尽量稳定的排序条件。
- 在多表查询中漏写表名。 同名列会造成歧义,最好为表取简短别名,并在字段前加上表别名。
- 混淆 WHERE 与 HAVING。 普通行条件放在
WHERE;聚合分组结果的条件放在HAVING。 - 误把 DISTINCT 当成修复工具。 它可能掩盖关联条件错误或粒度理解错误。先查明行数为何增加,再决定是否需要去重。
- 用字符串拼接用户输入。 参数化查询用于绑定数据值。动态标识符必须经过白名单映射,不能直接信任外部输入。
- 假定不同数据库语法完全相同。 日期处理、分页、自动生成主键、字符串函数和参数占位符都可能有差异。跨库开发时应测试实际目标数据库。
十、索引入门:让常见筛选更有效
索引可以帮助数据库更快定位数据,但会占用存储空间,也会增加插入、更新和删除时维护索引的成本。是否使用索引、具体如何执行查询,取决于数据规模、查询条件、索引设计和数据库优化器;不能因为某列出现在 SQL 中,就认定加索引一定更快。
外键字段、经常用于筛选或关联的列,是评估索引时可以优先考虑的对象。示例中可以为订单的顾客编号和日期建立索引:
CREATE INDEX idx_orders_customer_id
ON orders(customer_id);
CREATE INDEX idx_orders_date
ON orders(order_date);
如果常见查询同时按顾客编号筛选并按日期排序,也可以评估组合索引:
CREATE INDEX idx_orders_customer_date
ON orders(customer_id, order_date);
组合索引的列顺序会影响可用场景。以 (customer_id, order_date) 为例,它通常更适合先按 customer_id 查找,再进一步使用日期条件的查询;不应假定它与分别建立两个单列索引效果相同。索引选择应结合真实查询测试。
SQLite 可以用 EXPLAIN QUERY PLAN 查看查询计划,辅助了解是否使用了索引。它显示的是计划信息,不是性能保证;要评估实际速度,还需要使用具有代表性的数据和工作负载进行测试。
EXPLAIN QUERY PLAN
SELECT order_id, order_date
FROM orders
WHERE customer_id = 1
ORDER BY order_date;
十一、把查询放进事务中
当多条写操作必须作为一个整体成功或失败时,应使用事务。例如创建一张订单和对应的多条明细,如果中途失败,应回滚已经完成的写入,避免只保存部分数据。事务的具体隔离级别、锁行为和并发能力因数据库而异。
BEGIN;
INSERT INTO orders (order_id, customer_id, order_date, status)
VALUES (1004, 2, '2024-04-15', 'paid');
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (1004, 101, 1, 299.00);
COMMIT;
如果执行过程中发生错误,应由客户端回滚事务,或让事务管理机制处理,而不是继续提交不完整的数据。Python 的 sqlite3 模块通常也通过连接对象管理事务;具体提交和回滚方式可根据所用驱动的行为选择。
十二、建议的练习路线
- 先从单表查询开始:练习选择列、比较条件、
IN、LIKE、排序和限制行数。 - 专门练习空值:分别比较
IS NULL、COALESCE、COUNT(*)与COUNT(column)。 - 练习统计:按商品类别统计数量和均价,再加入
HAVING条件。 - 练习关联:分别写出订单与顾客的内连接、顾客与订单的左连接,以及查找无订单顾客的查询。
- 先确认查询的行代表什么,再写聚合。订单关联明细后,一行通常代表一条明细,不再是一张订单。
- 将示例放进应用代码,用参数绑定传递输入;另外测试非法页码、空值和不存在的关联记录。
SQL 入门的核心不是把所有关键字背下来,而是准确描述想要的数据:从哪些表取数据、表之间如何匹配、哪些行要保留、结果按什么粒度汇总,以及怎样稳定排序。把这些问题先说清楚,再写查询并检查结果行数,通常比盲目调整语句更有效。掌握单表查询、NULL 语义、聚合和连接之后,就已经具备处理许多日常数据读取任务的基础;性能优化和数据库差异则应结合实际目标数据库继续学习与验证。




