码上问学问代码之理,学编程之道

第 10 章 · 安全与 Python 连接

前面九章你都用图形工具或命令行操作数据库,可真实项目里数据库是由后端代码去连的。本章先讲一件必须刻进肌肉记忆的事——SQL 注入是怎么回事、为什么一行字符串拼接就能让整站沦陷;再讲最小权限、备份恢复这两道运维底线;最后用 pymysql 写出参数化的增删改查与事务。

SQL 注入:一句拼接毁掉整站

新手最致命的一个错误
SQL 注入:把「数据」拼进了「语法」;预处理:让数据和语法永远分开❌ 字符串拼接(危险)sql = "SELECT * FROM user WHERE name='" + name + "'用户输入 name = ' OR '1'='1拼出来变成恒真条件 → 全表用户泄露输入被当成了 SQL 语法的一部分✓ 参数化查询(安全)cursor.execute("SELECT * FROM user WHERE name=%s", (name,))先编译带 ? 的模板,再把输入塞进「值槽」输入永远是数据,参与不了语法解析记住:凡是「用户输入 + SQL」,必须走参数化,一次都不能拼
先讲为什么你写登录功能,很自然会想:用户名密码是用户输入的,那就拼进 SQL 里查呗。问题是——用户输入的不是「数据」,而是「一段字符串」;如果这段字符串里恰好含有 SQL 的语法符号(引号、-- 注释符、分号),它就会改变你原本那句 SQL 的含义。
打个比方:你写了一张请假条「我因 ___ 请假一天」,让同事填。结果同事填的是「感冒。附:本假条同时批准张三休假一年」——你把「填空」和「写指令」混在了一起,这就是 SQL 注入。
动手做先看一段错误的登录代码。它用 + 把用户输入直接拼进 SQL——这是全站最危险的一行。
login_bad.pypython
import pymysql

username = input("用户名:")
password = input("密码:")

conn = pymysql.connect(host="127.0.0.1", port=3306, user="hiwen_app",
                       password="App@Pass2026", database="hiwen", charset="utf8mb4")
try:
    with conn.cursor() as cur:
        # ✗ 反面教材:把用户输入用 + 直接拼进 SQL
        sql = ("SELECT id, username, nickname FROM user "
               "WHERE username = '" + username + "' AND password = '" + password + "'")
        cur.execute(sql)
        row = cur.fetchone()
        print("登录成功:" + str(row) if row else "用户名或密码错误")
finally:
    conn.close()
运行输出Python 运行结果
运行结果: 用户名:admin 密码:123456 用户名或密码错误 -- 正常输入时看着「没错」,问题只在遇到恶意输入时才暴露
动手做攻击者什么都不知道,只知道有个叫 admin 的账号(页面上写着「站长:admin」)。他在用户名框里输入 admin' -- ,密码框随便填一个。看看那句 SQL 被拼成了什么。
注入后的真实 SQL.sqlsql可运行
-- 攻击者输入: 用户名 = admin' -- (注意 -- 后面有一个空格)
-- 拼出来的语句变成了这样:
SELECT id, username, nickname FROM user
WHERE username = 'admin' -- ' AND password = '随便填的';

-- 在 MySQL 里 -- 之后是注释,整段「密码校验」被注释掉了!
-- 真实执行的只有:
SELECT id, username, nickname FROM user WHERE username = 'admin';
查询结果SQL 执行结果
运行结果: id | username | nickname 1 | admin | 管理员 -- 共 1 行 -- 密码校验被注释掉,攻击者没输对密码也登录成功了
讲透:注入的几种常见套路
  • 注释掉后半句:admin' -- ,把 AND password = … 整段废掉。这是登录绕过最经典的一招。
  • 永真条件:输入 ' OR '1'='1,条件变成 WHERE … OR '1'='1'——恒为真,结果返回整张表的内容,用户名存不存在都无所谓。
  • 堆叠多条语句:输入 '; DROP TABLE article; -- ,如果驱动允许一次执行多条语句,你的作品表就没了。这条是最狠的。
  • 本质只有一个:用户输入从「值」越界成了「语法」。所以防御思路也很明确——让输入永远只能是值,这就是下一节的预处理。
  • 注意:上面演示里 password 列在种子库里是占位符 PUT_HASH_HERE,本来就不能做明文比对。真实项目里密码永远存哈希,登录时应先按用户名查记录,再用 check_password_hash() 比对——但就算这么做了,只要拼接字符串,注入依然成立。
