零到全栈 · 课程
模块 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
结语
按照惯例,我们结束的时候需要总结一下这一节都讲了什么,但是对于数据库这个话题来说,我们讲的其实还很少,比起我们讲了什么,我更想聊一下我们没有讲什么。
按照传统的计算机教学方案,数据库应该被单独列为一门大课,讲数据库建模、表关联、索引、字段类型、函数、存储过程、触发器、权限控制、数据库迁移、容灾、分布式存储、性能优化,等等。这些知识的学习需要花费比较长的时间,更重要的是这些需要在业务中使用,才可以体验到它们的价值。
如果需要较为完整地学习这些知识,一般的课程会配套制作一个小型进销存管理软件,或者教务管理系统之类的信息管理系统。而我们的课程从设计的第一天起,就没有打算往这个方向走。如果你需要学习这方面的知识,还是需要去专门找一个关于数据库的“大课”。
今天这一节如果要谈收获,我希望是能帮你理清两件事:为什么我们会需要数据库,以及数据库长什么样,这就够了。