上一篇给项目装上了第一份记忆------把每次分析的结果连同 UTC 时间戳一起写进 history.json,再用 /api/history 倒序读出最近十条;但也亲手数出了文件方案的四处隐患:读十条要搬空全部,存一条要重写整份,多人同时读写会脏写、脏读,写到一半崩溃就一坏全坏------这不是代码写得烂,是 "拿一个文件存不断增长的结构化数据" 这条路本身的天花板;这一篇请出专门收拾这些问题的软件:数据库------先看懂它的分类与选型,再上手 SQLite 和 SQL,顺手挡住史上最经典的 SQL 注入,最后看清它凭什么又快又稳
数据库正传:SQLite 与 SQL
看数据库如何又快又好地解决存储和查询过程中的各种问题
七步之内必有解药:
手搓文件存储埋下了不少问题:效率问题、并发问题、健壮性问题;但这些并不是我们项目特有的------任何人只要用 "一个文件" 去存不断增长的结构化数据,都不可避免地会遭遇这些问题
在计算机领域,如果是人人都可能遇到的问题,那么请相信------七步之内必有解药:一定有人做好了专门的东西来替我们解决它(框架如此、库如此,今天也一样)
这个解药就是数据库:专门负责 "把数据存好、帮助我们快速查询数据" 的软件------上一部分那些痛,全是它的本职工作
数据库的类型:
市面上已经有太多数据库软件了,许多你可能听过: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,不用多养服务,部署的时候更能感受到它的优势);未来如果想迁移到 MySQL / PostgreSQL,也比较方便------它们都可以用 SQL 语言操作(虽然不同的关系型数据库会有不同的 "方言")
体验 SQLite 和 SQL 语言:
用 Python 的 REPL 快速体验一下 SQLite;这一部分的东西都放在家目录里做,先 cd 到家目录,再进 REPL:
bash
cd ~ # 先回到家目录,等下的 test.db 就建在这儿
python3
第一步:造一个数据库
python
import sqlite3
conn = sqlite3.connect("test.db") # 没有就创建------就建在当前目录(刚 cd 进的家目录)
cur = conn.cursor() # cursor:往下递 SQL 的"手柄",固定搭配
此时打开家目录,会发现已经有了一个 test.db 文件------一个数据库,就是硬盘上这么一个文件,这就是 SQLite 的工作方式
第二步:建一张表(CREATE TABLE)
SQLite 是关系型数据库,而关系型数据库是围绕 "表" 的,所以上手第一件事是学怎么建一张表
在数据库里定义一张表,有两件事必须明确:表叫什么名字;表的每一个字段(列)叫什么、是什么类型;对应的 SQL 语法是:
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 可以这样写:
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 表才创建,已经存在就不创建了(实际上就算不写这句,表已存在时也不会再建一个新的)
但是,一条裸的 SQL 语句无法直接执行,需要用 Python 的 cur 帮我们执行------在 REPL 中执行时把 SQL 包进 cur.execute()(整段直接粘进 REPL 就行):
python
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 语句是:
sql
INSERT INTO 表名 (字段名, 字段名, 字段名, ......) VALUES (值, 值, 值, ......)
向 films 表插入一行数据:
sql
INSERT INTO films (title, language, release_date, created_at) VALUES ('行尸走肉', '英语', '2010-10-31', datetime('now'))
注意:文字的值需要用单引号裹起来------片名、语言、日期都是文字,所以都得裹;数字就可以不用裹;id 是自增的,不需要赋值------插入时 id 的值会自动生成,这就是自增的意义
至于最后的 created_at(创建时间),我们不想手写死一个时间:SQLite 自带取当前时间的函数 datetime('now')------注意它没有单引号,因为它不是文字值,而是一个 "函数调用":让数据库在插入的那一刻自己算出当前时间填进去(默认按 UTC 计算)
真正执行时,把这句 SQL 包进 cur.execute():
python
cur.execute(
"INSERT INTO films (title, language, release_date, created_at) "
"VALUES ('行尸走肉', '英语', '2010-10-31', 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():
python
cur.execute("SELECT * FROM films").fetchall()
语句末尾的 fetchall() 是把结果一次性拿成一个列表;REPL 直接回显(created_at 是 datetime('now') 算出的当前时间,默认按 UTC 算,所以会和本机时钟差几个时区,都正常):
(1, '行尸走肉', '英语', '2010-10-31', '2026-9-27 17:07:33')
注意开头那个 1 就是这一行的 id------我们从没填过,是 id 自动发的身份证
照上面的写法再插两条(created_at 同样交给 datetime('now')):
python
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 子句:
python
cur.execute("SELECT * FROM films WHERE language = '日语'").fetchall()
(2, '千与千寻', '日语', '2001-07-20', '2026-08-17 17:07:35')
WHERE 就是筛选:给某一列出个条件,只把符合的挑出来------上面这句因为指定了 WHERE language = '日语',就把三部电影里的日语片检索了出来
第五步:删一行(DELETE)
删除一条记录的 SQL 语句是:
sql
DELETE FROM 表名 WHERE 条件表达式
比如想删除 id=2 的这条记录:
python
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:
python
keyword = "行尸走肉" # 用户输入的搜索词
cur.execute("SELECT * FROM films WHERE title = '" + keyword + "'").fetchall()
查一部电影,稳稳当当;这句 SELECT 看着人畜无害,对吧?
直到有一天,一个疯狂的导演来了;他给新片起的 "名字" 不是什么《XX 往事》,而是一串符号------就叫《' OR '1'='1》(对,片名就是这个,别问,艺术家的世界你不懂);片子上了架,麻烦也跟着上了架:只要有观众在搜索框里搜这个 "片名",我们那句拼出来的 SQL 就成了------
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:
python
cur.execute(
"INSERT INTO 表名 (字段名, 字段名, 字段名, 字段名) VALUES (?, ?, ?, ?)",
["值", "值", "值", "值"],
)
或者:
python
cur.execute("SELECT * FROM 表名 WHERE 字段 = ?", ["值"]).fetchall()
写 SQL 时该放值的地方只写 ?,把值单独作为参数交给 execute------这样数据库就认定这个位置铁定是个值,里头无论是引号还是别的什么,都只当纯数据,绝不解析成命令
于是那位导演的怪片,照样能安安稳稳存进去:
python
cur.execute(
"INSERT INTO films (title, language, release_date, created_at) "
"VALUES (?, ?, ?, datetime('now'))",
["' OR '1'='1", "英语", "2020-01-01"],
)
conn.commit()
搜索也用这样的套路写:
python
cur.execute("SELECT * FROM films WHERE title = ?", ["' OR '1'='1"]).fetchall()
执行之后,不多不少,正好返回导演那一部怪片,片库安然无恙
立成规矩:SQL 里永远不拼用户输入,值永远走占位符 ?;这和讲 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 同一个目录),代码从下面复制:
python
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),重新建表 (CREATE TABLE),然后把 24 部电影灌进去------其中还混进了那位疯狂导演的怪片名 ' OR '1'='1,等下看看它会不会 "SQL 注入" 成功
脚本里那句 time.sleep(1) 的意思是:轮番灌入的过程中每写一条暂停 1 秒再写下一条------为了让每条记录的 created_at 都不同,不然一次性灌进去的 created_at 全都一样,就玩不了按 created_at 排序的游戏了
跑一下:
bash
python3 seed_data.py
现在 test.db 的 films 表里躺着 24 部各不相同的电影,24 条 created_at 依次相差约 1 秒
然后回到 Python 的 REPL,重新连接数据库、拿到 cursor:
python
import sqlite3
conn = sqlite3.connect("test.db")
cur = conn.cursor()
分别执行下面三句 SQL:
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 逆序排序;逆序排序后取前 5 条;执行时一定要把 SQL 包进 cur.execute():
python
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 条是怎么做的:
python
# 文件版(亲手写的)
records = load_history() # 全量读进内存
records.reverse() # 自己倒序
return records[:10] # 自己切片
换成数据库版,这三行 Python 可以直接写成一句 SQL:
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 实现索引,甚至可以给文件加上事务,再做一个 SQL 的语法解析器......如果我们真的这么做了,那就重新发明了数据库;但这丝毫没有必要:SQLite、MySQL、PostgreSQL、DuckDB、MariaDB 等等数据库都是开源的、又不收费;真有伟大的想法,不如直接去给这些开源项目贡献代码
数据库可视化工具:
用文件做存储时,可以用 VS Code 直接打开 history.json 查看数据;但 test.db 就不行了------.db 是精心组织的二进制格式,不像 history.json 双击就能看;想看里面的数据,得用专门的工具
对 SQLite 来说,可以用 DB Browser for SQLite,或者 DBeaver 的社区版------都是开源免费的数据库可视化工具,用任意一个就能查看 test.db;比如通过 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------操作稍微复杂一点,但优点是支持多种不同的数据库(比如常见的 MySQL、PostgreSQL 都可以);很多人会觉得这两个工具难用------相比成熟的商业化软件,它们在 UI 和交互体验上确实差一些;不介意付费的话,也可以选体验更好的,比如 DataGrip 或 Navicat
结语:
关于数据库,讲的其实还很少------比起讲了什么,更想说说没讲什么
按照传统的计算机教学方案,数据库应该被单独列为一门大课:讲数据库建模、表关联、索引、字段类型、函数、存储过程、触发器、权限控制、数据库迁移、容灾、分布式存储、性能优化等等;这些知识需要较长的时间学习,更重要的是需要在业务中使用,才能体验到它们的价值;一般课程还会配套制作一个小型进销存管理软件或教务管理系统之类的信息管理系统;而这份文档从设计之初就没打算往这个方向走------如果需要系统学习这些知识,还是要去专门找一门数据库的 "大课"
这篇能帮你理清两件事就够了:为什么我们会需要数据库,以及数据库长什么样