避坑:把这条当成铁律任何「用户能控制的内容」(表单、URL 参数、请求头、上传的文件名、甚至另一个接口返回的数据)都不允许用字符串拼接进 SQL。不要指望「我自己加个 replace("'", "") 过滤引号」这种小聪明——绕过方式多得是(宽字节、编码差异、数字型注入根本不用引号)。唯一的正解是参数化查询,一个字都别偷懒。

为什么预处理能防住注入

数据不参与语法解析
先讲为什么再看那张请假条的比方:正确的做法是发两张纸——第一张是模板「我因 ____ 请假一天」,先把这句话的语法结构定死;第二张是填空的内容,单独交给对方。不管对方在空格里写什么,都不会改变「这是一张请假条」这个事实。这就是预处理语句(Prepared Statement)。
讲透:为什么「过滤特殊字符」不行
  • 黑名单永远不完备:你能想到过滤单引号,那 URL 编码呢?宽字节 %bf%27 呢?纯数字型的 WHERE id = 1 OR 1=1 压根不需要引号。
  • 过滤会误伤正常数据:作品摘要里本来就有引号,一刀切过滤掉,用户看到的内容就变了。
  • 参数化的思路完全不同:它不是「把坏东西挑出来」,而是根本不给数据参与语法解析的机会。前者是打补丁,后者是换地基。
  • 代价几乎为零:把拼接改成占位符,代码只差几个字符,安全性却是天壤之别。没有理由不用。

预习语句:PREPARE / EXECUTE

在纯 SQL 里亲手走一遍
动手做先用最原始的 SQL 语法走一遍完整流程,亲眼看清「模板」与「值」是怎么分开的。? 就是占位符。
预处理.sqlsql可运行
-- ① 准备:把 SQL 模板交给服务器编译,? 是占位符
PREPARE stmt FROM 'SELECT id, title, author FROM article WHERE id = ?';

-- ② 给占位符赋值(用用户变量传值)
SET @aid = 1;

-- ③ 执行:把值填进模板
EXECUTE stmt USING @aid;

-- ④ 释放:不再使用就清掉,避免占用服务器资源
DEALLOCATE PREPARE stmt;
查询结果SQL 执行结果
运行结果: id | title | author 1 | 静夜思 | 李白 -- 共 1 行 -- PREPARE / SET / DEALLOCATE 三条本身没有结果集,只有 EXECUTE 返回上面这一行
动手做再把第一章那个攻击 payload 喂给参数化的语句,看看会发生什么。
注入失效.sqlsql可运行
PREPARE stmt FROM 'SELECT id, username FROM user WHERE username = ?';

-- 把攻击者的输入当成「一个普通字符串」传进去
SET @name = 'admin\'-- ';

EXECUTE stmt USING @name;

DEALLOCATE PREPARE stmt;
查询结果SQL 执行结果
运行结果: id | username -- 共 0 行 -- 没有哪个用户名真的等于 admin'-- 这一串字符,所以查不到 —— 注入失败了
讲透:对照着看两边的差别
  • 拼接写法下,那句话变成 WHERE username = 'admin' -- ' AND password = …,语法结构被用户改掉了。
  • 参数化写法下,模板始终是 WHERE username = ?,用户那串 admin'-- 只是被当成「一个很长、很奇怪的用户名」,语法结构纹丝不动。
  • 这就是全部的秘密:不是过滤了危险字符,而是危险字符根本没有机会被解析。
  • 实际开发中你不会手写 PREPARE:pymysql、SQLAlchemy 这些驱动已经帮你做了。但你必须知道底层发生了什么,否则就会写出「用 % 拼出 SQL 字符串再 execute」这种自以为安全的代码。

最小权限原则

别拿 root 跑应用
先讲为什么很多教程让你在代码里写 user="root", password="你的root密码",然后就能跑——于是大家都这么干了。但 root 是「上帝账号」:能删任何库、能改任何表、能读所有项目的数据。
万一你的代码真有一个注入漏洞,攻击者拿到的就是 root 的权限——他不再是「猜到了一个登录」,而是「拿到了整个数据库服务器的钥匙」。最小权限原则就一句话:应用只需要什么权限,就只给什么权限。
动手做给「古典文学网」建一个专用账号 hiwen_app:只能从本机连、只能碰 hiwen 这一个库、只给增删改查(不给建表改表删库)。
建账号授权.sqlsql可运行
-- ① 建一个只允许本机连接的应用账号
CREATE USER 'hiwen_app'@'localhost' IDENTIFIED BY 'App@Pass2026';

