MySQL 08 - 与后端语言集成

数据库的立身之本是为后端服务提供数据持久化。学会建库建表和写 SQL 只是第一步,真正的工作是把 MySQL 接进 Java、Python、C、Go、Node.js 这些后端程序里:建立连接、执行查询、管理事务、处理并发。本章就是这门「衔接课」。

前置:SQL 基础语法索引事务与优化。本章示例默认你已有一个可用的数据库(见 01 安装与配置)。


一、连接的三层模型

任何语言连接 MySQL,本质都走同一条路:

flowchart LR
    APP["应用程序<br/>(Java/Python/C/Go/...)"] --> POOL["连接池<br/>(复用连接)"]
    POOL --> DRIVER["数据库驱动<br/>(协议实现)"]
    DRIVER -->|"TCP 3306 / Unix Socket"| SERVER["MySQL Server"]
    SERVER --> ENGINE["InnoDB 存储引擎"]
职责举例
驱动把语言的调用翻译成 MySQL 协议JDBC Driver、PyMySQL、libmysqlclient、go-sql-driver、mysql2
连接池复用 TCP 连接,控制并发数,提供健康检查HikariCP、Druid、SQLAlchemy Pool、database/sql 内置池
应用层组装 SQL、处理结果、管理事务你的业务代码 / ORM

为什么必须有连接池? MySQL 建立一条连接需要 TCP 三次握手 + 认证握手,成本约几毫秒到几十毫秒。Web 服务每秒可能处理上千请求,如果每个请求都新建连接,数据库会先被连接建立压垮。连接池预先建立若干连接并复用,把开销摊薄到接近零。

1.1 连接池的关键参数

以 HikariCP 为例(各语言参数名不同,含义相通):

参数含义建议
maximumPoolSize最大连接数4 核机器约 10~20,不是越大越好
minimumIdle最小空闲连接数与 maximumPoolSize 相同可避免抖动
connectionTimeout获取连接超时3~5 秒,避免请求长时间挂起
idleTimeout空闲连接回收时间10 分钟
maxLifetime连接最大存活时间比 MySQL 的 wait_timeout 小 30 秒
connectionTestQuery连接健康检查现代驱动用 JDBC4 校验,无需配置

重要公式:连接池不是越大越好。经验公式为
连接数 ≈ CPU 核数 × 2 + 磁盘数
数据库的真实瓶颈通常是磁盘 IO 与锁,几百个连接只会让它们互相排队。后端服务 4 核配 10~20 个连接,通常比 200 个更快。


二、各语言连接 MySQL

2.1 Java(JDBC)

JDBC 是 Java 访问数据库的标准接口,MySQL 官方驱动为 mysql-connector-j

Maven 依赖:

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>9.1.0</version>
</dependency>

JDBC 六步(最核心的模板):

import java.sql.*;
 
public class JdbcDemo {
    public static void main(String[] args) throws Exception {
        // 1. 连接 URL:协议:子协议://主机:端口/库名?参数
        String url = "jdbc:mysql://localhost:3306/school"
                   + "?characterEncoding=utf8&serverTimezone=Asia/Shanghai";
        // 2. 建立连接(生产环境由连接池提供,不要手写 DriverManager)
        try (Connection conn = DriverManager.getConnection(url, "appuser", "app123")) {
 
            // 3. 创建 PreparedStatement,? 是占位符
            String sql = "INSERT INTO students (name, age, score) VALUES (?, ?, ?)";
            try (PreparedStatement ps = conn.prepareStatement(sql,
                    Statement.RETURN_GENERATED_KEYS)) {
                ps.setString(1, "赵六");
                ps.setInt(2, 22);
                ps.setBigDecimal(3, new java.math.BigDecimal("88.5"));
                // 4. 执行更新,返回受影响行数
                int rows = ps.executeUpdate();
                System.out.println("插入 " + rows + " 行");
 
                // 5. 获取自增主键
                try (ResultSet keys = ps.getGeneratedKeys()) {
                    if (keys.next()) System.out.println("新 ID = " + keys.getLong(1));
                }
            }
 
            // 6. 查询
            try (PreparedStatement ps = conn.prepareStatement(
                    "SELECT id, name, score FROM students WHERE score >= ? ORDER BY score DESC")) {
                ps.setBigDecimal(1, new java.math.BigDecimal("80"));
                try (ResultSet rs = ps.executeQuery()) {
                    while (rs.next()) {
                        System.out.printf("%d %s %.1f%n",
                                rs.getLong("id"), rs.getString("name"), rs.getDouble("score"));
                    }
                }
            }
        }   // try-with-resources 自动关闭连接/语句/结果集
    }
}

