数据库入门到初级 · 第 21/22 篇。实验口径:MySQL 8.4、InnoDB、utf8mb4。案例:小型商城。
命令行里 SQL 写对,只完成了一半。接入程序后,还要处理不可信输入、并发、异常和连接资源。本篇使用 Python 与 MySQL Connector/Python 演示,其他语言也要承担同样的责任,但占位符写法可能不同。
1. 参数化查询:值不能靠拼接进入 SQL
不要使用 "SELECT ... WHERE email = '" + email + "'" 这种拼接方式。用户输入包含引号或 SQL 片段时,可能改变语句结构。正确思路是让 SQL 结构固定,把输入作为参数单独交给驱动。
cursor.execute(
"SELECT id, name FROM users WHERE email = %s",
(email,)
)
这里 %s 是 Connector/Python 的参数占位符,不是让你自己用 Python % 运算符替换。单个参数的元组需要逗号。占位符通常用于值,不能拿来代替表名、字段名或排序方向;动态排序字段应使用允许名单映射。
参数化不能代替用户权限校验。查到了订单,也必须验证当前登录者是否有权查看;不能仅因为请求传了一个订单编号就直接返回。

2. 准备依赖与连接配置
python -m pip install mysql-connector-python
下面的程序从环境变量读取 DB_HOST、DB_PORT、DB_USER、DB_PASSWORD、DB_NAME。Docker 本地实验端口按第 3 篇设置为 3307。不要把真实密码写入源码、截图或日志;远程连接还要按部署要求配置 TLS 与证书验证。
建议先在第 22 篇恢复出来的练习副本里运行,避免改变原始样例。以下是“一个用户购买一种商品并预留库存”的最小教学函数。
3. 一次事务完成下单
import os
import mysql.connector
def place_order(user_id: int, product_id: int, quantity: int) -> int:
if type(quantity) is not int or quantity <= 0:
raise ValueError("购买数量必须为正整数")
conn = mysql.connector.connect(
host=os.getenv("DB_HOST", "127.0.0.1"),
port=int(os.getenv("DB_PORT", "3307")),
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
database=os.environ["DB_NAME"],
charset="utf8mb4",
autocommit=False,
)
cursor = None
try:
conn.start_transaction()
cursor = conn.cursor(dictionary=True)
cursor.execute(
"SELECT name, price, stock, is_on_sale "
"FROM products WHERE id = %s FOR UPDATE",
(product_id,),
)
product = cursor.fetchone()
if product is None:
raise ValueError("商品不存在")
if product["is_on_sale"] != 1 or product["stock"] < quantity:
raise ValueError("商品不可售或库存不足")
# DECIMAL 默认映射为 Decimal,金额计算避免转成 float。
amount = product["price"] * quantity
cursor.execute(
"UPDATE products SET stock = stock - %s "
"WHERE id = %s AND is_on_sale = 1 AND stock >= %s",
(quantity, product_id, quantity),
)
if cursor.rowcount != 1:
raise ValueError("扣减库存失败")
cursor.execute(
"INSERT INTO orders (user_id, status, total_amount) "
"VALUES (%s, %s, %s)",
(user_id, "pending", amount),
)
order_id = cursor.lastrowid
cursor.execute(
"INSERT INTO order_items "
"(order_id, product_id, product_name, unit_price, quantity) "
"VALUES (%s, %s, %s, %s, %s)",
(order_id, product_id, product["name"], product["price"], quantity),
)
conn.commit()
return order_id
except Exception:
conn.rollback()
raise
finally:
if cursor is not None:
cursor.close()
conn.close()
把代码保存为 place_order.py。环境变量配置好以后,可以在同一文件末尾添加 print(place_order(1, 2, 2)) 试运行。基于原始副本,成功后鼠标库存从 30 变成 28,新待支付订单金额为 178 元,并生成一条数量为 2 的明细。
使用不存在的用户编号时,订单插入会被外键拒绝,异常分支回滚此前的库存更新。这个失败用例能验证事务是否真的覆盖整个流程。
本函数没有实现登录授权、重复请求幂等、支付接入、取消释放库存和死锁重试,不能直接作为完整商城接口上线。它演示的是数据库访问与事务边界;其中 user_id 应来自经过验证的身份或受控后台流程,而不是任意相信请求参数。
4. 连接池解决什么问题
建立连接有开销,连接池让应用复用一组受控连接。池大小并非越大越好,要结合数据库连接上限、查询耗时与并发量设置。
借出的连接要及时归还,事务结束后再归还,不能让带锁的连接长期闲置。使用连接池后,close() 可能表示归还池,而不是断开底层连接,具体看驱动实现。
5. 账号权限与 N+1 查询
应用账号只授予所需库和操作权限,日常请求通常不需要建用户、删库等管理权限。迁移账号、备份账号和应用账号可以分开管理。
N+1 查询是先查 N 张订单,再为每张订单单独查一次明细,产生 1+N 次数据库往返。可考虑一次查订单页,再用订单编号集合批量取明细,或使用适合的连接查询;同时注意一对多展开会影响分页,不能为了减少次数破坏结果含义。
练习与答案
练习:把排序字段作为 %s 参数能安全实现任意排序吗? 不能。参数通常被当作值,应把允许的排序选项映射到预先写好的列名和方向。
练习:订单插入失败后,只关闭游标够吗? 不够。需要明确回滚事务,并释放连接。资源清理与事务回滚是不同责任。
延伸阅读
上一篇:第 20 篇 · 系列目录 · 下一篇:第 22 篇