-- ② 只授「增删改查」,不给 CREATE / DROP / ALTER / GRANT
GRANT SELECT, INSERT, UPDATE, DELETE ON hiwen.* TO 'hiwen_app'@'localhost';

-- ③ 让权限立即生效
FLUSH PRIVILEGES;

-- ④ 检查一下到底给了什么
SHOW GRANTS FOR 'hiwen_app'@'localhost';
查询结果SQL 执行结果
运行结果: Grants for hiwen_app@localhost GRANT USAGE ON *.* TO `hiwen_app`@`localhost` GRANT SELECT, INSERT, UPDATE, DELETE ON `hiwen`.* TO `hiwen_app`@`localhost` -- 共 2 行 -- 第一行的 USAGE 是「空权限」,MySQL 建完账号都会显示它
动手做如果一开始手滑授多了,用 REVOKE 收回来。顺便看看怎么彻底删掉账号。
收权与删号.sqlsql可运行
-- 假设一开始图省事给了全部权限
GRANT ALL PRIVILEGES ON hiwen.* TO 'hiwen_app'@'localhost'; -- ✗ 太宽了

-- 全部收回,再按需要最小化地授
REVOKE ALL PRIVILEGES ON hiwen.* FROM 'hiwen_app'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON hiwen.* TO 'hiwen_app'@'localhost';
FLUSH PRIVILEGES;

-- 账号不用了就删掉
DROP USER 'hiwen_app'@'localhost';
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected Query OK, 0 rows affected Query OK, 0 rows affected Query OK, 0 rows affected Query OK, 0 rows affected -- 授权、收权、删号均无结果集,只返回执行状态
讲透:权限该怎么划
  • 'hiwen_app'@'localhost':主机部分决定了「允许从哪连」。开发时用 localhost;上线后应用和数据库在同一内网,就写内网 IP 段,尽量不要用 '%'(任意主机)。
  • 为什么给 DELETE 却不给 DROP:取消收藏、取消点赞本质上就是 DELETE 一行数据,属于正常业务;而 DROP/ALTER 是「改结构」,应用运行时压根不需要。
  • 再进一步还能拆得更细:报表/导出脚本用一个只有 SELECT 的只读账号;定时任务账号只给特定几张表的写权限。每个程序用自己那一份最小的钥匙。
  • 顺带提一句:hiwen_app 的密码同样要写进配置文件,而不是硬编码在代码里;配置文件加入 .gitignore,别把密码提交进 Git。
避坑:为什么不该用 root 跑应用① 一个注入漏洞 = 攻击者拿到删库权限;② root 看得见服务器上所有库,你的项目数据泄露会连带别人;③ 代码写错库名、写错 WHERE 时没有任何兜底,最小权限本身就是一道安全带——把损失锁死在「hiwen 库的增删改查」之内。另外也不要给应用 GRANT ALL ON *.*,那等于把 root 换了个名字。

备份与恢复

最便宜的保险
备份与恢复:一条导出命令 + 一条导入命令,就是最便宜的保险① 导出备份mysqldump -u root -phiwen > hiwen.sql② 保存 .sql 文件本地 + 异地各一份③ 恢复mysql -u root -p hiwen< hiwen.sqlhiwen.sql 里是什么?① CREATE DATABASE/TABLE(结构) ② INSERT INTO … VALUES(数据) ③ 必要的索引与视图别人拿到这一个文件,一条命令就能完整跑起你的数据库——这就是交付脚本习惯:每次大改数据前先 dump 一份;文件名带上日期
先讲为什么「手滑 DELETE FROM article 忘了写 WHERE」不是段子,是每个开发者迟早会遇到的事故。备份的作用不是「备份」,而是在出事之后你能回到出事之前。做备份花五分钟,不做可能损失一个月的用户数据。
动手做用 mysqldump 导出。它导出来的是一个 .sql 文本文件(建库建表语句 + INSERT 数据),可读、可 diff、可进 Git(数据敏感的话别进)。
备份.shbash可运行
# ① 导出整个 hiwen 库(结构 + 数据)到带日期的文件
mysqldump -uroot -p --databases hiwen > backup_hiwen_20261012.sql

