RSS
菜单
全部文章快讯开发科技深度热点

第 33 章 pandas 与 MySQL 实战:读写与 SQL 对比

内容摘要

本篇把 pandas 真正接到生产数据库上,覆盖 **SQLAlchemy 连接、建表 DDL、read_sql 读取、to_sql 写入、参数化查询防 SQL 注入、chunksize 大批量写入、pandas 操作与 SQL 语句对照表**,以及时区/类型映射/字符集/事务四大常见坑。前置知识:已掌握[第 22 章](22-数据读写进阶.md)的数据库读写基础、会写基础 SELECT/JOIN/GROUP BY。学完后你能独立搭一条"CSV → 清洗 → 入库 → SQL 反查 → 报表"的数据管道,并自信应对面试中"pandas 怎么连 MySQL"这类题。

📖 本篇简介:本篇把 pandas 真正接到生产数据库上,覆盖 SQLAlchemy 连接、建表 DDL、read_sql 读取、to_sql 写入、参数化查询防 SQL 注入、chunksize 大批量写入、pandas 操作与 SQL 语句对照表,以及时区/类型映射/字符集/事务四大常见坑。前置知识:已掌握第 22 章的数据库读写基础、会写基础 SELECT/JOIN/GROUP BY。学完后你能独立搭一条"CSV → 清洗 → 入库 → SQL 反查 → 报表"的数据管道,并自信应对面试中"pandas 怎么连 MySQL"这类题。
前面章节的数据源都是 CSV/Excel。真实生产环境里,90% 的数据躺在关系型数据库里。本篇以 MySQL 为例,带你走通 pandas 与数据库的完整读写链路。若本机没有 MySQL,用 SQLite 也能跑通全部示例——SQLAlchemy 会把方言差异屏蔽掉,只需换一行连接串。

目录

1. 环境准备与连接串

2. 建库建表:DDL 示例

3. read_sql:从数据库读 DataFrame

4. to_sql:把 DataFrame 写回数据库

5. 参数化查询:防 SQL 注入

6. pandas 操作 ↔ SQL 语句对照表

7. 大批量写入:chunksize 优化

8. 四大常见坑:时区 / 类型 / 字符集 / 事务

9. 完整实战:CSV → 清洗 → 入库 → 反查

10. 本章小结


1. 环境准备与连接串

1.1 安装依赖

# 必装:SQLAlchemy(ORM 引擎,pandas 底层用它连数据库)
pip install sqlalchemy

# MySQL 官方驱动(二选一)
pip install pymysql              # 纯 Python,安装简单,推荐入门
# pip install mysqlclient        # C 扩展,性能更好,但 Windows 编译麻烦

# 如果本机没有 MySQL,用 SQLite 演示(Python 自带,无需安装)
说明:pandas 自己不直接连数据库,它通过 SQLAlchemy Engine 统一对接各种数据库。你只要换一行连接串,代码就能从 SQLite 切到 MySQL/PostgreSQL。

1.2 连接串格式

from sqlalchemy import create_engine

# ===== MySQL 连接串 =====
# 格式:mysql+pymysql://用户名:密码@主机:端口/数据库名?字符集
engine_mysql = create_engine(
    "mysql+pymysql://root:123456@127.0.0.1:3306/testdb?charset=utf8mb4",
    echo=False,          # True 会打印所有 SQL,调试用
    pool_size=5,         # 连接池大小,生产建议 5~10
    pool_recycle=3600,   # 连接回收秒数,避满 MySQL 的 wait_timeout
)

# ===== SQLite 连接串(本机无 MySQL 时用这个演示)=====
# 格式:sqlite:///文件路径(三个斜杠 + 相对/绝对路径)
engine_sqlite = create_engine("sqlite:///demo.db")

# ===== 后续所有示例用 engine 变量统一指代 =====
# MySQL 场景下把下面这行换成 engine_mysql 即可,业务代码一行不用改
engine = engine_sqlite
关键:本篇所有代码都用 engine 这个变量。MySQL 场景下替换连接串即可,read_sql / to_sql 的写法完全一样。

1.3 测试连接

# 执行一条原始 SQL,验证连接通不通
with engine.connect() as conn:
    result = conn.exec_driver_sql("SELECT 1 AS ok")
    print(result.fetchone())     # (1,)

2. 建库建表:DDL 示例

2.1 用 pandas 直接建表(最简单)