用连接池(HikariCP):

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
 
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl("jdbc:mysql://localhost:3306/school?characterEncoding=utf8");
cfg.setUsername("appuser");
cfg.setPassword("app123");
cfg.setMaximumPoolSize(10);        // 连接池大小
cfg.setConnectionTimeout(3000);    // 获取连接超时 3s
cfg.setMaxLifetime(600_000);       // 连接最长存活 10 分钟
 
HikariDataSource ds = new HikariDataSource(cfg);   // 全应用共用一个实例
try (Connection conn = ds.getConnection()) {
    // 与上面的 JDBC 用法完全一致
}

事务:

try (Connection conn = ds.getConnection()) {
    conn.setAutoCommit(false);          // 关闭自动提交,开启事务
    try {
        // ... 多条 SQL ...
        conn.commit();                  // 全部成功才提交
    } catch (Exception e) {
        conn.rollback();                // 任一步失败全部回滚
        throw e;
    }
}

进一步学习:仓库已有完整的 Java 数据库章节——JDBC 与数据库连接MyBatisSpring Data JPA

2.2 Python

方式一:PyMySQL(最直接的驱动)

import pymysql
 
# 建立连接(短脚本够用;Web 服务应使用连接池或 SQLAlchemy)
conn = pymysql.connect(
    host="localhost", port=3306,
    user="appuser", password="app123",
    database="school", charset="utf8mb4",
    cursorclass=pymysql.cursors.DictCursor,   # 查询结果返回字典
)
 
try:
    with conn.cursor() as cur:
        # 参数化查询:%s 是占位符,驱动负责安全转义
        cur.execute("INSERT INTO students (name, age, score) VALUES (%s, %s, %s)",
                    ("赵六", 22, 88.5))
        conn.commit()
        print("新 ID =", cur.lastrowid)
 
        cur.execute("SELECT id, name, score FROM students WHERE score >= %s", (80,))
        for row in cur.fetchall():
            print(row["id"], row["name"], row["score"])
finally:
    conn.close()

方式二:SQLAlchemy(工程化推荐)

from sqlalchemy import create_engine, text
 
# URL 格式:mysql+pymysql://用户:密码@主机:端口/库名?charset=utf8mb4
engine = create_engine(
    "mysql+pymysql://appuser:app123@localhost:3306/school?charset=utf8mb4",
    pool_size=10,          # 连接池大小
    max_overflow=5,        # 超出 pool_size 后最多再建 5 条
    pool_pre_ping=True,    # 取连接前先 ping,避免使用失效连接
    pool_recycle=3600,     # 一小时回收连接(小于 MySQL wait_timeout)
    echo=False,
)
 
with engine.begin() as conn:               # 自动提交 / 异常回滚
    conn.execute(
        text("INSERT INTO students (name, age, score) VALUES (:n, :a, :s)"),
        {"n": "赵六", "a": 22, "s": 88.5},
    )
 
with engine.connect() as conn:
    rows = conn.execute(text("SELECT id, name FROM students WHERE score >= :s"),
                        {"s": 80}).fetchall()
    for r in rows:
        print(r.id, r.name)

ORM 映射(表 ↔ 类):

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
 
class Base(DeclarativeBase): pass
 
class Student(Base):
    __tablename__ = "students"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))
    score: Mapped[float | None]
 
with Session(engine) as session:
    session.add(Student(name="钱七", score=91.0))
    session.commit()
    for s in session.query(Student).filter(Student.score >= 90).all():
        print(s.name, s.score)

进一步学习SQLite 与 ORM 集成(ORM 思路相通)、FastAPIDjango(自带 ORM)。