# ② 只要结构不要数据(想拿一份干净的建表语句时用)
mysqldump -uroot -p --no-data hiwen > hiwen_schema_only.sql

# ③ 只导某几张表
mysqldump -uroot -p hiwen article category > backup_two_tables.sql

# ④ 确认文件生成成功
ls -lh *.sql
终端输出命令行执行结果
运行结果: Enter password: -rw-r--r-- 1 user staff 3.1K 10月 12 09:20 backup_hiwen_20261012.sql -rw-r--r-- 1 user staff 2.2K 10月 12 09:20 hiwen_schema_only.sql -rw-r--r-- 1 user staff 1.1K 10月 12 09:20 backup_two_tables.sql
动手做恢复有两条路:命令行直接灌进去,或先登录再用 source 执行脚本。后者更常用,因为你能先看一眼再执行。
恢复.sqlsql可运行
-- 先登录:  mysql -uroot -p
-- 再执行(source 后面建议写绝对路径,避免找不到文件)
source /Users/you/backup_hiwen_20261012.sql;

-- 验证数据是否回来了
SELECT COUNT(*) AS 作品数 FROM article;
查询结果SQL 执行结果
运行结果: 作品数 6 -- 共 1 行 -- 备份时用 --databases 导出的文件里含 CREATE DATABASE,会整库重建
讲透:备到点上才算数
  • --single-transaction 是生产备份的标配:让 InnoDB 在一个一致性快照里导出,不用锁表停服,导出期间用户照样能用。mysqldump -uroot -p --single-transaction --databases hiwen > backup.sql
  • 备份文件要验证能恢复。只备份、从不演练恢复,等于没有备份——真出事那天你才发现文件是空的或密码不对。
  • 只备份「全量」不够:生产环境一般是「每天一次全量 + binlog 增量」,才能把数据恢复到「误删前的那一秒」。
  • 别把备份放在同一台机器上:硬盘坏了、机器被删了,备份一起没。异地/对象存储是常规做法。
  • 恢复是有破坏性的操作:本项目的备份文件里含 DROP DATABASE + CREATE DATABASE,一执行就会把当前库覆盖掉。在生产环境恢复前,务必先确认目标库名。

Python 连接 MySQL 第一步

装上 pymysql,连上库
先讲为什么真实的网站不会有人坐在服务器上敲 SQL。后端代码要自己连数据库、发 SQL、拿结果。Python 连 MySQL 的驱动有好几个,我们选 pymysql——纯 Python 实现、安装简单、语法直白,最适合入门;等你学完课程二,Flask 里用的 SQLAlchemy 底层也是它。
动手做先安装驱动。Python 3.10+ 用 pip 直接装。
安装.shbash可运行
# 建议装在虚拟环境里
python -m venv .venv
source .venv/bin/activate

pip install pymysql
终端输出命令行执行结果
运行结果: Collecting pymysql Downloading PyMySQL-1.1.1-py3-none-any.whl (44 kB) Installing collected packages: pymysql Successfully installed pymysql-1.1.1 -- 版本号以你实际安装到的为准
动手做连上库、查三条最热作品。注意连接时用的是上一节建的应用账号 hiwen_app,不是 root。
connect_demo.pypython
import pymysql

# ① 建立连接
conn = pymysql.connect(
    host="127.0.0.1",       # 数据库地址
    port=3306,              # 端口
    user="hiwen_app",       # 应用专用账号
    password="App@Pass2026",
    database="hiwen",       # 默认库
    charset="utf8mb4",      # 一定要写,否则中文乱码
)

# ② 用游标执行 SQL 并取结果
with conn.cursor() as cur:
    cur.execute("SELECT id, title, author, dynasty FROM article ORDER BY views DESC LIMIT 3")
    rows = cur.fetchall()
    for row in rows:
        print(row)

