文章目录
如果你认为这篇文章能够帮到你,请一键三连(点赞,收藏,评论),点点关注!
有好的想法或问题,也可以私聊我进行沟通!
1.问题梳理
现象
在这平平无奇的一天,突然被轰炸,被喊过来帮忙处理一个线上问题,怎么回事呢?
客户反馈了一件事,本应该上传一个视频的功能,发现多了几条操作记录,而且,视频上传结束之后,发现视频上传列表显示正在上传中。
此时,听到这个消息的我,就是一整个地铁老人看手机!!!
经过我一番数据库查询,终于发现了复现流程。
在一个简单的上传视频开始接口中,前端会在开始上传视频前,调用一下后端接口,而后端的这个接口,需要执行下面的流程。
在上传完成后。
OK,问题出现了,最后我看到有2条今天的数据上传记录,第一条是上传中,第二条是上传完成。
而视频上传列表中,展示的上传中,也就是第一条数据的信息。
紧急解决
那么问题看似明朗了,数据被插入了多条,解决方案暂时比较简单。
前端增加了防抖,以及在调用接口之前,将按钮禁用,直到接口返回成功后,在允许后续操作。
深入研究
此时,看似问题解决了。
其实,也只是暂时性的,因为,后端没有做控制。
那,为什么出现这个问题呢?
经过我又一番检查代码,发现了让我震惊的一幕。
由于后端的开发者,是经常操作mongo数据库的,而这个项目是mysql数据库,所以,开发者习惯性用了async/await来异步执行了整个流程,流程异常时,也仅仅只有try/catch来进行throw error。那么当前端请求2次的时候,后端也就走了两次插入,然后,在最后,只调用了一次完成视频上传,那么就只有第2条数据显示完成了。
居然完全没有考虑mysql的事务问题
斩草除根
所以,问题总结如下
- 前端问题
- 没有防抖
- 没有等接口调用结束,就进行后续流程操作
- 后端问题
- 没有考虑接口并发
- 没有考虑mysql数据库事务
- 没有考虑数据库唯一约束
那么,问题清晰,如何处理,让我来娓娓道来。
本文不再赘述前端的处理逻辑,仅对后端技术做处理
2.后端处理
先来重复一下后端的问题
- 没有考虑接口并发
- 没有考虑mysql数据库事务
- 没有考虑数据库唯一约束
那么,来看看如何处理吧!
基础配置🔧 /config/db.js
// 数据库配置
// 在实际项目中,这些信息通常放在 .env 文件中
module.exports = {
host: 'localhost',
user: 'root',
database: 'test_db',
password: 'password',
waitForConnections: true,
connectionLimit: 10, // 连接池最大连接数
queueLimit: 0
};
公共文件🚌 /src/utils/db.js
const mysql = require('mysql2/promise');
const dbConfig = require('../config/db');
/**
* ✅ 最佳实践:使用连接池 (Connection Pool)
*
* 优势:
* 1. 连接被创建后会放入池中,后续请求直接复用,无需重新握手。
* 2. 自动管理连接的生命周期(创建、销毁、空闲检测)。
* 3. 可以限制最大连接数 (connectionLimit),防止把数据库打挂。
*/
// 1. 在模块加载时创建连接池(只执行一次)
// 这个 pool 对象是单例的,因为 Node.js 的模块缓存机制
const pool = mysql.createPool(dbConfig);
console.log('✅ 数据库连接池已创建 (全局只创建一次)');
async function executeQueryGood(sql, params) {
// 2. 从池中获取连接(或者直接使用 pool.execute,它内部会自动获取和释放连接)
// pool.execute 是 mysql2 的便捷方法,它相当于:
// const conn = await pool.getConnection();
// try { return await conn.execute(...); } finally { conn.release(); }
console.log('正在从连接池复用连接执行查询...');
try {
const [results] = await pool.execute(sql, params);
return results;
} catch (err) {
console.error('查询出错:', err);
throw err;
}
// ⚠️ 注意:使用 pool.execute 不需要手动 release,库会自动处理
}
// 导出查询函数和 pool 对象(以便应用关闭时销毁池)
module.exports = {
executeQueryGood,
pool
};
分布式锁🔒 /src/utils/lock.js
const Redis = require('ioredis');
/**
* 🔒 分布式锁管理器 (Redis 实现)
*
* 适用于多进程、多服务器环境。
* 使用 Redis 的 SET NX PX 命令实现互斥。
*/
class LockManager {
constructor() {
// 默认连接本地 Redis (localhost:6379)
// 生产环境应从环境变量读取配置
this.redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
// 遇到连接错误时不要崩溃,而是重试或记录日志
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
}
});
this.redis.on('error', (err) => {
console.error('[Redis Error]', err.message);
});
this.redis.on('connect', () => {
console.log('[Redis] 连接成功,分布式锁就绪。');
});
}
/**
* 尝试获取锁
* @param {string|number} key - 锁的唯一标识 (如 userId)
* @param {number} ttlMs - 锁的过期时间 (毫秒),防止死锁,默认 10秒
* @returns {Promise<boolean>} - true 表示获取成功,false 表示锁已被占用
*/
async tryLock(key, ttlMs = 10000) {
const lockKey = `lock:user:${key}`;
const token = 'locked'; // 可以使用随机字符串来增强安全性(防止误删),但演示用固定值即可
try {
// SET key value NX PX ttl
// NX: Only set if not exists
// PX: Set expiry in milliseconds
const result = await this.redis.set(lockKey, token, 'NX', 'PX', ttlMs);
return result === 'OK';
} catch (err) {
console.error('[Lock Error]', err.message);
// 如果 Redis 挂了,为了安全起见,可以选择返回 false (拒绝服务) 或 true (降级)
// 这里选择返回 false,优先保证数据一致性
return false;
}
}
/**
* 释放锁
* @param {string|number} key
*/
async unlock(key) {
const lockKey = `lock:user:${key}`;
try {
await this.redis.del(lockKey);
} catch (err) {
console.error('[Unlock Error]', err.message);
}
}
/**
* 关闭 Redis 连接 (用于应用退出时)
*/
async quit() {
await this.redis.quit();
}
}
// 导出单例
module.exports = new LockManager();
核心逻辑🎁 /src/services/videoService.js
const { pool } = require('../utils/db');
const lockManager = require('../utils/lock');
/**
* 📹 视频上传业务流程
*
* 解决的核心问题:
* 1. 【并发控制】如何防止用户手抖导致重复提交? -> 使用 Redis 分布式锁拦截。
* 2. 【数据一致性】如何保证"统计计数"和"插入记录"要么全成功,要么全失败? -> 使用数据库事务。
* 3. 【幂等性】如何处理"每日上传统计"的插入或更新? -> 使用 ON DUPLICATE KEY UPDATE。
* 4. 【防重】如何防止重复的视频 URL 入库? -> 依赖数据库唯一索引回滚事务。
*/
async function processVideoUpload(userId, videoData) {
// --- 🔒 [解决问题:并发请求] ---
// 使用 Redis 分布式锁,在进入数据库操作前先在内存/缓存层拦截。
// 如果用户 1秒内点了 5 次,只有第 1 次能拿到锁,其余 4 次直接报错返回。
const acquired = await lockManager.tryLock(userId);
if (!acquired) {
throw new Error(`[并发拒绝] 用户 ${userId} 正在进行其他上传操作,请稍后。`);
}
let connection;
try {
// --- 步骤 1: 验证数据正确性 ---
console.log(`[Step 1] 正在验证数据: ${JSON.stringify(videoData)}`);
if (!videoData.title || !videoData.url) {
throw new Error('参数错误: 视频标题或URL不能为空');
}
// 获取数据库连接并开启事务
// ⚠️ 注意:事务必须在同一个 connection 上执行
connection = await pool.getConnection();
await connection.beginTransaction();
console.log(`[事务开始] 用户 ${userId} 上传视频...`);
// --- 步骤 2: 查询用户信息 ---
console.log('[Step 2] 查询用户信息...');
const [users] = await connection.execute(
'SELECT id, username, status FROM users WHERE id = ? FOR UPDATE',
[userId]
);
if (users.length === 0) {
throw new Error('用户不存在');
}
if (users[0].status !== 'active') {
throw new Error('用户状态异常,禁止上传');
}
// --- 步骤 3: 插入用户今天的视频上传信息 ---
// 💡 [解决问题:原子性更新统计]
// 使用 ON DUPLICATE KEY UPDATE 语法。
// 如果是今天第一条 -> 执行 INSERT。
// 如果今天已有记录 -> 执行 UPDATE upload_count + 1。
// 这避免了 "Select -> Check -> Insert/Update" 的竞态条件。
console.log('[Step 3] 插入/更新每日上传统计...');
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
await connection.execute(
`INSERT INTO daily_upload_stats (user_id, date, upload_count, last_upload_time)
VALUES (?, ?, 1, NOW())
ON DUPLICATE KEY UPDATE
upload_count = upload_count + 1,
last_upload_time = NOW()`,
[userId, today]
);
// --- 步骤 4: 插入视频上传记录信息 ---
// 💡 [解决问题:数据唯一性与回滚]
// 数据库表 video_records 对 video_url 建有唯一索引。
// 如果插入重复 URL,数据库会抛出 Duplicate entry 错误。
// 这个错误会被 catch 块捕获,进而触发 rollback(),
// 从而自动撤销【步骤 3】中已经增加的 upload_count,保证数据一致性。
console.log('[Step 4] 插入视频详细记录...');
await connection.execute(
'INSERT INTO video_records (user_id, video_title, video_url, file_size, created_at) VALUES (?, ?, ?, ?, NOW())',
[userId, videoData.title, videoData.url, videoData.size || 0]
);
// --- 步骤 5: 插入操作记录 ---
console.log('[Step 5] 插入审计日志...');
await connection.execute(
'INSERT INTO audit_logs (user_id, action, details, timestamp) VALUES (?, ?, ?, NOW())',
[userId, 'VIDEO_UPLOAD', `Uploaded: ${videoData.title}`]
);
// 提交事务:只有执行到这里,上述所有操作才会真正生效
await connection.commit();
console.log('[✅ 事务提交] 视频上传流程完成。\n');
return { success: true };
} catch (error) {
// 💡 [解决问题:异常回滚]
// 无论是在哪一步出错(参数错、数据库错、逻辑错),
// 只要进入这里,之前的修改全部作废。
if (connection) {
await connection.rollback();
console.error(`[❌ 事务回滚] ${error.message}\n`);
}
throw error;
} finally {
if (connection) connection.release();
// --- 🔓 释放锁 (异步) ---
// 无论成功还是失败,最后必须释放锁,防止死锁
await lockManager.unlock(userId);
}
}
module.exports = { processVideoUpload };
mysql schema 🌰 /src/schema.sql
-- ---------------------------------------------------------
-- 1. 用户表 (users)
-- ---------------------------------------------------------
CREATE TABLE IF NOT EXISTS `users` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL,
`status` ENUM('active', 'banned') DEFAULT 'active',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- ---------------------------------------------------------
-- 2. 用户每日上传统计表 (daily_upload_stats)
-- 对应流程:插入用户今天的视频上传信息
-- ---------------------------------------------------------
CREATE TABLE IF NOT EXISTS `daily_upload_stats` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL,
`date` DATE NOT NULL,
`upload_count` INT DEFAULT 1,
`last_upload_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 保证每天每个用户只有一条统计记录
UNIQUE KEY `uk_user_date` (`user_id`, `date`)
);
-- ---------------------------------------------------------
-- 3. 视频上传记录表 (video_records)
-- 对应流程:插入视频上传记录信息
-- ---------------------------------------------------------
CREATE TABLE IF NOT EXISTS `video_records` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL,
`video_title` VARCHAR(255) NOT NULL,
`video_url` VARCHAR(255) NOT NULL,
`file_size` BIGINT,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 假设同一个视频文件(URL)不能重复上传
UNIQUE KEY `uk_video_url` (`video_url`)
);
-- ---------------------------------------------------------
-- 4. 操作日志表 (audit_logs)
-- 对应流程:插入操作记录
-- ---------------------------------------------------------
CREATE TABLE IF NOT EXISTS `audit_logs` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL,
`action` VARCHAR(50) NOT NULL,
`details` TEXT,
`timestamp` DATETIME DEFAULT CURRENT_TIMESTAMP
);
现在,再来解释一下。
1.Redis分布式锁(处理接口并发)
- 锁key隔离 每个用户的锁独立,不会导致不同用户的请求互相阻塞。
- 原子性保障
使用 Redis 的
SET key value NX PX ttl命令。
NX (Not Exists): 只有当 Key 不存在时才设置成功,保证互斥性。
PX (Milliseconds): 设置自动过期时间,防止因服务崩溃导致的死锁。
ttl:锁有效期 10 秒,即使服务宕机,Redis 也会自动释放锁,避免死锁。
释放锁:用 unlock保证 “只有锁的持有者才能释放”,防止误删其他请求的锁
2.mysql事务回滚(事务管理,保证“要么全做,要么全不做”)
核心逻辑如下
```javascript
let connection;
try {
connection = await pool.getConnection(); // 1. 获取独立连接
await connection.beginTransaction(); // 2. 开启事务
await updateDailyStats(connection); // Step 3: 更新统计 (Upsert)
await insertVideoRecord(connection); // Step 4: 插入记录 (若此处报错)
await insertAuditLog(connection); // Step 5: 记录日志
await connection.commit(); // 3. 提交事务
} catch (err) {
if (connection) {
await connection.rollback(); // 4. ⏪ 自动回滚 Step 3 的修改
}
throw err;
} finally {
if (connection) connection.release(); // 5. 释放连接回池
}
```
- 用
connection.beginTransaction()开启事务,connection.commit()提交,connection.rollback()回滚; - 任意一步操作失败(比如插入记录失败),都会触发
rollback,已更新的用户信息会被撤销; - 事务完成后必须释放 MySQL 连接(
connection.release()),避免连接池耗尽。
3.防重复插入(数据库唯一约束)
核心操作:
- MySQL 表的唯一索引
UNIQUE KEY写法,建立唯一索引daily_upload_stats: 联合唯一索引(user_id, date)。video_records: 唯一索引(video_url)。
-- ---------------------------------------------------------
-- 2. 用户每日上传统计表 (daily_upload_stats)
-- 对应流程:插入用户今天的视频上传信息
-- ---------------------------------------------------------
CREATE TABLE IF NOT EXISTS `daily_upload_stats` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL,
`date` DATE NOT NULL,
`upload_count` INT DEFAULT 1,
`last_upload_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 保证每天每个用户只有一条统计记录
UNIQUE KEY `uk_user_date` (`user_id`, `date`)
);
-- ---------------------------------------------------------
-- 3. 视频上传记录表 (video_records)
-- 对应流程:插入视频上传记录信息
-- ---------------------------------------------------------
CREATE TABLE IF NOT EXISTS `video_records` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL,
`video_title` VARCHAR(255) NOT NULL,
`video_url` VARCHAR(255) NOT NULL,
`file_size` BIGINT,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 假设同一个视频文件(URL)不能重复上传
UNIQUE KEY `uk_video_url` (`video_url`)
);
- 插入数据时,使用
ON DUPLICATE KEY UPDATE写法,作用是,重复时更新而非插入,避免报错
// --- 步骤 3: 插入用户今天的视频上传信息 ---
// 💡 [解决问题:原子性更新统计]
// 使用 ON DUPLICATE KEY UPDATE 语法。
// 如果是今天第一条 -> 执行 INSERT。
// 如果今天已有记录 -> 执行 UPDATE upload_count + 1。
// 这避免了 "Select -> Check -> Insert/Update" 的竞态条件。
console.log('[Step 3] 插入/更新每日上传统计...');
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
await connection.execute(
`INSERT INTO daily_upload_stats (user_id, date, upload_count, last_upload_time)
VALUES (?, ?, 1, NOW())
ON DUPLICATE KEY UPDATE
upload_count = upload_count + 1,
last_upload_time = NOW()`,
[userId, today]
);
3.总结
经过这个问题的排查,发现很多开发者在开发途中会忽略事务的作用,无论是mysql还是mongo其实都有这种类似的写法。当问题出现时,不能归结于用户操作问题,毕竟严谨的后端判定,也用不了多少时间,防抖的前端,是谁也不确定使用者电脑的流畅程度。既然我们不能要求使用者提高配置,那就要在流程以及验证上更加严谨。
由此延伸,其实所有的创建、更新、删除操作,前端都应该有防抖,后端都应该做严谨的判定。