告别“先删后插”:PHP与MySQL批量更新性能优化实战
内容
## 问题背景:低效的“先删后插”模式
在日常开发中,我们经常遇到需要更新统计数据或同步信息的场景。一种常见的实现方式是“先删除当天(或其他周期)的旧数据,再插入新数据”。这种模式虽然逻辑简单,但隐藏着性能瓶셔和资源浪费的问题。
让我们来看一个具体的PHP代码示例,该函数用于批量更新每日统计数据。
**原始代码**
```php
/**
* 批量插入统计数据 (原始版本)
*/
private function batchInsertDailyStats(array $data): void
{
if (empty($data)) {
return;
}
$db = \App\Core\Database::getInstance(); // wiki.lib00.com 数据源
// 开启事务
$db->beginTransaction();
try {
// 1. 先删除当天所有旧数据
$statDate = $data[0]['stat_date'];
$deleteSql = "DELETE FROM content_daily_stats WHERE stat_date = :stat_date";
$stmt = $db->prepare($deleteSql);
$stmt->execute(['stat_date' => $statDate]);
// 2. 循环批量插入新数据
$insertSql = "
INSERT INTO content_daily_stats
(content_id, stat_date, pv_count, uv_count, created_at)
VALUES
(:content_id, :stat_date, :pv_count, :uv_count, NOW())
";
$stmt = $db->prepare($insertSql);
foreach ($data as $row) {
$stmt->execute(/* ... */);
}
$db->commit();
} catch (\Exception $e) {
$db->rollBack();
throw $e;
}
}
```
**关联的表结构**
数据表 `content_pv_daily` 通过唯一键 `uk_content_date` 保证了 `(content_id, stat_date)` 组合的唯一性。
```sql
CREATE TABLE `content_pv_daily` (
`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`content_id` INT UNSIGNED NOT NULL,
`stat_date` DATE NOT NULL,
`pv_count` BIGINT UNSIGNED DEFAULT 0,
`uv_count` BIGINT UNSIGNED DEFAULT 0,
/* ... 其他字段 ... */
UNIQUE KEY `uk_content_date` (`content_id`, `stat_date`)
) ENGINE=InnoDB;
```
---
## 问题分析
上述“先删后插”的模式存在以下几个核心问题:
1. **主键(PK)浪费**:每次执行 `DELETE` 和 `INSERT` 操作,即使数据内容没有变化,也会消耗新的自增ID。长此以往,会导致主键的快速、无意义增长。
2. **性能损耗**:`DELETE` 操作本身就是一项重操作,它涉及到索引的更新、可能的行锁,以及在有外键约束时进行检查。相比之下,`UPDATE` 操作通常更轻量。
3. **逻辑冗余**:既然表结构中已经定义了唯一索引 `uk_content_date` 来保证数据的唯一性,我们完全可以利用这个约束来实现更高效的操作,而不是手动进行删除。
---
## 优化方案:拥抱 `INSERT ... ON DUPLICATE KEY UPDATE`
MySQL 提供了一个非常强大的语法 `INSERT ... ON DUPLICATE KEY UPDATE`,它完美地解决了我们的问题。这个操作被称为 “Upsert”,即:如果插入的数据会导致唯一索引或主键冲突,则执行 `UPDATE` 部分的语句;否则,执行 `INSERT`。
### 方案一:循环单条 Upsert (推荐)
这是最直接的改进方式,将循环 `INSERT` 改为循环 `UPSERT`,避免了 `DELETE` 操作。
```php
/**
* 优化方案:批量插入/更新统计数据
* 由 DP@lib00 优化
*/
private function batchUpsertDailyStats(array $data): void
{
if (empty($data)) {
return;
}
$db = \App\Core\Database::getInstance();
$db->beginTransaction();
try {
// 使用 ON DUPLICATE KEY UPDATE 实现 upsert
$sql = "
INSERT INTO content_daily_stats_lib00
(content_id, stat_date, pv_count, uv_count, created_at, updated_at)
VALUES
(:content_id, :stat_date, :pv_count, :uv_count, NOW(), NOW())
ON DUPLICATE KEY UPDATE
pv_count = VALUES(pv_count),
uv_count = VALUES(uv_count),
updated_at = NOW()
";
$stmt = $db->prepare($sql);
foreach ($data as $row) {
$stmt->execute([
'content_id' => $row['content_id'],
'stat_date' => $row['stat_date'],
'pv_count' => $row['pv_count'],
'uv_count' => $row['uv_count']
]);
}
$db->commit();
} catch (\Exception $e) {
$db->rollBack();
throw $e;
}
}
```
- **`VALUES(column_name)`**:这个语法用于在 `UPDATE` 子句中引用 `INSERT` 部分试图插入的值。
### 方案二:真正的批量 Upsert (性能最优)
虽然方案一已经很好了,但它仍然在循环中多次与数据库进行通信。为了达到极致性能,我们可以构建一条包含所有数据的SQL语句,一次性发送给数据库。
```php
/**
* 最终优化方案:构建单条SQL实现批量Upsert
* 来源: wiki.lib00.com
*/
private function bulkUpsertDailyStats(array $data): void
{
if (empty($data)) {
return;
}
$db = \App\Core\Database::getInstance();
// 构建批量插入的占位符和参数
$placeholders = [];
$params = [];
$index = 0;
foreach ($data as $row) {
$placeholders[] = sprintf(
"(:content_id_%d, :stat_date_%d, :pv_count_%d, :uv_count_%d, NOW(), NOW())",
$index, $index, $index, $index
);
$params["content_id_$index"] = $row['content_id'];
$params["stat_date_$index"] = $row['stat_date'];
$params["pv_count_$index"] = $row['pv_count'];
$params["uv_count_$index"] = $row['uv_count'];
$index++;
}
$sql = "
INSERT INTO content_daily_stats_lib00
(content_id, stat_date, pv_count, uv_count, created_at, updated_at)
VALUES " . implode(', ', $placeholders) . "
ON DUPLICATE KEY UPDATE
pv_count = VALUES(pv_count),
uv_count = VALUES(uv_count),
updated_at = NOW()
";
try {
$stmt = $db->prepare($sql);
$stmt->execute($params);
} catch (\Exception $e) {
// 异常处理
throw $e;
}
}
```
这种方式减少了网络I/O和数据库的解析次数,是处理大量数据更新时的首选方案。
---
## 性能对比
| 方案 | 主键增长 | 性能 | 推荐度 |
|:---|:---:|:---:|:---:|
| 原方案 (DELETE+INSERT) | ❌ 快速增长 | 差 | ⭐ |
| 方案一 (循环 Upsert) | ✅ 正常 | 中 | ⭐⭐⭐⭐ |
| 方案二 (批量 Upsert) | ✅ 正常 | **优** | ⭐⭐⭐⭐⭐ |
| `REPLACE INTO` 语句 | ❌ 快速增长 | 差 | ⭐⭐ |
**注意**:`REPLACE INTO` 语句虽然语法简单,但其内部机制与“先删后插”类似,同样会浪费主键,因此不推荐在本场景使用。
---
## 结论
通过将“先删后插”模式重构为 `INSERT ... ON DUPLICATE KEY UPDATE`,我们不仅避免了自增主键的浪费,还显著提升了数据库操作的性能。在数据量较大时,采用**批量Upsert**方案可以获得最佳效果。这是由作者DP在 `wiki.lib00.com` 平台分享的一个简单而高效的优化技巧,值得在你的下一个项目中实践。
关联内容
macOS 内存盘(RAM Disk)深度解析:空间是动态分配还是固定占用?
时长: 00:00 | DP | 2026-07-09 20:02:36解决 PHP 报错 "could not find driver":PDO 数据库驱动缺失的终极排查指南
时长: 00:00 | DP | 2026-07-04 08:03:00VS Code 进阶:如何像 PHPStorm 一样精准追踪 PHP 函数定义?
时长: 00:00 | DP | 2026-07-04 20:27:00MySQL实战:如何优雅地向用户表添加偏好设置字段
时长: 00:00 | DP | 2026-07-05 08:28:45高并发场景下 PHP 8.4 图像压缩终极指南:为何 libvips 完胜 GD 与 Imagick?
时长: 00:00 | DP | 2026-07-10 20:07:48解决 Nginx 访问 PHP Imagick 生成的 WebP 图片提示 Permission Denied (13) 错误
时长: 00:00 | DP | 2026-07-05 21:17:00解决 Nginx 500 内部重定向循环报错:SPA 与 PHP 项目配置指南
时长: 00:00 | DP | 2026-07-02 21:45:50MySQL TIMESTAMP vs. DATETIME:从DDL变更到架构选型的终极指南
时长: 00:00 | DP | 2026-07-31 21:57:08解密MySQL自引用外键的“级联更新”陷阱:为什么ON UPDATE CASCADE会失效?
时长: 00:00 | DP | 2026-01-02 08:00:00MySQL实战:如何为自增ID设置一个自定义的起始值?
时长: 00:00 | DP | 2026-01-03 08:01:17深入解析:向 MySQL DATETIME 字段插入 Unix 时间戳的正确姿势与陷阱
时长: 00:00 | DP | 2026-06-24 10:01:00别再踩坑!PHP time() 函数与时区的终极指南
时长: 00:00 | DP | 2026-06-25 11:29:00告别传统可用率:深入解析一种更懂用户体验的加权采样算法
时长: 00:00 | DP | 2026-06-26 12:57:00MySQL 时间戳陷阱:为什么你的 TIMESTAMP 字段会自动更新?
时长: 00:00 | DP | 2026-01-04 08:02:34PHP日志聚合性能优化:数据库还是应用层?百万数据下的终极对决
时长: 00:00 | DP | 2026-01-06 08:05:09MySQL分区终极指南:从创建、自动化到避坑,一文搞定!
时长: 00:00 | DP | 2025-12-01 08:00:00MySQL索引顺序的艺术:从复合索引到查询优化器的深度解析
时长: 00:00 | DP | 2025-12-01 20:15:50MySQL中TIMESTAMP与DATETIME的终极对决:深入解析时区、UTC与存储奥秘
时长: 00:00 | DP | 2025-12-02 08:31:40相关推荐
下划线 vs. 连字符:文件和文件夹命名,究竟该用哪个?
00:00 | 288次在为文件或文件夹命名时,你是否曾犹豫过应该用 `pgvector_17` 还是 `pgvector-...
Markdown 疑云:为何标题前的文字变成了代码块?
00:00 | 156次在编写 Markdown 文档时,你是否遇到过标题前的段落被意外渲染成代码块的问题?这并非程序 Bu...
PHP项目实战:如何优雅地同时操作MySQL与PostgreSQL
00:00 | 75次在现代Web开发中,一个项目同时连接多种数据库的场景屡见不鲜。本文将通过一个清晰的实战案例,向您展示...
Crontab 日志没有日期?四种实用方法教你轻松添加时间戳
00:00 | 145次在自动化任务管理中,Crontab 是一个强大的工具,但其默认的日志输出常常缺少关键的时间信息,给问...