# ③ 用完必须关连接
conn.close()
运行输出Python 运行结果
运行结果: (3, '念奴娇·赤壁怀古', '苏轼', '宋') (1, '静夜思', '李白', '唐') (4, '声声慢', '李清照', '宋')
讲透:连接、游标、结果
  • connect() 的关键参数:host / port / user / password / database / charset。少了 charset="utf8mb4" 是中文乱码的头号原因。
  • cursor(游标)是「执行 SQL 的手」:cur.execute(sql) 发出语句,fetchone() 取一条、fetchall() 取全部、fetchmany(n) 取 n 条。取完再取就是空。
  • 默认返回元组。输出里 (3, '念奴娇·赤壁怀古', '苏轼', '宋') 就是一行,顺序跟你 SELECT 的列顺序一致。想要字段名访问,可以换成字典游标:conn.cursor(pymysql.cursors.DictCursor),结果会变成 {'id': 3, 'title': '念奴娇·赤壁怀古', …}——写接口返回 JSON 时特别好用。
  • with conn.cursor() as cur 只管游标的关闭,不管连接的关闭,所以外层的 conn.close() 不能省。连接不关会一直占着数据库的连接数,几百个请求之后服务就卡死了。

参数化查询:把 %s 用在刀刃上

本章最重要的一节
pymysql 全流程:连接 → 游标 → 参数化执行 → 提交 → 释放connect()建立连接host/user/password/dbcursor()拿游标执行 SQL 的「手」execute(sql, p)参数化执行%s 占位params 元组conn.commit()提交事务写操作必须提交close()释放with 语句自动关一条安全的查询长这样cursor.execute('SELECT * FROM article WHERE dynasty=%s AND views>%s', ('唐', 100))查 SELECT 不用 commit;增删改 UPDATE/INSERT/DELETE 必须 commit 才真正落库
先讲为什么上一节那段查询是「写死的」,安全。可一旦条件来自用户(筛选朝代、搜索关键词),你就到了本章最关键的分岔口:是继续拼字符串,还是用占位符?拼错一个字符,前面九章学的都白搭。
动手做先把错误与正确的写法并排放在一起,看清差别只有几个字符。pymysql 的占位符是 %s(不论字段是什么类型,都用 %s)。
对与错.pypython
dynasty = "唐"

# ========== ✗ 错误:把用户输入拼进 SQL ==========
cur.execute("SELECT title, author FROM article WHERE dynasty = '" + dynasty + "'")
print("拼接写法:", cur.fetchall())

# ========== ✓ 正确:占位符 %s + 参数元组 ==========
cur.execute("SELECT title, author FROM article WHERE dynasty = %s", (dynasty,))
#                                                                    ↑ 注意这个逗号
print("参数化写法:", cur.fetchall())

# 多个参数就多放几个占位符,按位置一一对应
cur.execute(
    "SELECT title FROM article WHERE dynasty = %s AND views > %s ORDER BY views DESC",
    (dynasty, 200),
)
print("两个参数:", cur.fetchall())
运行输出Python 运行结果
运行结果: 拼接写法: [('静夜思', '李白'), ('春望', '杜甫'), ('陋室铭', '刘禹锡')] 参数化写法: [('静夜思', '李白'), ('春望', '杜甫'), ('陋室铭', '刘禹锡')] 两个参数: [('静夜思',), ('春望',), ('陋室铭',)] -- 正常输入下两种写法输出完全一样,差别只在遇到恶意输入时才暴露
动手做完整跑一遍,再试试把攻击 payload 传进去会怎样。
param_query.pypython
import pymysql

conn = pymysql.connect(host="127.0.0.1", port=3306, user="hiwen_app",
                       password="App@Pass2026", database="hiwen", charset="utf8mb4")
try:
    with conn.cursor() as cur:
        # ① 正常筛选:唐朝作品,浏览量从高到低
        cur.execute(
            "SELECT title, author, views FROM article WHERE dynasty = %s ORDER BY views DESC",
            ("唐",),
        )
        for row in cur.fetchall():
            print(row)

        # ② 把注入 payload 当成朝代名传进去
        evil = "唐' OR '1'='1"
        cur.execute("SELECT COUNT(*) AS n FROM article WHERE dynasty = %s", (evil,))
        print("注入测试结果:", cur.fetchone())
finally:
    conn.close()
运行输出Python 运行结果
运行结果: ('静夜思', '李白', 328) ('春望', '杜甫', 256) ('陋室铭', '刘禹锡', 203) 注入测试结果: (0,) -- payload 被当成一个普通字符串,库里没有哪个朝代叫「唐' OR '1'='1」,所以是 0 条
讲透:三个容易写错的细节
  • 单元素参数必须带逗号:(dynasty,) 才是元组;写成 (dynasty) 只是个加了括号的字符串,会报「参数数量不对」。
  • 不要给 %s 加引号:正确写法是 = %s,不是 = '%s'。加了引号就变成「先拼成字符串再当值」,又回到拼接的老路了。
  • 类型不用你操心:日期、数字、字符串、None 都统一写 %s,驱动会负责正确的类型转换与转义。
  • 模糊搜索的参数要自己带上通配符:cur.execute("… WHERE title LIKE %s", ('%' + kw + '%',))——通配符在「值」里,不在 SQL 里,依然安全。
