第 7 部分 · 走向生产:工程化与实战
数据库是什么——从 Excel 到 SQL
数据库不是什么神秘的服务器技术。它的本质是一张能被快速查询的表——你用过 Excel,就理解了一大半。这一章讲清数据库和 SQL 到底是什么,再带你用 Racket 把它跑起来。
这一章要回答三个问题:数据库到底是什么、SQL 是什么、在 Racket 里怎么用。前两个问题不依赖任何编程知识——如果你是那种“工作中要用到数据、但不理解编程”的人,前两节读完你就懂了数据库的本质。
先回到一个朴素的需求:数据要存下来,还能查
你写过这种程序吧——几条配置、一点状态、几张待办事项,程序关掉下次还想留着。最直观的做法是把数据写进文件。Racket 里这件事简单到令人发指:
;; 写进去
(call-with-output-file "data.txt"
(λ (out) (write '(("买牛奶" . #f) ("写代码" . #t)) out)))
;; 读出来
(call-with-input-file "data.txt"
(λ (in) (read in)))
;; => '(("买牛奶" . #f) ("写代码" . #t))
write / read 这对函数直接把 Racket 的数据结构序列化进文件——一个列表进、一个列表出,连 JSON 都不用。这就是最原始的持久化:数据即文件。
这套方案在数据量小、结构简单时近乎完美。但某一天你会撞墙:你想知道“哪些任务还没做完”,于是把整个文件读进来,filter 一遍,再把结果用掉。数据量一大,每次都全量读进内存,只为了筛那么几条。
这时候你开始想:能不能“只读没做完的那些”?也就是说,数据得能被按条件查询,而不是整体搬回家再翻。
文件的天花板,正好是数据库的地板
文件的问题是它没有“查询”这个概念,只有“读取”。你只能把它整个搬回来,再用程序逻辑去筛、去找、去排序。这把两件事混在了一起:数据怎么存,以及怎么找。
数据库解决的正是这件事——它把“找”下沉成一个独立的、声明式的动作。你只要说“我要状态为未完成的所有任务”,怎么高效地找到它们是数据库的事,不是你的事。
但一提到数据库,很多人脑子里冒出的是另一套画面:下载安装包、配端口、建用户、起服务进程、再写网络连接代码。这套恐惧来自一个误解——把“数据库”和“数据库服务”画了等号。下面先把概念理清,这个误解自然就消了。
数据库的核心,是一张表
抛开所有技术细节,数据库最核心的抽象就是一样东西:表。
如果你用过 Excel,你已经理解了。一张 Excel 表是什么样的?
| 标题 | 完成 | 创建日期 |
|---|---|---|
| 买牛奶 | 否 | 2026-08-01 |
| 写代码 | 是 | 2026-08-01 |
| 还书 | 否 | 2026-08-02 |
数据库里的表就是这个样子:
- 表(table):一张表,对应一个 Excel 工作表
- 列(column):表的结构,对应 Excel 的列头——“标题”“完成”“创建日期”这些
- 行(row):一条具体的数据,对应 Excel 的一行——“买牛奶、否、2026-08-01”
就这么简单。数据库的“表”和 Excel 的表,在概念上几乎一模一样。差别只在于:Excel 靠你手动翻、手动筛,数据库靠一种叫 SQL 的语言让程序自动翻、自动筛,而且能在一百万行里瞬间找到你要的那几行。
理解了“表 = 结构化的数据”,你就理解了数据库的本质。它不是什么高深的服务器技术,就是一张能被程序快速查询的表。
SQL:和表格对话的语言
怎么和这张表“对话”?用一种叫 SQL(Structured Query Language,结构化查询语言)的语言。SQL 是专门为表格数据设计的——你用它告诉数据库“我要什么”,数据库替你去找。
SQL 看起来像英文句子,核心只有四个动词,对应数据的四种基本操作:
| 动词 | 干什么 | 对应日常操作 |
|---|---|---|
SELECT | 查找:按条件读出数据 | 在 Excel 里筛选 |
INSERT | 插入:添加一行新数据 | 在 Excel 里新增一行 |
UPDATE | 更新:修改已有数据 | 在 Excel 里改某个格子 |
DELETE | 删除:去掉数据 | 在 Excel 里删行 |
记住这四个动词,你就掌握了 SQL 的骨架。来看真实的例子。假设有一张 tasks 表(标题、是否完成),这四个动词长这样:
-- 查:找出所有没完成的任务
SELECT title FROM tasks WHERE done = 0;
-- 增:添加一条新任务
INSERT INTO tasks (title, done) VALUES ('学 Racket', 0);
-- 改:把"买牛奶"标记为完成
UPDATE tasks SET done = 1 WHERE title = '买牛奶';
-- 删:删掉所有已完成的任务
DELETE FROM tasks WHERE done = 1;
你看,每条 SQL 都像一句简短的英文:SELECT title FROM tasks WHERE done = 0,直译就是“从 tasks 表里,选出 done 等于 0 的那些行的 title”。SQL 之所以设计成这样,就是为了让“我要什么数据”这件事尽量接近自然语言——你说清要什么,怎么找交给数据库。
有个细节值得强调:SELECT 里你只说“要什么”(哪些列、什么条件),没说“怎么找”。如果是一百万行数据,数据库自己决定怎么高效地翻、用不用索引、从哪开始找——这些“怎么找”全是数据库的事。
这种“你声明要什么,机器负责怎么做”的风格,和函数式编程里用 filter、map 的思路是一脉相承的。SQL 是一种声明式的语言,这正是它强大的根源。
SQLite:长得像文件,干活像数据库
概念理清了,现在上手用。但“数据库”那个运维画面又来了——难道真要装一个服务?
不用。有一种数据库叫 SQLite,它的存在就是为了把这个门槛抹平。SQLite 不是“一个跑着的服务”,而是“一个能听懂 SQL 的文件”。在 Racket 里用 SQLite,只需要三步,全程不装任何服务:
(require db)
;; 1. 打开一个数据库文件(不存在就新建)
(define conn (sqlite3-connect #:database "app.db"))
;; 2. 建表、插数据、查询——全是 SQL,通过同一个连接
(query-exec conn
"CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY,
title TEXT,
done INTEGER DEFAULT 0)")
(query-exec conn
"INSERT INTO tasks (title) VALUES (?)" "学 Racket")
(query-list conn "SELECT title FROM tasks WHERE done = 0")
;; => '("学 Racket")
;; 3. 关掉连接
(disconnect conn)
注意几件事:没有 apt install,没有 systemctl start,没有端口号,没有用户密码。app.db 就是一个文件,删掉它数据就没了,拷走它数据就跟着走。连接打开到关闭之间,整个数据库就是一个文件在你程序的生命周期里活着。
这正是 SQLite 为什么适合入门——它去掉了数据库里所有属于“服务”的部分,只留下“能查询”这个核心。你刚才学到的“表”“SQL 四个动词”,在 SQLite 里和在那些重型数据库(PostgreSQL、MySQL)里完全一样。数据库的概念是通用的,SQLite 只是让你用最低成本摸到它。
从使用体验上倒推它的身份:对刚入门的程序来说,SQLite 根本不是“数据库”,而是“一个带查询能力的结构化文件”。理解了这层身份,新手对数据库的恐惧就没了。
把 SQL 结果变成 Racket 数据
query-list 只是查询的一种形式。更多时候你要拿回整行数据,而不只是一个值。db 库提供几种查询函数,对应不同的需求:
;; 拿回一个值(单行单列)
(query-value conn "SELECT COUNT(*) FROM tasks WHERE done = 0")
;; => 2
;; 拿回一行(作为向量)
(query-row conn "SELECT * FROM tasks WHERE id = 1")
;; => #(1 "学 Racket" 0)
;; 拿回多行(向量组成的向量)
(query-rows conn "SELECT title, done FROM tasks")
;; => #(#("学 Racket" 0) #("买牛奶" 1))
;; 拿回某一列的所有值(列表)
(query-list conn "SELECT title FROM tasks")
;; => '("学 Racket" "买牛奶")
注意一个关键细节——前面插入数据用的是参数化查询:
(query-exec conn "INSERT INTO tasks (title) VALUES (?)" "学 Racket")
那个 ? 是占位符,真正的值 "学 Racket" 作为单独的参数传进去。永远用这种方式拼接用户输入,不要用 string-append 把值直接拼进 SQL 字符串。
直接拼接会留下 SQL 注入的漏洞——如果某个值里藏了 '; DROP TABLE tasks;--,你的表就没了。参数化查询让数据库自己处理转义,这是用数据库的一条铁律。
什么时候该用什么
到这里你有了三种存数据的方式,怎么选?判断标准很简单——看你的数据会不会长大、要不要查询:
| 场景 | 用什么 | 理由 |
|---|---|---|
| 几条配置、一点状态 | 文件 | 量小、不查询,文件最简单 |
| 要按条件查数据,但量不大 | SQLite | 能查询、零运维,一个文件搞定 |
| 多个程序同时读写、高并发 | 服务型数据库 | SQLite 的单写者模型扛不住 |
关键认知是:这是“项目长大了才考虑”的事,不是“为了将来可能长大所以一开始就上”。先用最简单够用的方案,等它真痛了再换——这本身就是一条值得养成的工程判断。
可以用一个 Racket 式的类比把三种方式串起来,方便记忆:
[架构图:三种持久化方式的演进关系——需求驱动逐步升级]
- 文件 →
(define data ...),数据就是个绑定,能存能取,但不会自己找 - SQLite →
(define/query data ...),数据加了查询能力,但还是程序的一部分 - 服务型数据库 →
(define data elsewhere ...),数据搬到了程序之外,独立常驻
不管你最后用哪种,核心都回到那个本质:把数据组织成能被快速查询的表,用 SQL 这种声明式语言去对话。理解了这层,从文件到 SQLite 到服务型数据库的演进,就只是一条需求驱动、逐步升级的单行道——而你现在已经站在能看懂全貌的位置了。
找问题,再改对
有人想按标题查任务,把用户输入直接拼进了 SQL:
(define (find-task conn title)
(query-list conn
(string-append
"SELECT title FROM tasks WHERE title = '" title "'")))
正常输入它能跑。但传入 it's 试试——用户输入的单引号把 SQL 拦腰截断,查询直接报错;精心构造的输入甚至能改写这条 SQL 的本意。把拼接改成参数化:SQL 里放 ? 占位,值作为独立参数另传。
改完想一想:为什么占位符不怕引号?SQL 和数据分开放,省掉的到底是哪种麻烦? 数据存下来了,下一个问题是:怎么让你的程序被别人用上?最普遍的答案就是 Web——下一篇带你用 Racket 从零写一个 Web Server。