学习SQLite时,很多人的困惑不在于SQL语法本身,而在于不知道如何把它嵌入到一个真实业务里。餐厅的菜单与订单管理恰好是一个麻雀虽小五脏俱全的场景:菜单是典型的主数据,订单是高频写入的流水数据,两者之间还存在一对多的明细关系,几乎覆盖了日常开发中最常见的数据库操作。本文将用C语言加SQLite3官方API,完整实现这个系统,重点讲清楚接口选择、外键处理、防注入和性能优化这几个容易踩坑的地方。

一、数据库设计与建表
先想清楚业务模型再动手建表,是避免后期返工的关键。这个系统至少需要三张表:菜品表menu、订单表orders、订单明细表order_items。菜品表存名称、价格、分类和库存;订单表记录桌号、下单时间和总金额;明细表是订单和菜品之间的桥梁,记录每一道菜的数量和小计。
这里有一个非常经典的坑要提前说明:SQLite默认是不开启外键约束的,即使你在建表语句里写了FOREIGN KEY,删除菜品时也不会阻止脏数据产生。必须在每次打开数据库连接后执行PRAGMA foreign_keys = ON;,外键才会真正生效。很多人在MySQL上养成了习惯,迁移到SQLite后不明所以,就是这个原因。
CREATE TABLE IF NOT EXISTS menu (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
price REAL NOT NULL CHECK(price >= 0),
category TEXT DEFAULT '家常菜',
stock INTEGER DEFAULT 100
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_no INTEGER NOT NULL,
total REAL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
menu_id INTEGER NOT NULL,
qty INTEGER NOT NULL CHECK(qty > 0),
subtotal REAL NOT NULL,
FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY(menu_id) REFERENCES menu(id)
);建表语句中用到了CHECK约束和ON DELETE CASCADE,前者可以在数据库层面挡住非法数据,后者让删除订单时自动清掉对应明细,省去应用层手动清理的麻烦。
二、C语言操作SQLite的三个核心接口
SQLite官方C API里最常用的是三个函数:sqlite3_open负责建立连接,sqlite3_exec适合执行不需要参数的批量SQL,sqlite3_prepare_v2配合sqlite3_bind系列函数则用于带参数的增删改查。新手常犯的错误是不管什么场景都用sqlite3_exec拼接字符串,这样不仅麻烦,还存在SQL注入风险。
举个实际的例子:顾客点单时需要插入订单明细。如果用字符串拼接的方式把菜名或ID直接拼进SQL,一旦输入内容里出现单引号,轻则报错,重则被注入攻击。正确的做法是预处理语句加参数绑定,下面的代码演示了添加菜品和创建订单的完整流程。
#include <sqlite3.h>
#include <stdio.h>
static sqlite3 *db = NULL;
int open_db(const char *path) {
if (sqlite3_open(path, &db) != SQLITE_OK) {
fprintf(stderr, "打开失败: %s\n", sqlite3_errmsg(db));
return -1;
}
// 关键:开启外键约束,否则FOREIGN KEY形同虚设
sqlite3_exec(db, "PRAGMA foreign_keys = ON;", 0, 0, 0);
return 0;
}
// 添加菜品,使用预处理语句防止SQL注入
int add_menu(const char *name, double price) {
const char *sql = "INSERT INTO menu(name, price) VALUES(?1, ?2);";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK)
return -1;
sqlite3_bind_text(stmt, 1, name, -1, SQLITE_TRANSIENT);
sqlite3_bind_double(stmt, 2, price);
int rc = sqlite3_step(stmt); // 执行一次
sqlite3_finalize(stmt); // 释放语句,必须有
return (rc == SQLITE_DONE) ? 0 : -1;
}
// 查询并遍历菜单
void list_menu(void) {
sqlite3_stmt *stmt = NULL;
sqlite3_prepare_v2(db, "SELECT id, name, price FROM menu ORDER BY id;", -1, &stmt, NULL);
while (sqlite3_step(stmt) == SQLITE_ROW) {
printf("%d %s %.2f元\n",
sqlite3_column_int(stmt, 0),
sqlite3_column_text(stmt, 1),
sqlite3_column_double(stmt, 2));
}
sqlite3_finalize(stmt);
}注意sqlite3_step的返回值语义:插入更新成功返回SQLITE_DONE,查询有数据返回SQLITE_ROW,这两者不能混用判断。另外sqlite3_finalize必须成对调用,漏掉会造成语句对象泄漏,长时间运行的服务程序会慢慢吃掉内存。
三、订单创建与事务优化
一笔订单往往包含多道菜,意味着要往order_items里插入多条记录,同时还要更新orders的total字段。如果每条INSERT都单独提交一次,SQLite默认的同步机制会让每次写入都经历磁盘刷盘,一笔五道菜的订单就要刷五次盘,速度慢得肉眼可见。
解决办法是用事务把整笔订单包起来。事务的本质是让多条写操作共享一次提交,磁盘只刷一次。在C API层面,就是先执行BEGIN;,全部插入成功后再执行COMMIT;,中间任何一步失败则ROLLBACK;回滚,保证订单数据要么完整写入要么完全不写,这对账务类数据尤为重要。
// 创建一笔订单:items是菜品ID和数量的数组
int create_order(int table_no, int menu_ids[], int qtys[], int count) {
char *err = NULL;
sqlite3_exec(db, "BEGIN;", 0, 0, &err);
// 1. 插入订单主表
sqlite3_exec(db, "INSERT INTO orders(table_no) VALUES(...);", 0, 0, &err);
sqlite3_int64 order_id = sqlite3_last_insert_rowid(db);
// 2. 逐条插入明细并累加总额
double total = 0;
const char *sql =
"INSERT INTO order_items(order_id, menu_id, qty, subtotal) "
"SELECT ?1, id, ?2, price * ?2 FROM menu WHERE id = ?3;";
sqlite3_stmt *stmt = NULL;
sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
for (int i = 0; i < count; i++) {
sqlite3_bind_int64(stmt, 1, order_id);
sqlite3_bind_int(stmt, 2, qtys[i]);
sqlite3_bind_int(stmt, 3, menu_ids[i]);
if (sqlite3_step(stmt) != SQLITE_DONE) {
sqlite3_exec(db, "ROLLBACK;", 0, 0, 0);
sqlite3_finalize(stmt);
return -1;
}
sqlite3_reset(stmt); // 重置语句以便复用,比重新prepare快得多
}
sqlite3_finalize(stmt);
// 3. 回写总额并提交
char buf[128];
snprintf(buf, sizeof(buf), "UPDATE orders SET total=%.2f WHERE id=%lld;",
total, (long long)order_id);
sqlite3_exec(db, buf, 0, 0, &err);
sqlite3_exec(db, "COMMIT;", 0, 0, &err);
return 0;
}上面还有一个细节值得注意:sqlite3_reset可以在同一个语句对象上反复绑定参数执行,省去了反复prepare的开销,批量插入时性能提升明显。而sqlite3_last_insert_rowid则用来拿到自增主键,作为明细表的外键值。
四、统计查询与并发访问的坑
系统跑起来后,老板最关心的是营业额。这类统计查询非常适合用JOIN加聚合函数一条SQL搞定,比如查询某个时间段内销量前十的菜品。
SELECT m.name, SUM(oi.qty) AS sold, SUM(oi.subtotal) AS revenue FROM order_items oi JOIN menu m ON m.id = oi.menu_id JOIN orders o ON o.id = oi.order_id WHERE o.created_at >= '2024-01-01' GROUP BY m.id ORDER BY sold DESC LIMIT 10;
最后说说部署阶段几乎必然遇到的问题:database is locked。SQLite采用文件级锁,如果收银和后厨两个进程同时写同一个数据库文件,后写的那个就会报这个错。解决办法有几个层次:单进程多线程场景下,开启sqlite3_config(SQLITE_CONFIG_MULTITHREAD)并用同一个连接配合互斥锁串行写入;多进程场景则建议设置忙等待超时sqlite3_busy_timeout(db, 3000),让抢不到锁的一方等待而不是立即失败;写入量更大时,可以考虑WAL模式,执行PRAGMA journal_mode=WAL;后读写可以并行,锁冲突会大幅减少。
整体来看,这个小项目把建表、CRUD、事务、外键、统计查询、并发控制都串了一遍。把这些环节亲手写一遍,比看十遍文档的印象要深刻得多。后续还可以在此基础上加Web界面或对接扫码点单,SQLite单文件、零部署的特性让这些扩展都变得非常轻量。