避坑:占位符只能替换「值」,不能替换表名、列名、排序方向这类结构性内容。cur.execute("SELECT * FROM %s ORDER BY %s DESC", (table, col)) 是错的——数据库在 PREPARE 阶段就要确定语句结构,表名列名必须在那时已知。
碰到这种情况(接口的 order_by 参数就是典型),正确做法是白名单映射:把用户传来的值映射成固定的 SQL 片段,非法值一律回落到默认。见下面这段:
白名单排序.pypython
# 接口参数:order_by = latest / hot / likes(对应接口文档里的作品列表)
ORDER_MAP = {
    "latest": "id DESC",
    "hot":    "views DESC",
    "likes":  "likes DESC",
}

def build_order(order_by: str) -> str:
    # 不在白名单里的值,一律回落到默认排序,绝不拼进 SQL
    return ORDER_MAP.get(order_by, "id DESC")

cur.execute(f"SELECT id, title, views FROM v_article_list ORDER BY {build_order('hot')} LIMIT 3")
for row in cur.fetchall():
    print(row)
运行输出Python 运行结果
运行结果: (3, '念奴娇·赤壁怀古', 412) (1, '静夜思', 328) (4, '声声慢', 298)

增删改查与事务

写操作一定要 commit
先讲为什么查能防注入了,写呢?写操作多了两件事:一是必须 commit,二是最好包在事务里。因为真实业务常常是「多个动作要一起成功」——比如点赞:user_like 插一行记录,同时 article.likes 加一。如果只成功了前一步,数据就永久错乱了。
动手做把增、改、查、删写在一个事务里跑一遍,并打印影响行数便于核对。
crud_tx.pypython
import pymysql

conn = pymysql.connect(host="127.0.0.1", port=3306, user="hiwen_app",
                       password="App@Pass2026", database="hiwen", charset="utf8mb4")
try:
    with conn.cursor() as cur:
        # ① 增:用户 1 收藏作品 5
        cur.execute("INSERT INTO favorite (user_id, article_id) VALUES (%s, %s)", (1, 5))
        print("插入行数:", cur.rowcount, "新记录 id:", cur.lastrowid)

        # ② 改:浏览量 +1
        cur.execute("UPDATE article SET views = views + 1 WHERE id = %s", (5,))
        print("更新行数:", cur.rowcount)

        # ③ 查
        cur.execute("SELECT title, views FROM article WHERE id = %s", (5,))
        print("查询结果:", cur.fetchone())

        # ④ 删:取消刚才那次收藏
        cur.execute("DELETE FROM favorite WHERE user_id = %s AND article_id = %s", (1, 5))
        print("删除行数:", cur.rowcount)

    conn.commit()                 # 四步全部成功,一起提交
    print("事务已提交")
except Exception as e:
    conn.rollback()               # 任何一步出错,全部撤销
    print("出错已回滚:", e)
finally:
    conn.close()
运行输出Python 运行结果
运行结果: 插入行数: 1 新记录 id: 4 更新行数: 1 查询结果: ('天净沙·秋思', 188) 删除行数: 1 事务已提交

连接对象本身也支持 with,写起来更省心:正常退出自动提交,出异常自动回滚,并帮你关掉连接。

with_context.pypython
import pymysql

DB_CONF = dict(host="127.0.0.1", port=3306, user="hiwen_app",
               password="App@Pass2026", database="hiwen", charset="utf8mb4")

# 连接对象支持 with:正常退出自动 commit,异常自动 rollback,并自动关闭
with pymysql.connect(**DB_CONF) as conn:
    with conn.cursor() as cur:
        cur.execute("SELECT COUNT(*) AS n FROM article")
        print(cur.fetchone())
