MySQL 08 - 与后端语言集成
数据库的立身之本是为后端服务提供数据持久化。学会建库建表和写 SQL 只是第一步,真正的工作是把 MySQL 接进 Java、Python、C、Go、Node.js 这些后端程序里:建立连接、执行查询、管理事务、处理并发。本章就是这门「衔接课」。
一、连接的三层模型
任何语言连接 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 与数据库连接、MyBatis、Spring 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 思路相通)、FastAPI、Django(自带 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_config由mysql-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 / :name | f-string 拼接 | cur.execute(sql, (name,)) |
| C | ? | sprintf 拼接 | mysql_stmt_bind_param |
| Go | ? | fmt.Sprintf 拼接 | db.Query(sql, name) |
| Node | ? | 模板字符串拼接 | pool.execute(sql, [name]) |
| Rust | ? / $1 | format! 拼接 | 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 数据库迁移工具
后端项目的表结构必须纳入版本管理,不能靠手工改生产库。主流工具:
| 语言 | 工具 | 特点 |
|---|---|---|
| Java | Flyway / Liquibase | SQL 文件版本化 / XML-YAML 描述 |
| Python | Alembic | SQLAlchemy 官方迁移工具 |
| Go | golang-migrate | 简单的 up/down SQL 文件 |
| Rust | sqlx migrate | 与 sqlx 集成 |
| Node | Prisma Migrate / Knex | Schema 驱动 / 查询构建器 |
| 通用 | 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.id4.4 配套要点
| 要点 | 做法 |
|---|---|
| 密码存储 | bcrypt / argon2 哈希 + 每个用户独立 salt,绝不存明文或 MD5 |
| 唯一约束 | username 加 UNIQUE 索引,靠数据库兜底并发注册 |
| 参数化 | 所有用户输入走占位符 |
| 事务边界 | 一次业务操作一个事务,不要跨 HTTP 请求持有事务 |
| 连接池 | 应用启动时创建全局池,不要每次请求新建 |
| 配置管理 | 连接串、密码通过环境变量注入,不硬编码进代码仓库 |
五、与仓库其他教程的衔接
| 主题 | 教程 |
|---|---|
| Java 数据库完整链路 | JDBC → MyBatis → JPA |
| Java Web 项目实战 | Spring Boot、全栈开发 |
| Python Web 与 ORM | FastAPI、Django、ORM 集成 |
| 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 高级主题与性能诊断。