很多人不知道:`to_sql` 第一次写入时会自动建表,不需要手写 DDL。但生产环境建议显式建表,控制字段类型。

import pandas as pd

# 先造一份样例数据
users = pd.DataFrame({
    "user_id": [1, 2, 3, 4],
    "name":    ["张三", "李四", "王五", "赵六"],
    "city":    ["上海", "北京", "上海", "深圳"],
    "age":     [28, 35, 22, 40],
})

# to_sql 第一次写:自动建表(if_exists="replace" 表示每次重建)
users.to_sql("users", engine, if_exists="replace", index=False)
print("users 表已创建,行数:", len(users))

2.2 手写 DDL(生产推荐)

from sqlalchemy import text

# 用 SQLAlchemy 的 text() 包原始 SQL,防止注入
DDL_USERS = """
CREATE TABLE IF NOT EXISTS users (
    user_id  INT PRIMARY KEY,
    name     VARCHAR(50) NOT NULL,
    city     VARCHAR(50),
    age      INT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"""

DDL_ORDERS = """
CREATE TABLE IF NOT EXISTS orders (
    order_id  INT PRIMARY KEY AUTO_INCREMENT,
    user_id   INT,
    amount    DECIMAL(10,2),
    order_date DATE,
    FOREIGN KEY (user_id) REFERENCES users(user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"""

# SQLite 不支持 ENGINE/FOREIGN KEY 语法,下面这句只在 MySQL 执行
# engine.connect().execute(text(DDL_USERS))
# engine.connect().execute(text(DDL_ORDERS))

# SQLite 兼容写法(本篇演示用)
with engine.begin() as conn:                     # begin() 会自动提交事务
    conn.execute(text("""
        CREATE TABLE IF NOT EXISTS users (
            user_id  INTEGER PRIMARY KEY,
            name     TEXT NOT NULL,
            city     TEXT,
            age      INTEGER
        )
    """))
    conn.execute(text("""
        CREATE TABLE IF NOT EXISTS orders (
            order_id  INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id   INTEGER,
            amount    REAL,
            order_date DATE
        )
    """))
print("两张表已创建(SQLite 语法;MySQL 换成上面 DDL_USERS/DDL_ORDERS 即可)")
MySQL vs SQLite DDL 差异:
- 类型:INT vs INTEGER、VARCHAR vs TEXT、DATETIME vs TIMESTAMP;
- 引擎:MySQL 要 ENGINE=InnoDB DEFAULT CHARSET=utf8mb4,SQLite 没有;
- 自增:MySQL 用 AUTO_INCREMENT,SQLite 用 AUTOINCREMENT。

3. read_sql:从数据库读 DataFrame

3.1 全表读取

# 方式 1:直接传表名(最简单,但会 SELECT *)
df1 = pd.read_sql("users", engine)
print(df1)

# 方式 2:写 SQL(推荐,只取需要的列)
df2 = pd.read_sql("SELECT user_id, name, city FROM users", engine)
print(df2)

3.2 带参数的查询

# 参数化查询:用 :name 占位,params 传字典(防注入,详见第 5 节)
df = pd.read_sql(
    "SELECT * FROM users WHERE city = :c AND age > :min_age",
    engine,
    params={"c": "上海", "min_age": 25},
)
print(df)

3.3 分块读取(大表)

# chunksize=10000:每次只读 1 万行,返回一个迭代器
# 适合表很大(千万行)、内存装不下的场景
chunk_iter = pd.read_sql("SELECT * FROM orders", engine, chunksize=10000)

total = 0
for chunk in chunk_iter:                       # chunk 本身就是一个小 DataFrame
    total += chunk["amount"].sum()
print("orders 表总金额:", total)

3.4 只读前 N 行看结构

# 调试时先看 5 行,别 SELECT * 拉全表
preview = pd.read_sql("SELECT * FROM users LIMIT 5", engine)
print(preview.dtypes)

4. to_sql:把 DataFrame 写回数据库

4.1 if_exists 三种模式

import pandas as pd

new_users = pd.DataFrame({
    "user_id": [5, 6],
    "name":    ["钱七", "孙八"],
    "city":    ["杭州", "成都"],
    "age":     [30, 28],
})

# if_exists 三种模式:
#   "fail"    表已存在就报错(默认)
#   "replace" 先 DROP 再 CREATE(清空重写)
#   "append"  追加到已有表(最常用)
new_users.to_sql("users", engine, if_exists="append", index=False)

