零到全栈 · 课程
模块 6.4:数据库正传——SQLite 与 SQL
看数据库如何又快又好地解决存储和查询过程中的各种问题
七步之内必有解药
上一节我们自己手搓文件存储,埋下了不少问题:效率问题、并发问题、健壮性问题。
但是,这些问题并不是我们项目特有的——任何人只要用“一个文件”去存不断增长的结构化数据,都会不可避免地遭遇这些问题。
在计算机领域,如果是人人都可能会遇到的问题,那么请大家相信——七步之内必有解药:一定有人做好了专门的东西,来替我们解决它(5.4 的框架如此、6.1 的库如此,今天也一样)。
这个解药就是数据库,数据库就是专门负责“把数据存好、帮助我们快速查询数据”的软件。上一节那些痛,全是它的本职工作。
数据库的类型
目前,市面上已经有太多的数据库软件了,有许多你可能听过,比如说 MySQL、PostgreSQL、ClickHouse、MongoDB、DuckDB、SQLite、Redis 等等
这些数据库不只是厂商不同与名字不同,它们的底层实现逻辑也可能会有较大差异,擅长的业务类型也会有差异,对于零基础的朋友们,我们可以先不用在这些差异上深入。
但是我们仍然可以给它们大致用两个维度进行分类:从数据模型上,可以分为关系型与非关系型;从服务方式上,可以分为 嵌入式与服务式。
关系型 vs 非关系型
先看第一个维度——关系型 与 非关系型。它俩的区别,主要体现在存储的数据的形态是什么样。
数据的形态大概分为三种:
- 结构化——有固定的字段结构,每一行记录一条数据,数据可以整整齐齐摆进一张表格。这种就是最典型的结构化数据。
- 半结构化——有结构,但比较松散、允许每条数据都长得不完全一样,代表就是 JSON。
- 非结构化——没有行列结构,数据要么是一段大文本,要么是一张图片或者一段视频。
数据什么形态,就配什么库:
- 结构化数据最规整,最趁手的就是关系型数据库——把数据摆成一张张规整的表,这些表的行列固定,每列还规定了类型,支持用一种叫做 SQL 的通用语言去增删改查;
- 半结构化数据交给非关系型里的文档型数据库——存进去的东西长得就像 JSON,比较典型的是 MongoDB;
- 还有些特殊打法也归非关系型,比如需要按"名字 → 值"极速存取、专做缓存的键值型,比较典型的就是
Redis,数据主要放内存里,读写都很快; - 非结构化(图片、视频)一般不塞进数据库,而是丢进对象存储。
关系型数据库对格式的要求更严格,强调数据规范,可以用一种标准的 SQL 语法进行操作。非关系型数据库也叫 NoSQL,意思是 “Not Only SQL”,它对数据的要求更为松散和灵活。这两类数据库它有专长,不存在哪种更高级,只是为不同场景而生的不同工具。
开头列的那一串数据库,可以按这个维度归类如下:
- 关系型:
MySQL、PostgreSQL、SQLite,ClickHouse、DuckDB; - 非关系型:
MongoDB(文档)、Redis(键值)。
嵌入式 vs 服务式
再看第二个维度——嵌入式 与 服务式,它们的区别在于数据库跟我们的程序是什么关系。
- 服务式:数据库是独立的常驻程序,它需要被安装,需要被启动,启动之后是一个独立的进程,持续监听某个端口,在这个端口上 7×24 等人连接(
MySQL守 3306、PostgreSQL守 5432,就和我们的 FastAPI 守着 8000 端口一样)。我们的程序可以通过网络去连它。 - 嵌入式:这是一种更轻的模式,这类数据库本质就是一个库、一个文件,不用单独启动、也不占端口。
SQLite就是典型——整个数据库就是硬盘上一个.db文件,Python 标准库自带了 sqlite 的库,只需要import sqlite3就可以用。
这两类数据库也是各有千秋。服务式更强,能够支持多个应用同时连,权限控制精密,也可以扛高并发,支持主从备份,数据集群,支持跨网络访问,所以主流的生产环境的网站后端会用它们做数据库。
但是嵌入式也不差劲,比如说 SQLite 就很普及,它普及到什么程度?我们手机里此刻就躺着几十个 SQLite 文件——微信的聊天记录、浏览器的历史,底下都是它。
同样把开头列的那串数据库归归类:
- 嵌入式:
SQLite、DuckDB; - 服务式:
MySQL、PostgreSQL、MongoDB、Redis、ClickHouse。
基于需求选择用什么数据库
现在我们认识了这么多数据库,那么我们这个文字实验室该用哪一个呢? 我们先从需求出发来看一看。
先看存的是什么数据。文字实验室的历史记录——原文、分数、标签、时间,每条都这几样、整整齐齐。这是最规整的结构化数据,我们优先选择关系型数据库。
再看有多大规模、什么场景。文字实验室是一个小项目,不需要多个应用共享同一个库,也没有高并发压力,更不想为了存点数据、单独去养一个 7×24 的常驻服务,嵌入式就很合适(一个文件搞定,零维护)。
关系型 + 嵌入式,两个条件一交叉,落点就非常清楚了——SQLite。
DuckDB 也沾这两条边,但它更偏"数据分析",而我们做的是日常增删改查,SQLite 更对口;何况它 Python 自带、最成熟。
所以这一节我们就用 SQLite:零安装(标准库自带)、单文件(整个库就一个 .db,不用多养服务,模块 7 部署的时候更能感受到它的优势)。未来如果想要迁移到 MySQL / PostgreSQL,也比较方便,因为它们都可以用 SQL 语言来操作(虽然不同的关系型数据库会有不同的“方言”)。
体验 SQLite 和 SQL 语言
我们可以用 python 的 REPL 模式快速体验一下 SQLite。这一节的东西都放在家目录里做,先 cd 到家目录,再进 REPL:
cd ~ # 先回到家目录,等下的 test.db 就建在这儿
python3
第一步:造一个数据库
import sqlite3
conn = sqlite3.connect("test.db") # 没有就创建——就建在当前目录(我们刚 cd 进的家目录)
cur = conn.cursor() # cursor:往下递 SQL 的“手柄”,固定搭配
此时如果打开家目录,就发现已经有了一个 test.db 文件。一个数据库,就是硬盘上这么一个文件,这个就是 SQLite 的工作方式。
第二步:建一张表(CREATE TABLE)
SQLite 是关系型数据库,而关系型数据库是围绕“表”的。所以上手第一件事,就是学怎么建一张表。
在数据库里定义一张表,有两个事情必须要明确,一个是表叫什么名字,另一个是这个表的每一个字段(列)叫什么、是什么类型。对应的 SQL 语法就是:
CREATE TABLE 表名 (字段名 字段类型, 字段名 字段类型, ……)
字段类型就是关系型数据库“严格”的地方——在建表的时候,需要先把每个列的类型确定下来,然后往里放的东西就得守规矩。不同数据库支持的类型不完全一样,但大致就那么几类——文本、数字、日期时间、布尔(数字还会再细分整数和小数)。把三个主流数据库的常见类型摆一起认个脸:
| 类别 | MySQL | PostgreSQL | SQLite |
|---|---|---|---|
| 整数 | INT、BIGINT | INTEGER、BIGINT | INTEGER |
| 小数 | DECIMAL、FLOAT、DOUBLE | NUMERIC、REAL | REAL |
| 文本 | VARCHAR、TEXT | VARCHAR、TEXT | TEXT |
| 日期时间 | DATE、DATETIME、TIMESTAMP | DATE、TIMESTAMP | 无专门类型,用 TEXT 存 ISO 字符串 |
| 布尔 | TINYINT(1)/BOOLEAN | BOOLEAN | 无专门类型,用 INTEGER 的 0/1 代替 |
很容易可以看得出 SQLite 的类型系统最精简:翻来覆去就 INTEGER / REAL / TEXT(外加一个装二进制的 BLOB)。它连“日期时间”“布尔”的专门类型都没有,布尔就用 0/1 代替。别嫌它简陋,这份精简正是它能塞进我们手机的原因,也是能够支持“一个文件就是一整个库”的原因。
接下来我们尝试建一张表。 假设我们要用这个表来存电影信息,就叫它 films。一部电影记这么几样信息——名称、语言、上映时间、创建时间。另外,我们再给它加一个唯一的 id 字段,这个字段用自增的数字。
这个建表的 SQL 语句就可以这样写:
CREATE TABLE films (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
language TEXT,
release_date TEXT,
created_at TEXT
)
列名用英文是数据库里的通行习惯:title=名称、language=语言、release_date=上映时间、created_at=创建时间。
除了 id 字段外,其余都是 TEXT 类型。你可能会问:release_date、created_at 明明是日期时间,怎么也用 TEXT?——正是因为刚才表格里说的,SQLite 没有专门的日期时间类型,所以日期时间在这里就当成一串文字来存,比如上映日期 '1994-09-23',或者带上时分秒的一串时间。这串时间具体长什么样、由谁生成,下一步插入数据时就见到。
id 字段的定义 id INTEGER PRIMARY KEY AUTOINCREMENT 这个值得说一下。首先,id 就是字段名,INTEGER 是字段类型,这个是整数数字的意思。AUTOINCREMENT 可以让数据库自动给每条记录发一个递增的“编号”,比如 1、2、3,至于 PRIMARY KEY,这个是主键的意思,你现在不理解也没事。
这就是我们用于建表的 SQL 语句。这个语句有时候也会被写成:
CREATE TABLE IF NOT EXISTS films (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
language TEXT,
release_date TEXT,
created_at TEXT
)
多了一个 IF NOT EXISTS, 意思是如果现在数据库里不存在 films 表的话才创建,如果已经存在就不创建了。(但实际上就算不写这句,如果已经存在 films 表的话,是不会再建一个新的 films 表的)
但是,一个裸的 SQL 语句无法直接执行,我们需要用 python 的 cur 帮我们执行。所以在 REPL 中执行的时候可以写成下面这样,把 SQL 语句包在 cur.execute() 之中(整段直接粘进 REPL 就行):
cur.execute("""
CREATE TABLE films (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
language TEXT,
release_date TEXT,
created_at TEXT
)
""")
敲回车后 REPL 会回显一个 Cursor 对象,不用管它。如果执行没有报错,这张 films 表就写进了刚才家目录里那个 test.db。
第三步:往 films 表里插入一行数据(INSERT)
向一个表里插入一条数据的 SQL 语句是
INSERT INTO 表名 (字段名, 字段名, 字段名, ……) VALUES (值, 值, 值, ……)
所以,我们如果向 films 表中插入一行数据,可以这样写:
INSERT INTO films (title, language, release_date, created_at) VALUES ('肖申克的救赎', '英语', '1994-09-23', datetime('now'))
这里需要注意的是文字的值需要用单引号裹起来,因为片名、语言、日期都是文字,所以都得裹。但是如果是数字就可以不用裹。另外,因为 id 是自增的,我们不需要给它赋值——插入数据的时候 id 字段的值会自动生成,这就是自增的意义。
至于最后那个 created_at(创建时间),我们不想手写死一个时间。SQLite 自带了一个取当前时间的函数 datetime('now')——注意它没有单引号,因为它不是一个文字值,而是一个"函数调用":让数据库在插入的那一刻自己算出当前时间填进去(默认按 UTC 计算)。
真正执行的时候,把这句 SQL 包进 cur.execute():
cur.execute(
"INSERT INTO films (title, language, release_date, created_at) "
"VALUES ('肖申克的救赎', '英语', '1994-09-23', datetime('now'))"
)
conn.commit()
但是,请注意,这次在执行 cur.execute() 之后,还执行了 conn.commit(),为什么要这样呢?
因为执行完 conn.commit() 这一步,才算是把改动真正落盘。 它有个正经名字叫提交事务——就是上一节讲并发安全时点过名的“事务”,这是我们头一回用到它。如果执行了 cur.execute() 但是没执行 commit 就退出,改动就不算数。
commit 之后,数据就是真正写入到数据库了,哪怕退出 REPL、重进、再查,这一行还在——因为它正式进了 test.db 这个文件。
第四步:查数据(SELECT)
查询数据的 SQL 语句比较简单,如果想要从一个表中查询到所有的数据记录,可以用这样的 SQL 语句:
SELECT * FROM 表名
在 python 中执行查询语句查 films 表中的全部数据也需要通过 cur.execute()
cur.execute("SELECT * FROM films").fetchall()
语句末尾的 fetchall() 是把结果一次性拿成一个列表。
REPL 直接回显(created_at 是 datetime('now') 算出的当前时间,默认按 UTC 算,所以会和本机时钟差几个时区、也和这里不一样,都正常):
[(1, '肖申克的救赎', '英语', '1994-09-23', '2026-08-17 17:07:33')]
注意开头那个 1 就是这一行的 id, 我们从没填过,是 id 自动发的身份证。
照着上面的写法,再插两条(created_at 同样交给 datetime('now')):
cur.execute(
"INSERT INTO films (title, language, release_date, created_at) "
"VALUES ('千与千寻', '日语', '2001-07-20', datetime('now'))"
)
cur.execute(
"INSERT INTO films (title, language, release_date, created_at) "
"VALUES ('让子弹飞', '汉语', '2010-12-16', datetime('now'))"
)
conn.commit()
这样插入之后,数据库中就有了三条记录,如果想要查询其中一条,可以通过 WHERE 子句:
cur.execute("SELECT * FROM films WHERE language = '日语'").fetchall()
[(2, '千与千寻', '日语', '2001-07-20', '2026-08-17 17:07:35')]
WHERE 就是筛选:给某一列出个条件,只把符合的挑出来。上面这句里面,因为我们指定了 WHERE language = '日语', 于是就把这三部电影中日语片给检索了出来。
第五步:删一行(DELETE)
删除一条记录的 SQL 语句是
DELETE FROM 表名 WHERE 条件表达式
比如说我们想要删除 id=2 的这一条记录,就可以用 cur.execute() 来执行下面这个 SQL 语句
cur.execute("DELETE FROM films WHERE id = 2")
conn.commit()
DELETE ... WHERE ... 把符合条件的行删掉。WHERE 千万别漏——如果执行的是 DELETE FROM films 不带条件,是把整张表的数据全清空。
另外,如果要执行删除,也需要再带一句 conn.commit()
顺带再认两个 SQL 语句,不演示:
UPDATE,是改某行的值DROP TABLE是 整张表连结构一起删掉(江湖上说的“删库跑路”,说的就是这类操作——认得它们,敬畏它们)
SQL 注入
停下来,看一眼刚才每一句 SQL 的本质:我们递给数据库的,其实是一段文本。数据库拿到后,得先解析这段文本,才知道我们要干嘛。它需要区分 哪些词是命令(SELECT、INSERT)、哪个是表名(films)、哪些是值。
而 值,是靠一对单引号来分界的:'肖申克的救赎',两个引号之间的,就是一个文字值。
但是这个地方会有隐患。我们先给网站加个再普通不过的功能——按片名搜索,把用户输进来的关键词拼进一句 SELECT:
keyword = "肖申克的救赎" # 用户输入的搜索词
cur.execute("SELECT * FROM films WHERE title = '" + keyword + "'").fetchall()
查一部电影,稳稳当当。这句 SELECT 看着人畜无害,对吧?
直到有一天,一个疯狂的导演来了。 他给新片起的“名字”,不是什么《XX 往事》,而是一串符号——就叫 《' OR '1'='1》(对,片名就是这个,别问,艺术家的世界你不懂)。片子上了架,麻烦也跟着上了架:只要有观众在搜索框里搜这个“片名”想看它,我们那句拼出来的 SQL 就成了——
SELECT * FROM films WHERE title = '' OR '1'='1'
看出门道没有?片名开头那个单引号,把我们原本用来框值的引号提前闭合了;于是后面的 OR '1'='1' 不再是“数据”,而被数据库当成 SQL 的一部分去执行——而 '1'='1' 永远为真。结果:WHERE 形同虚设,一句“搜这一部”,硬生生变成了把整个片库哗啦全吐出来。这位导演一个恶趣味的命名,成了全站每次搜索都触发的数据泄露。
而这还只是“多吐点东西”。要是名字起得更歹毒——塞的是一句删表指令——搜一次,整张 films 表都可能没了(SQLite 还好,因为它不支持“一次跑多条语句”,但如果换个数据库、换个写法,这刀就真砍下去了)。
程序员圈有个流传很广的段子:一位家长给孩子登记的名字,写成一段“删除全表”的 SQL,学校系统一拼、一执行,全校的学生表没了…
这就是 SQL 注入——把数据伪装成命令,撬开你的 SQL,这是史上最经典,但至今仍在批量发生的漏洞。要命的是:干这事的不一定是黑客,也可能只是一个名字刁钻的正常数据。所以铁律只有一条——只要某个值可能自带引号,就一个都不能信。(想想我们的文字实验室,history 表存的全是用户打的字,更是重灾区)
既然是数据库通用的问题,那么按照我们的直觉,七步之内必有解药。
解法就是在使用 cur.execute() 提交语句的时候,把值的部分写成问号 ?, 然后让 python 帮我们往这个 SQL 语句的问号上传值——要传的值,放进一个列表 [...] 交给 execute。比如可以这样提交 SQL 语句:
cur.execute(
"INSERT INTO 表名 (字段名, 字段名, 字段名, 字段名) VALUES (?, ?, ?, ?)",
["值", "值", "值", "值"],
)
或者:
cur.execute("SELECT * FROM 表名 WHERE 字段 = ?", ["值"]).fetchall()
写 SQL 语句的时候,该放值的地方只写 ?,把值单独作为参数交给 execute,这样写的时候,数据库就认定这个位置铁定是个值,里头无论是引号还是别的什么,都只当纯数据,绝不解析成命令。
于是那位导演的怪片,照样能安安稳稳存进去:
cur.execute(
"INSERT INTO films (title, language, release_date, created_at) "
"VALUES (?, ?, ?, datetime('now'))",
["' OR '1'='1", "英语", "2020-01-01"],
)
conn.commit()
搜索也用这样的套路写:
cur.execute("SELECT * FROM films WHERE title = ?", ["' OR '1'='1"]).fetchall()
执行之后,不多不少,正好返回导演那一部怪片,片库安然无恙。
立成规矩:SQL 里永远不拼用户输入,值永远走占位符
?。 这和 4.6 讲 CVE 是同一个道理——安全不是某一章的知识,是每一行代码里的习惯。
ORDER BY / DESC / LIMIT
我们已经了解了数据库和表的最最基本的操作,可以看到它能写、能查、能删、能改,但是我们还没有感受到当数据量比较大的时候,它有哪些优势。
接下来,我们要学三个 SQL 关键字,来体验一下查询时的排序、限量操作。
ORDER BY:这个是排序用的,在查询的时候,如果用了 ORDER BY 字段名,那么查询的结果就会按照这个字段名来排序,但是默认是正序排序。
DESC:如果想要逆序排序呢?可以给 ORDER BY 加上 DESC 关键字,比如说 ORDER BY 字段名 DESC
LIMIT:如果想要截取前面的少量部分,不想要筛选出来的全部数据,就可以用 LIMIT,比如说只想要前 10 条,就可以用 LIMIT 10
现在我们的 films 表里电影还太少,尝不出味道。我们索性写个小脚本,一次性灌 24 部电影进去。在家目录新建 seed_data.py(跟 test.db 同一个目录),代码从下方复制:
import sqlite3
import time
conn = sqlite3.connect("test.db")
cur = conn.cursor()
cur.execute("DROP TABLE IF EXISTS films") # 清掉刚才手玩的,从头来
cur.execute("""
CREATE TABLE films (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT, language TEXT, release_date TEXT, created_at TEXT
)
""")
films = [
("肖申克的救赎", "英语", "1994-09-23"),
("阿甘正传", "英语", "1994-07-06"),
("霸王别姬", "汉语", "1993-01-01"),
("龙猫", "日语", "1988-04-16"),
("天空之城", "日语", "1986-08-02"),
("天堂电影院", "意大利语", "1988-11-17"),
("泰坦尼克号", "英语", "1997-12-19"),
("楚门的世界", "英语", "1998-06-05"),
("花样年华", "汉语", "2000-09-29"),
("千与千寻", "日语", "2001-07-20"),
("无间道", "汉语", "2002-12-12"),
("盗梦空间", "英语", "2010-07-16"),
("让子弹飞", "汉语", "2010-12-16"),
("少年派的奇幻漂流", "英语", "2012-11-21"),
("星际穿越", "英语", "2014-11-07"),
("疯狂动物城", "英语", "2016-03-04"),
("你的名字", "日语", "2016-08-26"),
("摔跤吧!爸爸", "印地语", "2016-12-23"),
("燃烧", "韩语", "2018-05-17"),
("寄生虫", "韩语", "2019-05-30"),
("' OR '1'='1", "英语", "2020-01-01"), # 疯狂导演的“怪片名”
("奥德赛", "英语", "2026-07-17"),
("牛来", "汉语", "2026-08-05"),
("欢迎来龙餐馆", "汉语", "2026-08-11"),
]
for i, (title, language, release_date) in enumerate(films, 1):
cur.execute(
"INSERT INTO films (title, language, release_date, created_at) "
"VALUES (?, ?, ?, datetime('now'))", # created_at 取此刻的时间
[title, language, release_date],
)
print(f"已灌入 {i}/{len(films)}:{title}")
time.sleep(1) # 歇 1 秒再插下一部,好让每部的入库时间错开
conn.commit()
print("完成")
这个脚本会先连接到我们家目录刚才建的那个 test.db,然后把 films 表给删除掉(DROP TABLE),然后重新建 films 表(CREATE TABLE),然后把 24 部电影给灌进这个 films 表里。其中还混进了上文提到的那位疯狂导演的怪片名 ' OR '1'='1,我们等下也看一看它会不会“SQL 注入”成功。
脚本中一句 time.sleep(1),这个意思是在轮番把电影灌入 films 表的过程中,每写入一条,都暂停 1 秒再写下一条,这是为了让每一条记录的 created_at 都不同,不然一次性灌进去的全部都是一样的 created_at,那我们就玩不了按 created_at 排序的游戏了。
跑一下:
python3 seed_data.py
现在 test.db 的 films 表里躺着 24 部各不相同的电影,24 条 created_at 依次相差约 1 秒。
然后我们开始测试,再次回到 python 的 REPL 模式,再次连接数据库,拿到 cursor:
import sqlite3
conn = sqlite3.connect("test.db")
cur = conn.cursor()
分别执行下面三句 SQL 语句:
SELECT id, title, created_at FROM films ORDER BY created_at
SELECT id, title, created_at FROM films ORDER BY created_at DESC
SELECT id, title, created_at FROM films ORDER BY created_at DESC LIMIT 5
这三句分别是:
- 按 created_at 字段排序
- 按 created_at 字段逆序排序
- 按 created_at 字段逆序排序之后取前 5 条
注意,执行的时候,一定要把 SQL 语句放入到 python 的 cur.execute() 的方法中:
cur.execute("SELECT id, title, created_at FROM films ORDER BY created_at").fetchall()
cur.execute("SELECT id, title, created_at FROM films ORDER BY created_at DESC").fetchall()
cur.execute("SELECT id, title, created_at FROM films ORDER BY created_at DESC LIMIT 5").fetchall()
与文件版存储对比
回想一下我们的上一节,我们对项目的 history 进行逆序排序取前 10 条是怎么做的:
# 文件版(6.3,亲手写的)
records = load_history() # 全量读进内存
records.reverse() # 自己倒序
return records[:10] # 自己切片
如果换成数据库版,上面三行 python 代码其实可以直接写成下面的一行 SQL 语句
-- 数据库版(下一节就用它)
SELECT * FROM history ORDER BY created_at DESC LIMIT 10
它们的主要区别不在行数,而在姿态——文件版是我们告诉程序怎么做(读、倒、切);SQL 是我们只说要什么(按时间倒序的前 10 条),至于怎么扫、怎么排、怎么快,这些就给数据库自己安排。
而且,这些只是我们看到的部分,其实,我们用了数据库之后,上一节我们遇到的那些问题,其实一个一个地都消解不见了。
- 关于全量读取:无论是读还是写,即使是要全量排序,数据库都不会把全部数据加载到内存里,它的做法要聪明的多。
- 关于整个文件重写:它如果要写一行,那就只会写一行,不会整个表重写。
- 关于并发时的脏读和脏写:它用事务来管理和协调不同的任务,可以有效避免脏读脏写。
- 关于一坏全坏:它有自己的保护机制,比裸文件要皮实和健壮得多。
数据库是怎么做到的?
对于爱学习的朋友,我知道现在你肯定不尽兴。你只知道上一节的四个问题数据库都解决了,但是你不知道它是怎么解决的,所以有点不甘心,好像没学透,对不对?
但是,如果真的讲起来,这个东西又有点复杂。所以我们不贪多,只挑最有代表性、也最容易让人犯嘀咕的那一个问题,把它讲透:排序 + 取前几条——就是刚才那句 ORDER BY created_at DESC LIMIT 10。
为什么挑它?因为它最反直觉。你可能会想:要"按时间排序",数据库是不是得先把整张表几百万行全搬进内存,排好队,再切下前 10 条?真要这么干,表一大,内存不就爆了吗?
但换成数据库,这事就是不会爆。而且这不是 SQLite 一家的独门绝技,MySQL、PostgreSQL 这些关系型数据库,路子都大同小异。数据库有三招解决这个问题,一招比一招聪明。
第一招:只要 10 条,就只攥住 10 条。
关键在 LIMIT 10 这几个字。它不是"排完之后随手切一刀"的备注,而是一开始就递给数据库的一句话——“我最多只要 10 条,多的别给我留着。”
数据库收到这句话,就不会傻乎乎把全表搬进内存。打个比方,这像海选留前 10 名,根本不用把几万个报名的人同时请进会场:主办方手里只备 10 把椅子,选手一个一个上台——
- 前 10 个上台的,先都坐下;
- 从第 11 个起,每来一条,就跟椅子上当前最旧的那条比:比它还旧,当场请走;比它新,就把最旧的那位换下去、自己坐上。
从头到尾,椅子永远只有 10 把,内存里也就始终只攥着这 10 条。全表是 100 条还是 1 亿条,椅子数纹丝不动。
所以,数据库到最后确实**“挨个看过每一行”**,但它避免了"同时把每一行都放入内存"。而我们上一节那个文件版,是要先把全部数据都加载进内存,才挑出那 10 条。
第二招:就算不写 LIMIT,也不硬塞内存。
那要是我们不写 LIMIT,就是要求数据库把几百万行整个排好、一条不落全都要呢?那是不是就准备几百万个“椅子”,然后都灌进内存里?
这时数据库还有后手,靠两件事顶住:
- 按"页"读盘,不整包搬家。 数据库在硬盘上不是把数据堆成一坨,而是切成一块块固定大小的"页",就好比一本活页夹,一页一页地装订。要用哪页就翻哪页进来,手上只留有限几页,看完就放回去。所以哪怕表有一千万行,读的过程占的内存也有个上限,跟表多大几乎没关系。
- 排不下,就摊到硬盘上排。 真要给全部排序、内存装不下时,数据库会把排到一半的中间结果先寄存到硬盘的临时文件里,分批理好再拼起来,而不是死往内存里塞。就像我们整理一大摞考卷,桌子小,就先在桌上分成几摞、理好的挪到旁边地上,而不是非把所有卷子同时铺满桌面。代价顶多是慢一点、多占点硬盘,而绝不会"内存爆掉、程序崩溃"。
第三招:干脆存的时候就排好,这就是"索引"。
前两招都还是"查询的时候现排"。最狠的一招是:让排序这件事,根本不用等到查询时才做。
靠的是索引(index)。
索引有点像是汉语字典前面的检字表。字典有好几百页,如果想查某个字,我们不会把全书从头翻到尾,而是先到检字表上手——它早就按拼音(或部首)排好了序,一下就能找到对应的页码,直接翻过去。
数据库的索引就是这么个东西:它在硬盘上,替我们把某一列的顺序维护好,放在一个地方,比如我们需要经常按"创建时间"取最新记录的话,就可以给"创建时间"这一列建个索引,数据库便在正表旁边多存一份**“按时间排好序的目录”**。
有了这份目录,“取最近 10 条"就不用再临时排队了,而变成:翻到目录的末尾,倒着拿 10 条,再顺着找到对应的整行,这样就快得多了。
索引的代价,其实就是除了存数据之外,还要额外地存一份索引,另外,每次存进来一条新数据的时候,都顺手维护一下这个索引,会稍微拖慢一丢丢的存储速度。但是也正是因为提前把累活给干了,真到了查数据的时候,就轻轻松松游刃有余。
也正是因为索引有代价,所以我们的原则应该是只给那些经常拿来排序,或经常拿来筛选的列建索引,不要什么字段都去建。
刚才提到的检字表、活页夹这些比喻,其实背后是一种数据结构,叫 B 树(B-Tree),关系型数据库的索引的底层基本上都是它。B 树有一个优点,就是插一条新数据的时候,它只在局部动一下,不用把整张表重写一遍。我不打算再往下深讲 B 树,但之所以把这个概念点出来,是希望大家能感受到数据结构的价值和意义。读计算机的小伙伴,如果以后想做大数据量的性能优化,数据结构还是要认真学一下。
收个口。
其实,知道了数据库的底层逻辑,我们操作文件的时候,也可以实现这些优化,我们也可以在读写文件的时候通过建立 B 树结构来控制内存,我们也可以用 python 实现索引,我们甚至也可以用 python 给文件加上事务,然后再做一个 SQL 的语法解析器…
如果我们真的这么做了,那我们就重新发明了数据库。
但这丝毫没有必要,SQLite、MySQL、PostgreSQL、DuckDB、MariaDB 等等数据库都是开源的,又不收费,如果我们真的有伟大的想法,不如直接去给这些开源项目贡献代码。
数据库可视化工具
我们在上一节用文件做存储的时候,是可以用 VS Code 打开 history.json 直接查看其中的数据的。但是对于 test.db 就不行了。.db 是精心组织的二进制格式,不像 history.json 双击就能看。想看它里面的数据,就得用专门的工具。
对于 SQLite 这种数据库,可以选择使用 DB Browser for SQLite,或者 DBeaver 的社区版,它们都是 开源免费 的数据库可视化工具,用任意一个就可以查看我们的 test.db 数据库了。
比如说可以通过 https://sqlitebrowser.org 下载它的安装包,Windows、macOS 和 Linux 都支持,装好后双击打开它,点击 Open Database,然后选择 test.db,就可以在 Database Structure 下的 tables 里看到 films 这个表了。选择 Browse data, 可以看到这个表中的全部数据——连那部片名叫 ' OR '1'='1 的怪片,也安安静静躺在表里。还记得我们担心它会不会"SQL 注入"成功吗?seed 脚本当初是走 ? 占位符把它存进去的,数据库自始至终把它当成一个普普通通的片名,一个字都没多解析,它自然也就没能兴风作浪。这就是"占位符防注入"最直观的一眼。
除了 DB Browser for SQLite,也可以用 DBeaver,DBeaver 的操作稍微复杂一点点,但是 DBeaver 的优点是它可以支持多种不同的数据库,例如常见的 MySQL、PostgreSQL 都可以。
很多人会觉得这两个工具难用,确实相比一些成熟的商业化软件,它们在 UI 和交互体验上稍微差一些。如果你不介意付费的话,也可以选择一些体验更棒的数据库可视化工具,比如说 DataGrip 或者 Navicat
结语
按照惯例,我们结束的时候需要总结一下这一节都讲了什么,但是对于数据库这个话题来说,我们讲的其实还很少,比起我们讲了什么,我更想聊一下我们没有讲什么。
按照传统的计算机教学方案,数据库应该被单独列为一门大课,讲数据库建模、表关联、索引、字段类型、函数、存储过程、触发器、权限控制、数据库迁移、容灾、分布式存储、性能优化,等等。这些知识的学习需要花费比较长的时间,更重要的是这些需要在业务中使用,才可以体验到它们的价值。
如果需要较为完整地学习这些知识,一般的课程会配套制作一个小型进销存管理软件,或者教务管理系统之类的信息管理系统。而我们的课程从设计的第一天起,就没有打算往这个方向走。如果你需要学习这方面的知识,还是需要去专门找一个关于数据库的“大课”。
今天这一节如果要谈收获,我希望是能帮你理清两件事:为什么我们会需要数据库,以及数据库长什么样,这就够了。