零基础入门Python15|关联、聚合、索引与事务:订单数据库
本篇图解:数据从哪里来、到哪里去

图中最重要的边界是事务:只有成功路径才提交数据,失败路径不能留下半条业务记录。
一、上一篇课后练习讲解
多对多关系需要中间表:
sql
CREATE TABLE authors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE book_authors (
book_id INTEGER NOT NULL,
author_id INTEGER NOT NULL,
PRIMARY KEY (book_id, author_id),
FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE,
FOREIGN KEY (author_id) REFERENCES authors(id) ON DELETE CASCADE
);
参数化关联函数:
python
def add_author_to_book(book_id, author_id):
with connect() as connection:
connection.execute(
"INSERT INTO book_authors (book_id, author_id) VALUES (?, ?)",
(book_id, author_id),
)
联合主键禁止重复关联,外键保证两个编号真实存在。
可执行验收答案
把下面 SQL 保存为 check_book_tags.sql,SQLite 中执行:
sql
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS books(id INTEGER PRIMARY KEY, title TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS authors(id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
CREATE TABLE IF NOT EXISTS book_authors(
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
author_id INTEGER NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
PRIMARY KEY(book_id, author_id)
);
INSERT INTO books VALUES (1, 'Python入门');
INSERT INTO authors VALUES (1, '小李');
INSERT INTO book_authors VALUES (1, 1);
-- 下面一行重复执行时应失败:UNIQUE constraint failed
-- INSERT INTO book_authors VALUES (1, 1);
SELECT b.title, a.name FROM books b
JOIN book_authors ba ON ba.book_id=b.id
JOIN authors a ON a.id=ba.author_id;
预期查询返回一行 Python入门 | 小李。若重复关联没有失败,检查是否漏写复合主键;若删除 book 后关联仍在,确认开启 PRAGMA foreign_keys=ON,SQLite 默认可能关闭外键检查。
二、本篇成果
建立用户、商品、订单和订单项四张表,使用JOIN读取关联数据,GROUP BY统计消费,索引优化查询,并用事务保证"创建订单、写明细、扣库存"全部成功或全部失败。
三、订单项为什么是独立实体
sql
CREATE TABLE order_items (
id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
price NUMERIC NOT NULL CHECK (price > 0),
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id)
);
成交价格必须复制到订单项,不能以后总读取商品当前价格,否则商品涨价会改变历史订单。
四、JOIN和聚合
sql
SELECT o.id AS order_id, u.name, p.name AS product_name,
oi.quantity, oi.price, oi.quantity * oi.price AS line_total
FROM orders o
JOIN users u ON u.id = o.user_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.id = ?;
SELECT u.id, u.name, COUNT(o.id) AS order_count,
COALESCE(SUM(o.total_amount), 0) AS spent
FROM users u LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.name
ORDER BY spent DESC;
INNER JOIN只保留匹配行;LEFT JOIN保留左表,即使没有订单。COUNT计数,SUM求和,COALESCE把NULL转为0。
五、索引
sql
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
索引加快读取,但增加写入成本和存储空间。先观察真实查询,再创建索引。
六、完整下单事务
python
from datetime import datetime, timezone
def create_order(connection, user_id, items):
try:
connection.execute("BEGIN")
cursor = connection.execute(
"INSERT INTO orders (user_id,total_amount,created_at) VALUES (?,0,?)",
(user_id, datetime.now(timezone.utc).isoformat()),
)
order_id = cursor.lastrowid
total = 0
for product_id, quantity in items:
product = connection.execute(
"SELECT name,price,stock FROM products WHERE id = ?",
(product_id,),
).fetchone()
if product is None:
raise ValueError("商品不存在")
if product["stock"] < quantity:
raise ValueError(f"{product['name']}库存不足")
connection.execute(
"INSERT INTO order_items(order_id,product_id,quantity,price) VALUES (?,?,?,?)",
(order_id, product_id, quantity, product["price"]),
)
connection.execute(
"UPDATE products SET stock=stock-? WHERE id=?",
(quantity, product_id),
)
total += product["price"] * quantity
connection.execute(
"UPDATE orders SET total_amount=? WHERE id=?",
(total, order_id),
)
connection.commit()
return order_id
except Exception:
connection.rollback()
raise
任何一步失败都rollback。捕获后重新raise,让调用者知道失败原因;不能回滚后假装成功。
七、验证回滚
准备一个库存充足和一个库存不足的商品,同时购买。预期orders、order_items没有新增,充足商品库存也没有减少。只检查异常不够,还要查询三张表证明回滚完整。
八、本篇验收
- 能判断一对多和多对多;
- 订单项保存成交价格快照;
- 会写JOIN、GROUP BY和聚合;
- 理解索引读写权衡;
- 下单失败后所有相关写操作回滚。
九、课后练习
实现cancel_order(connection, order_id):只能取消未取消订单;把订单项数量加回库存,再修改状态;任一步失败全部回滚。下一篇会把SQLite环境迁移到MySQL。
实战补充:用 SQL 解释更新结果
UPDATE 和 DELETE 都要检查影响行数;0 行可能表示资源不存在,不能盲目返回成功。
python
cursor = db.execute('UPDATE tasks SET done = ? WHERE id = ?', (1, task_id))
if cursor.rowcount == 0:
raise NotFound('任务不存在')
db.commit()
课后练习:把完成任务和审计放进同一事务,并写成功、不存在和审计失败三组测试;下一篇比较数据库类型和连接配置。
本篇结束:完整模块文件
本节不是代码片段,而是本篇结束时该模块的完整版本。请先备份旧文件,再整体替换;替换后重新运行本篇命令和测试。阅读时重点看本篇新增的函数、事务边界和错误处理,未涉及的代码先不要自行删减。
本篇完整示例
sql
SELECT u.name, COUNT(o.id) AS order_count
FROM users u LEFT JOIN orders o ON u.id=o.user_id
GROUP BY u.id, u.name
ORDER BY order_count DESC;