# 验证
print(pd.read_sql("SELECT * FROM users", engine))

4.2 index 参数

# index=False:不要把 DataFrame 的行号写进数据库(绝大多数情况都要 False)
# index=True:会多一列叫 "index",通常是你不想要的
new_users.to_sql("users", engine, if_exists="append", index=False)

4.3 dtype 指定字段类型

# 写入时显式指定列类型,避免 pandas 自动推断出错(比如把 VARCHAR 推断成 TEXT)
from sqlalchemy import Integer, String, Float

new_users.to_sql(
    "users", engine, if_exists="append", index=False,
    dtype={
        "user_id": Integer(),
        "name":    String(50),
        "city":    String(50),
        "age":     Integer(),
    },
)

5. 参数化查询:防 SQL 注入

5.1 错误示范:拼接 SQL(高危)

# ❌ 绝对不要这样写!
def get_user_bad(city: str):
    # 直接把用户输入拼进 SQL 字符串——经典 SQL 注入漏洞
    sql = f"SELECT * FROM users WHERE city = '{city}'"
    return pd.read_sql(sql, engine)

# 如果用户输入是 "上海' OR '1'='1",拼出来的 SQL 就变成:
# SELECT * FROM users WHERE city = '上海' OR '1'='1'
# 结果:把全表数据都吐出来了!

5.2 正确姿势:参数化查询

# ✅ 正确:用占位符 + params 字典
def get_user_good(city: str, min_age: int = 0):
    # :city 是命名占位符,pandas/SQLAlchemy 会自动转义
    sql = "SELECT * FROM users WHERE city = :city AND age >= :min_age"
    return pd.read_sql(sql, engine, params={"city": city, "min_age": min_age})

print(get_user_good("上海", 25))
# 即使 city = "上海' OR '1'='1",也只会查 city 恰好等于这个字符串的行,不会注入

5.3 不同数据库的占位符

| 数据库 | 占位符风格 | 示例 |

|--------|------------|------|

| SQLAlchemy 统一写法 | 命名占位 | :city |

| SQLite | ? 或 :name | WHERE city = ? |

| MySQL(pymysql) | %s 或 %(name)s | WHERE city = %s |

| psycopg2 (PG) | %s 或 %(name)s | WHERE city = %s |

建议:一律用 SQLAlchemy 的 :name 命名占位符 + params 字典。这样换数据库不用改 SQL。

6. pandas 操作 ↔ SQL 语句对照表

| 业务需求 | SQL | pandas |

|----------|-----|--------|

| 全表查询 | SELECT * FROM users | pd.read_sql("users", engine) |

| 选列 | SELECT name, age FROM users | pd.read_sql("SELECT name, age FROM users", engine) |

| WHERE 过滤 | SELECT * FROM users WHERE age > 30 | df[df.age > 30] |

| 排序 | SELECT * FROM users ORDER BY age DESC | df.sort_values("age", ascending=False) |

| LIMIT | SELECT * FROM users LIMIT 10 | df.head(10) |

| DISTINCT | SELECT DISTINCT city FROM users | df["city"].unique() |

| GROUP BY | SELECT city, AVG(age) FROM users GROUP BY city | df.groupby("city")["age"].mean() |

| HAVING | SELECT city, COUNT(*) c FROM users GROUP BY city HAVING c > 1 | g = df.groupby("city").size(); g[g > 1] |

| INNER JOIN | SELECT * FROM orders o JOIN users u ON o.user_id=u.user_id | pd.merge(orders, users, on="user_id", how="inner") |

| LEFT JOIN | SELECT * FROM orders o LEFT JOIN users u ON ... | pd.merge(orders, users, on="user_id", how="left") |

| 聚合 SUM | SELECT SUM(amount) FROM orders | df["amount"].sum() |

| 聚合 COUNT | SELECT COUNT(*) FROM orders | len(df) 或 df.shape[0] |

| 子查询 | SELECT * FROM users WHERE user_id IN (SELECT user_id FROM orders) | df[df.user_id.isin(orders.user_id)] |

| 窗口函数 | ROW_NUMBER() OVER (PARTITION BY city ORDER BY age DESC) | df.groupby("city")["age"].rank(ascending=False) |