2.3 C(libmysqlclient)

C 语言直接使用 MySQL 官方的 C API,是理解「驱动层到底做了什么」的最佳方式。

/* app.c —— 编译:gcc app.c $(mysql_config --cflags --libs) -o app */
#include <mysql/mysql.h>
#include <stdio.h>
 
int main(void) {
    /* 1. 初始化句柄 */
    MYSQL *conn = mysql_init(NULL);
    if (conn == NULL) { fprintf(stderr, "mysql_init failed\n"); return 1; }
 
    /* 2. 建立连接 */
    if (mysql_real_connect(conn, "localhost", "appuser", "app123",
                           "school", 3306, NULL, 0) == NULL) {
        fprintf(stderr, "connect failed: %s\n", mysql_error(conn));
        mysql_close(conn);
        return 1;
    }
 
    /* 3. 设置字符集(C API 必须显式设置,否则中文可能乱码) */
    mysql_set_character_set(conn, "utf8mb4");
 
    /* 4. 执行查询 */
    if (mysql_query(conn, "SELECT id, name, score FROM students ORDER BY id")) {
        fprintf(stderr, "query failed: %s\n", mysql_error(conn));
        mysql_close(conn);
        return 1;
    }
 
    /* 5. 取结果集并遍历 */
    MYSQL_RES *res = mysql_store_result(conn);
    if (res == NULL) { fprintf(stderr, "store_result failed\n"); mysql_close(conn); return 1; }
 
    MYSQL_ROW row;
    while ((row = mysql_fetch_row(res)) != NULL) {
        /* row[0] 是 id,row[1] 是 name,row[2] 是 score;NULL 需判空 */
        printf("%s\t%s\t%s\n", row[0], row[1] ? row[1] : "NULL", row[2] ? row[2] : "NULL");
    }
 
    /* 6. 释放资源 */
    mysql_free_result(res);
    mysql_close(conn);
    return 0;
}

参数化查询(防注入,C API 需要 prepared statement):

MYSQL_STMT *stmt = mysql_stmt_init(conn);
const char *sql = "INSERT INTO students (name, age, score) VALUES (?, ?, ?)";
mysql_stmt_prepare(stmt, sql, strlen(sql));
 
MYSQL_BIND bind[3] = {0};
char name[50] = "赵六";
int  age = 22;
double score = 88.5;
 
bind[0].buffer_type = MYSQL_TYPE_STRING; bind[0].buffer = name; bind[0].buffer_length = strlen(name);
bind[1].buffer_type = MYSQL_TYPE_LONG;   bind[1].buffer = &age;
bind[2].buffer_type = MYSQL_TYPE_DOUBLE; bind[2].buffer = &score;
 
mysql_stmt_bind_param(stmt, bind);
mysql_stmt_execute(stmt);
mysql_stmt_close(stmt);

Arch 上 C API 头文件与 mysql_configmysql-clients 包提供;Debian/Ubuntu 由 libmysqlclient-dev(或 default-libmysqlclient-dev)提供。

2.4 Go(database/sql)

package main
 
import (
    "database/sql"
    "fmt"
    "time"
    _ "github.com/go-sql-driver/mysql"   // 匿名导入注册驱动
)
 
func main() {
    // DSN:user:pass@tcp(host:port)/dbname?参数
    dsn := "appuser:app123@tcp(127.0.0.1:3306)/school?charset=utf8mb4&parseTime=true&loc=Local"
    db, err := sql.Open("mysql", dsn)
    if err != nil { panic(err) }
    defer db.Close()
 
    // 连接池参数(database/sql 内置连接池)
    db.SetMaxOpenConns(20)                  // 最大打开连接数
    db.SetMaxIdleConns(10)                  // 最大空闲连接数
    db.SetConnMaxLifetime(time.Hour)        // 连接最长存活
    db.SetConnMaxIdleTime(10 * time.Minute) // 空闲多久回收
 
    // 参数化查询
    res, err := db.Exec("INSERT INTO students (name, age, score) VALUES (?, ?, ?)",
        "赵六", 22, 88.5)
    if err != nil { panic(err) }
    id, _ := res.LastInsertId()
    fmt.Println("新 ID =", id)
 
    rows, err := db.Query("SELECT id, name, score FROM students WHERE score >= ?", 80)
    if err != nil { panic(err) }
    defer rows.Close()
    for rows.Next() {
        var id, age int
        var name string
        var score sql.NullFloat64          // 可空列用 NullXxx 类型
        if err := rows.Scan(&id, &name, &score); err != nil { panic(err) }
        fmt.Println(id, name, score.Float64)
    }
    if err := rows.Err(); err != nil { panic(err) }
}