运行输出Python 运行结果
运行结果: (6,)
讲透:事务这件事
  • pymysql 默认 autocommit=False:执行完 INSERT / UPDATE / DELETE 后,数据其实还在事务里没落地。只有 conn.commit() 之后,别的连接才看得到。
  • 不 commit 会怎样:连接一关(或被回收),InnoDB 就把未提交的事务回滚掉——你会看到「代码没报错,但库里没数据」这个经典现象。反过来,想撤销就调 conn.rollback()。
  • rowcount 与 lastrowid 要会用:cur.rowcount 是本次语句影响的行数(改 0 行往往说明 WHERE 没命中),cur.lastrowid 是刚插入那行的自增主键——后端返回「新建的 id」就靠它。
  • 事务要短:事务里别做耗时操作(调外部接口、读大文件)。事务开得越久,锁住的行越多,并发下别人就得排队等。
  • 查操作不需要 commit,但连接照样要关——with 或 try / finally 二选一,别裸写。
避坑还有一个容易被忽略的坑:函数里提前 return 或抛异常,漏掉了 commit / rollback。推荐统一用 with pymysql.connect(...) as conn: 包住整段逻辑——它会在退出时按「有异常就回滚、没异常就提交」处理,把这类低级错误从根上消掉。注意它只解决连接层,游标层仍建议再套一层 with conn.cursor()。
动手练习
1把本章开头那段「拼接字符串」的登录代码改写成参数化查询。注意:真实项目不能拿密码明文去比对,请说明正确做法。
2为「只读报表」建一个账号 hiwen_read,只给查询权限,并用 SHOW GRANTS 验证。想一想:为什么不干脆让报表也用 hiwen_app?
3迁移练习:「红影赏析」要按关键词搜索电影标题(movie 表,字段 id、title、rating)。请写出参数化的查询,并说明为什么不能写成 "… LIKE '%" + kw + "%'"。
参考答案
练习答案.pypython
import pymysql

conn = pymysql.connect(host="127.0.0.1", port=3306, user="hiwen_app",
                       password="App@Pass2026", database="hiwen", charset="utf8mb4")

# ===== 第 1 题:登录改成参数化 =====
try:
    with conn.cursor() as cur:
        # 正确做法:先按用户名取记录,再用后端的哈希校验函数比对密码,
        # 而不是把密码拼进 SQL —— 密码列永远不参与 SQL 条件。
        cur.execute("SELECT id, username, nickname, password FROM user WHERE username = %s", ("admin",))
        row = cur.fetchone()
        if row:
            print("查到用户:", row[0], row[1], row[2])
            # 真实项目这里写: check_password_hash(row[3], 用户输入的密码)
        else:
            print("用户名或密码错误")
finally:
    conn.close()
运行输出Python 运行结果
运行结果: 查到用户: 1 admin 管理员
练习答案2.sqlsql可运行
-- 第 2 题:只读账号
CREATE USER 'hiwen_read'@'localhost' IDENTIFIED BY 'Read@Pass2026';
GRANT SELECT ON hiwen.* TO 'hiwen_read'@'localhost';
FLUSH PRIVILEGES;
SHOW GRANTS FOR 'hiwen_read'@'localhost';
查询结果SQL 执行结果
运行结果: Grants for hiwen_read@localhost GRANT USAGE ON *.* TO `hiwen_read`@`localhost` GRANT SELECT ON `hiwen`.* TO `hiwen_read`@`localhost` -- 共 2 行
练习答案3.pypython
# 第 3 题(红影赏析):按关键词搜电影标题
kw = "星际"

conn = pymysql.connect(host="127.0.0.1", port=3306, user="movie_app",
                       password="App@Pass2026", database="movie_db", charset="utf8mb4")
try:
    with conn.cursor() as cur:
        # 通配符放在「值」里,SQL 里只留一个 %s —— 依然安全
        cur.execute(
            "SELECT id, title, rating FROM movie WHERE title LIKE %s ORDER BY rating DESC",
            ("%" + kw + "%",),
        )
        print(cur.fetchall())
finally:
    conn.close()
运行输出Python 运行结果
运行结果: [(2, '星际穿越', 9.3)] -- 结果以库里数据为准

第 3 题的关键在于「通配符属于值」:'%' + kw + '%' 拼的是要传进去的参数,SQL 模板里永远只有一个 %s,所以安全性没有任何损失。而 "… LIKE '%" + kw + "%'" 拼的是 SQL 语句本身——如果 kw 里带了单引号,语句结构就被改写了。判断标准就一句话:你拼的是「值」还是「SQL」?

本章小结