从一个问题看出团队对mysql事务的理解问题

0 阅读10分钟

文章目录

如果你认为这篇文章能够帮到你,请一键三连(点赞,收藏,评论),点点关注!
有好的想法或问题,也可以私聊我进行沟通!

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 命令。

  1. NX (Not Exists): 只有当 Key 不存在时才设置成功,保证互斥性。

  2. PX (Milliseconds): 设置自动过期时间,防止因服务崩溃导致的死锁。

  3. 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其实都有这种类似的写法。当问题出现时,不能归结于用户操作问题,毕竟严谨的后端判定,也用不了多少时间,防抖的前端,是谁也不确定使用者电脑的流畅程度。既然我们不能要求使用者提高配置,那就要在流程以及验证上更加严谨。

由此延伸,其实所有的创建、更新、删除操作,前端都应该有防抖,后端都应该做严谨的判定。