事务:

tx, err := db.Begin()
if err != nil { return err }
defer tx.Rollback()          // 提交后 Rollback 是无害的
_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", 100, 1)
if err != nil { return err }
_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", 100, 2)
if err != nil { return err }
return tx.Commit()

2.5 Node.js(mysql2)

// npm install mysql2
const mysql = require('mysql2/promise');
 
const pool = mysql.createPool({
  host: 'localhost',
  port: 3306,
  user: 'appuser',
  password: 'app123',
  database: 'school',
  charset: 'utf8mb4',
  waitForConnections: true,
  connectionLimit: 10,       // 连接池大小
  queueLimit: 0,
  timezone: '+08:00',
});
 
async function main() {
  const [result] = await pool.execute(
    'INSERT INTO students (name, age, score) VALUES (?, ?, ?)',
    ['赵六', 22, 88.5]
  );
  console.log('新 ID =', result.insertId);
 
  const [rows] = await pool.execute(
    'SELECT id, name, score FROM students WHERE score >= ?', [80]
  );
  for (const r of rows) console.log(r.id, r.name, r.score);
 
  await pool.end();
}
main().catch(console.error);

pool.execute(预处理语句)而不是 pool.query(字符串拼接),后者在拼接用户输入时存在注入风险。

2.6 Rust(sqlx)

// Cargo.toml: sqlx = { version = "0.8", features = ["runtime-tokio", "mysql"] }
use sqlx::mysql::MySqlPoolOptions;
 
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    let pool = MySqlPoolOptions::new()
        .max_connections(10)
        .connect("mysql://appuser:app123@localhost/school")
        .await?;
 
    // query! 宏在编译期校验 SQL(需要设置 DATABASE_URL 环境变量)
    let id = sqlx::query!(
        "INSERT INTO students (name, age, score) VALUES (?, ?, ?)",
        "赵六", 22, 88.5
    )
    .execute(&pool)
    .await?
    .last_insert_id();
    println!("新 ID = {id}");
 
    let rows = sqlx::query!("SELECT id, name, score FROM students WHERE score >= ?", 80)
        .fetch_all(&pool)
        .await?;
    for r in rows {
        println!("{} {} {:?}", r.id, r.name, r.score);
    }
    Ok(())
}

进一步学习Rust sqlx 数据库操作

2.7 Dart(服务端)

// pubspec.yaml: mysql_client: ^0.0.27
import 'package:mysql_client/mysql_client.dart';
 
Future<void> main() async {
  final conn = await MySQLConnection.createConnection(
    host: 'localhost',
    port: 3306,
    userName: 'appuser',
    password: 'app123',
    databaseName: 'school',
  );
  await conn.connect();
 
  final res = await conn.execute(
    'SELECT id, name, score FROM students WHERE score >= :s',
    {'s': 80},
  );
  for (final row in res.rows) {
    print('${row.colAt(0)} ${row.colAt(1)} ${row.colAt(2)}');
  }
  await conn.close();
}

进一步学习Dart 服务端开发


三、跨语言通用问题

3.1 SQL 注入与参数化查询

永远不要用字符串拼接 SQL。看这个反例:

# 危险!用户输入 name = "x'; DROP TABLE students; --"
sql = f"SELECT * FROM students WHERE name = '{name}'"

如果用户输入 ' OR '1'='1,查询会变成永远为真;如果输入带分号的语句,可能直接删库。正确做法是参数化查询:SQL 结构由驱动预编译,参数单独传输,永远不被当作 SQL 解析。