# 举个完整例子:用 pandas 等价于下面这条 SQL
# SELECT city, COUNT(*) AS cnt, AVG(age) AS avg_age
# FROM users
# WHERE age >= 18
# GROUP BY city
# HAVING cnt >= 1
# ORDER BY cnt DESC
result = (
    users[users.age >= 18]
    .groupby("city", as_index=False)
    .agg(cnt=("user_id", "count"), avg_age=("age", "mean"))
    .query("cnt >= 1")
    .sort_values("cnt", ascending=False)
)
print(result)

7. 大批量写入:chunksize 优化

7.1 为什么要分块

一次性 to_sql 写 100 万行,会把所有数据打包成一个巨型 INSERT 语句,数据库瞬间被打爆。`chunksize` 让 pandas 分批提交。

import numpy as np

# 造 50 万行模拟数据
big = pd.DataFrame({
    "user_id": range(1, 500_001),
    "name":    [f"user_{i}" for i in range(1, 500_001)],
    "city":    np.random.choice(["上海","北京","广州","深圳"], 500_000),
    "amount":  np.random.uniform(10, 1000, 500_000).round(2),
})

# ❌ 错误:一次性写 50 万行,可能 OOM 或锁表
# big.to_sql("big_table", engine, if_exists="replace", index=False)

# ✅ 正确:每批 5000 行提交一次
big.to_sql(
    "big_table", engine,
    if_exists="replace",      # 第一次先建表
    index=False,
    chunksize=5000,          # 关键:每批 5000 行
    method="multi",          # 用多值 INSERT,比逐行快 10 倍
)
print("50 万行已分块写入")

7.2 chunksize 怎么选

| 数据量 | chunksize 建议 |

|--------|----------------|

| < 1 万行 | 不分块,一次写完 |

| 1 万 ~ 100 万 | 5000 ~ 10000 |

| > 100 万 | 10000 ~ 50000,配合 method="multi" |

经验:chunksize 不是越大越好。太大 = 单条 SQL 太长、锁表久;太小 = 网络往返多。5000~10000 是甜点区。

7.3 method 参数

  • 默认:逐行 INSERT(最慢);
  • method="multi":多值 INSERT(一条 SQL 插多行,快 5~10 倍);
  • MySQL 还可以用 method="multi" 配合 executemany。

8. 四大常见坑:时区 / 类型 / 字符集 / 事务

8.1 时区问题

# 坑:MySQL 存的是 UTC,pandas 读出来变成 naive datetime,差 8 小时
# 解决 1:连接串里指定时区
# mysql+pymysql://root:pw@host:3306/db?charset=utf8mb4&local_infile=1

# 解决 2:读出来后手动本地化
df["order_date"] = pd.to_datetime(df["order_date"]).dt.tz_localize("Asia/Shanghai")

8.2 类型映射

| pandas 类型 | MySQL 推荐类型 | 注意 |

|-------------|----------------|------|

| int64 | BIGINT / INT | 小心 NULL:int 列有 NULL 会变 float64 |

| float64 | DOUBLE / DECIMAL | 金额用 DECIMAL(10,2),别用 FLOAT |

| bool | TINYINT(1) | MySQL 没有原生 BOOL |

| datetime64 | DATETIME / TIMESTAMP | 时区问题见 8.1 |

| string/object | VARCHAR / TEXT | 中文务必用 utf8mb4 |

| category | VARCHAR + 索引 | 高基数字符串别用 category 入库 |

# 坑:pandas 的 int 列一旦有 NaN,自动变 float64(int 不支持 NaN)
# 写库时会变成 DOUBLE,而不是 INT
# 解决:写库前 fillna 或转成 Int64(pandas 的可空整数类型)
df["age"] = df["age"].astype("Int64")       # 注意大写 I

8.3 字符集

# 坑:MySQL 默认 latin1 存中文乱码
# 解决 1:建库时指定
# CREATE DATABASE testdb DEFAULT CHARSET utf8mb4;

# 解决 2:连接串加 charset
# "mysql+pymysql://root:pw@host:3306/db?charset=utf8mb4"

# 解决 3:表级别指定
# CREATE TABLE users (...) CHARSET=utf8mb4;

8.4 事务提交

# 坑:to_sql 默认会自动提交,但用 engine.begin() 手动控制时容易忘 commit
from sqlalchemy import text

# ✅ 正确:with engine.begin() 会自动 commit / rollback
with engine.begin() as conn:
    conn.execute(text("DELETE FROM users WHERE user_id = 999"))
    # 出异常会自动 rollback,正常结束自动 commit