语言参数占位符危险写法安全写法
Java?"WHERE name='" + name + "'"ps.setString(1, name)
Python%s / :namef-string 拼接cur.execute(sql, (name,))
C?sprintf 拼接mysql_stmt_bind_param
Go?fmt.Sprintf 拼接db.Query(sql, name)
Node?模板字符串拼接pool.execute(sql, [name])
Rust? / $1format! 拼接sqlx::query!(sql, name)

注意:表名、列名、ORDER BY 字段不能用占位符(它们不是数据而是 SQL 结构)。这类动态标识符必须用白名单校验,绝不能直接拼接用户输入。

3.2 应用层事务

数据库事务的 ACID 与隔离级别见 04 索引事务与优化。后端代码里的事务要点:

sequenceDiagram
    participant APP as 应用
    participant DB as MySQL
    APP->>DB: BEGIN
    APP->>DB: UPDATE 账户A -100
    APP->>DB: UPDATE 账户B +100
    alt 全部成功
        APP->>DB: COMMIT
    else 任一步失败
        APP->>DB: ROLLBACK
    end
要点说明
事务要短长事务持有锁、撑大 undo log、阻塞其他请求
先锁后查高并发扣减库存用 SELECT ... FOR UPDATE 或原子 UPDATE ... SET x = x - 1 WHERE x >= 1
重试死锁InnoDB 检测到死锁会回滚其中一个事务,应用应捕获错误码 1213 并重试
隔离级别多数业务用默认 REPEATABLE READ;对一致性要求高的场景评估 READ COMMITTED
跨库事务尽量在单库内解决;跨服务一致性用消息队列 + Outbox,而非 XA 两阶段提交

3.3 N+1 查询

ORM 最经典的性能陷阱:

# 反例:1 次查订单 + N 次查用户 = N+1 次查询
orders = session.query(Order).all()
for o in orders:
    print(o.user.name)          # 每次循环触发一次 SQL
 
# 正例:用 join / eager loading 一次查完
orders = session.query(Order).options(joinedload(Order.user)).all()

排查方法:打开 MySQL 通用日志或慢查询日志,观察同一请求是否产生大量结构相似的 SQL。详见 04 索引事务与优化 的慢查询章节。

3.4 ORM 还是原生 SQL?

维度ORM原生 SQL
开发速度简单 CRUD 快需要手写,慢一些
复杂查询表达困难、易生成低效 SQL完全可控
类型安全好(JPA/sqlx/SQLAlchemy 2.0)依赖手工映射
性能调优需要理解生成的 SQL直接
建议常规 CRUD 用 ORM报表、批处理、复杂分析用原生 SQL

工程实践中常见组合:ORM 管 80% 的 CRUD,原生 SQL 管 20% 的复杂查询。不要教条化。

3.5 数据库迁移工具

后端项目的表结构必须纳入版本管理,不能靠手工改生产库。主流工具:

语言工具特点
JavaFlyway / LiquibaseSQL 文件版本化 / XML-YAML 描述
PythonAlembicSQLAlchemy 官方迁移工具
Gogolang-migrate简单的 up/down SQL 文件
Rustsqlx migrate与 sqlx 集成
NodePrisma Migrate / KnexSchema 驱动 / 查询构建器
通用mysqldump + 版本库小项目最低成本方案

原则:迁移脚本一旦提交就不可修改,只追加新版本;上线前在预发环境演练;大表变更参考 09 高级主题 的在线 DDL 部分。


四、完整实战:用户注册与登录

用 Java 与 Python 各写一遍核心逻辑,覆盖「参数化查询 + 唯一约束 + 密码哈希 + 事务」。

4.1 建表

CREATE TABLE users (
    id            BIGINT PRIMARY KEY AUTO_INCREMENT,
    username      VARCHAR(50)  NOT NULL UNIQUE,
    password_hash VARCHAR(100) NOT NULL,     -- 永远不要存明文密码
    created_at    DATETIME DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

4.2 Java 版本

public class UserService {
    private final HikariDataSource ds;
 
    public UserService(HikariDataSource ds) { this.ds = ds; }
 
    /** 注册:返回新用户 ID */
    public long register(String username, String rawPassword) throws Exception {
        String hash = BCrypt.hashpw(rawPassword, BCrypt.gensalt());
        String sql = "INSERT INTO users (username, password_hash) VALUES (?, ?)";
        try (Connection conn = ds.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, username);
            ps.setString(2, hash);
            try {
                ps.executeUpdate();
            } catch (SQLIntegrityConstraintViolationException e) {
                throw new IllegalArgumentException("用户名已存在");   // 唯一索引兜底
            }
            try (ResultSet keys = ps.getGeneratedKeys()) {
                keys.next();
                return keys.getLong(1);
            }
        }
    }
 
    /** 登录:校验密码,成功返回用户 ID */
    public long login(String username, String rawPassword) throws Exception {
        String sql = "SELECT id, password_hash FROM users WHERE username = ?";
        try (Connection conn = ds.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, username);
            try (ResultSet rs = ps.executeQuery()) {
                if (!rs.next()) throw new IllegalArgumentException("用户名或密码错误");
                if (!BCrypt.checkpw(rawPassword, rs.getString("password_hash")))
                    throw new IllegalArgumentException("用户名或密码错误");
                return rs.getLong("id");
            }
        }
    }
}

登录失败时不要提示「用户不存在」还是「密码错误」,统一返回模糊提示,避免账号枚举攻击。

4.3 Python 版本(SQLAlchemy)

import bcrypt
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
 
class UserService:
    def __init__(self, engine):
        self.engine = engine
 
    def register(self, username: str, raw_password: str) -> int:
        password_hash = bcrypt.hashpw(raw_password.encode(), bcrypt.gensalt()).decode()
        try:
            with self.engine.begin() as conn:     # 自动提交/回滚
                result = conn.execute(
                    text("INSERT INTO users (username, password_hash) VALUES (:u, :p)"),
                    {"u": username, "p": password_hash},
                )
                return result.lastrowid
        except IntegrityError:
            raise ValueError("用户名已存在")
 
    def login(self, username: str, raw_password: str) -> int:
        with self.engine.connect() as conn:
            row = conn.execute(
                text("SELECT id, password_hash FROM users WHERE username = :u"),
                {"u": username},
            ).fetchone()
        if row is None or not bcrypt.checkpw(raw_password.encode(), row.password_hash.encode()):
            raise ValueError("用户名或密码错误")
        return row.id

4.4 配套要点

要点做法
密码存储bcrypt / argon2 哈希 + 每个用户独立 salt,绝不存明文或 MD5
唯一约束username 加 UNIQUE 索引,靠数据库兜底并发注册
参数化所有用户输入走占位符
事务边界一次业务操作一个事务,不要跨 HTTP 请求持有事务
连接池应用启动时创建全局池,不要每次请求新建
配置管理连接串、密码通过环境变量注入,不硬编码进代码仓库

五、与仓库其他教程的衔接

主题教程
Java 数据库完整链路JDBCMyBatisJPA
Java Web 项目实战Spring Boot全栈开发
Python Web 与 ORMFastAPIDjangoORM 集成
C 语言调用C 语言教程(编译链接与动态库知识是 C API 的前置)
Rust 异步数据库sqlx
Dart 服务端服务端 Dart
多语言后端协作跨语言接口总览统一容器化
安全视角MySQL 渗透(理解攻击才能写出安全代码)

六、动手实践

序号任务验收标准
1用任意语言连接本机 MySQL,完成一次参数化查询输入包含单引号的字符串不会报 SQL 语法错误,也不会注入成功
2实现转账事务:A 扣 100、B 加 100,中途抛异常异常后两个账户余额均不变(回滚生效)
3用 HikariCP 或 SQLAlchemy 配置连接池并打印连接数并发 20 个请求时数据库连接数不超过配置上限
4完成第四节用户注册/登录的最小实现重复用户名返回友好错误;正确密码登录成功、错误密码失败
5制造一次 N+1 查询并用日志确认慢查询日志中出现大量相似 SQL,改用 join 后数量下降

返回 数据库目录;下一章 09 高级主题与性能诊断