# ❌ 危险:engine.connect() 不会自动提交,需要手动 commit
# conn = engine.connect()
# conn.execute(text("DELETE FROM users WHERE user_id = 999"))
# conn.commit()    # 忘了这行,数据根本没删!

9. 完整实战:CSV → 清洗 → 入库 → 反查

import pandas as pd
from sqlalchemy import create_engine, text

# 1. 造一份 CSV(实际项目里是 pd.read_csv("orders.csv"))
orders_csv = pd.DataFrame({
    "order_id": [1, 2, 3, 4, 5, 6],
    "user_id":  [1, 1, 2, 2, 3, 3],
    "amount":   [100, 200, 150, np.nan, 300, 250],
    "city":     ["上海", "上海", "北京", "北京", "上海", "北京"],
    "date":     ["2025-01-05","2025-01-06","2025-01-07","2025-01-08","2025-01-09","2025-01-10"],
})

# 2. 清洗
orders_csv["amount"] = pd.to_numeric(orders_csv["amount"], errors="coerce")
orders_csv = orders_csv.dropna(subset=["amount"])          # 删掉金额缺失的行
orders_csv["date"] = pd.to_datetime(orders_csv["date"])

# 3. 入库
engine = create_engine("sqlite:///ecommerce.db")
orders_csv.to_sql("orders", engine, if_exists="replace", index=False, chunksize=1000)

# 4. 用 SQL 反查:每个城市的订单总额
sql_result = pd.read_sql("""
    SELECT city,
           COUNT(*)    AS order_cnt,
           SUM(amount) AS total_amount
    FROM orders
    GROUP BY city
    ORDER BY total_amount DESC
""", engine)
print("=== SQL 反查结果 ===")
print(sql_result)

# 5. 等价的 pandas 写法(对比)
pd_result = (
    orders_csv.groupby("city", as_index=False)
    .agg(order_cnt=("order_id", "count"), total_amount=("amount", "sum"))
    .sort_values("total_amount", ascending=False)
)
print("=== pandas 等价结果 ===")
print(pd_result)
=== SQL 反查结果 ===
   city  order_cnt  total_amount
0    上海          3        600.0
1    北京          2        400.0
=== pandas 等价结果 ===
   city  order_cnt  total_amount
1    上海          3        600.0
0    北京          2        400.0
体会:同一份数据,SQL 和 pandas 都能算。区别在于——
- 数据已经在数据库里 → 用 SQL 聚合完再 read_sql 拉结果,省内存;
- 数据已经在 pandas 里 → 直接 pandas 操作,省一次 IO。

10. 本章小结

10.1 速查

| 操作 | API |

|------|-----|

| 连接 | create_engine("mysql+pymysql://user:pw@host:3306/db?charset=utf8mb4") |

| 读全表 | pd.read_sql("table", engine) |

| 读 SQL | pd.read_sql("SELECT ...", engine, params={...}) |

| 写表 | df.to_sql("table", engine, if_exists="append", index=False) |

| 分块读 | pd.read_sql(..., chunksize=10000) |

| 分块写 | df.to_sql(..., chunksize=5000, method="multi") |

| 防注入 | 永远用 params 传参,别拼 SQL |

10.2 第三方库清单

  • pandas:DataFrame 主体;
  • sqlalchemy:连接引擎(必装);
  • pymysql:MySQL 驱动(纯 Python,Windows 友好);
  • mysqlclient:MySQL C 驱动(可选,更快但难装);
  • SQLite:Python 自带,无需安装。

10.3 练习

1. 把本篇的 SQLite 示例换成 MySQL 连接串,在本机 MySQL 上跑通。

2. 造 100 万行数据,分别用默认 to_sql 和 chunksize=5000, method="multi" 写入,对比耗时。

3. 用参数化查询实现一个函数 get_orders_by_city(city, start_date, end_date),打印 SQL 语句观察占位符。

4. 故意拼一条 f"... WHERE city='{city}'" 的 SQL,传入 city="上海' OR '1'='1",观察结果差异。

5. 把第 32 章题 10 的 pandas 版本改写成先 read_sql 入库、再用 SQL 聚合的版本。


下一篇预告:第 34 章 pandas 与 PostgreSQL 实战:大结果集与连接池 —— 进阶到 PG,掌握流式查询、连接池配置与大结果集不爆内存。
— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 33 章 pandas 与 MySQL 实战:读写与 SQL 对比》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。