【问题标题】:How to thread-safely read from and write to the database?如何线程安全地读取和写入数据库?
【发布时间】:2026-01-11 12:35:02
【问题描述】:

我正在编写 Java API,并且正在使用 MySQL。线程安全读写数据库的最佳实践是什么?

例如,采用以下createUser 方法:

// Create a user
// If the account does not have any users, make this
// user the primary account user
public void createUser(int accountId) {
    String sql =
            "INSERT INTO users " +
            "(user_id, is_primary) " +
            "VALUES " +
            "(0, ?) ";
    try (
            Connection conn = JDBCUtility.getConnection();
            PreparedStatement ps = conn.prepareStatement(sql)) {

        int numberOfAccountsUsers = getNumberOfAccountsUsers(conn, accountId);
        if (numberOfAccountsUsers > 0) {
            ps.setString(1, null);
        } else {
            ps.setString(1, "y");
        }

        ps.executeUpdate();
    } catch (SQLException e) {
        e.printStackTrace();
    }
}

// Get the number of users in the specified account
public int getNumberOfAccountsUsers(Connection conn, int accountId) throws SQLException {
    String sql =
            "SELECT COUNT(*) AS count " +
            "FROM users ";
    try (
            PreparedStatement ps = conn.prepareStatement(sql);
            ResultSet rs = ps.executeQuery()) {

        return rs.getInt("count");
    }
}

假设源 A 调用 createUser,并且刚刚读取到帐户 ID 100 有 0 个用户。然后,源 B 调用createUser,在源 A 执行更新之前,源 B 也读取到帐户 ID 有 0 个用户。因此,来源 A 和来源 B 都创建了主要用户。

这种情况如何安全实施?

【问题讨论】:

  • 所以这个问题其实和线程安全无关。这是你的实际问题还是你只是用它来说明你的问题?另外,您使用的是什么数据库系统(MySQL、SQL Server 等)?
  • 他/她提到了 MySQL
  • 顺便问一下,您在第二种方法中如何使用accountId
  • 为什么不将用户的is_primary 字段设为可派生。例如,对于特定帐户具有最早创建时间的任何用户都可以是主要用户。否则你很可能需要有表级锁。
  • 您的代码允许使用user_id 1 和is_primary 标志NULL 创建许多用户。您是要插入numberOfAccountsUsers + 1 作为user_id 吗?我认为这不会是基础表上典型的唯一键约束和连接上合适的事务设置的问题。

标签: java mysql multithreading jdbc


【解决方案1】:

这种事情就是事务的用途,它们允许您输入多个语句,并保证某种一致性。从技术上讲,原子性、一致性、隔离性和持久性,请参阅https://*/q/974596

您可以使用

锁定行
select for update 

并且锁会一直持有到交易结束。

对于这种情况,您可能会锁定整个表,运行 select 以计算行数,然后执行插入。锁定表后,您知道选择和插入之间的行数不会改变。

通过将 select 放在 insert 中来消除对 2 个语句的需求是一个好主意。但一般来说,如果您使用数据库,您需要了解事务。

对于本地 jdbc 事务(相对于 xa 事务),您需要对参与同一事务的所有语句使用相同的 jdbc 连接。一旦所有语句都运行完毕,请在连接上调用 commit。

易于使用事务是spring框架的一个卖点。

【讨论】:

    【解决方案2】:

    问题概述

    首先,您的问题与线程安全无关,它与可以更好地优化的事务和代码有关。

    如果我没看错,您尝试将第一个用户设置为主用户。我可能根本不会采用这种方法(也就是说,第一个用户有一个 is_primary 行),但如果我这样做了,我根本不会在你的 Java 应用程序中包含那个条件。每次创建用户时,您不仅要评估条件,而且还要对数据库进行不必要的调用。相反,我会像这样重构。

    代码

    首先,确保你的桌子是这样的。

    CREATE TABLE `users` (
         user_id INT NOT NULL AUTO_INCREMENT,
         is_primary CHAR(1),
         name VARCHAR(30),
         PRIMARY KEY (user_id)
    );
    

    换句话说,您的 user_id 应该是 auto_incrementnot null。我包含了name 列,因为它有助于说明我在下面的观点(但您可以将其替换为用户表上除user_idis_primary 之外的其他列)。我也将user_id 设为主要,因为它可能是。

    如果你只想修改你当前的表,它会是这样的。

    ALTER TABLE `users` MODIFY COLUMN `user_id` INT not null auto_increment;
    

    然后创建一个触发器,这样每次您插入一行时,它都会检查它是否是第一行,如果是,它会相应地更新。

    delimiter |
        CREATE TRIGGER primary_user_trigger 
        AFTER INSERT ON users
          FOR EACH ROW BEGIN
    
            IF NEW.user_id = 1 THEN
               UPDATE users SET is_primary = 'y' where user_id = 1;
            END IF;
          END;
    | delimiter ;
    

    此时,您只需使用 createUser 方法将新记录插入数据库,而您根本不需要指定 user_id 或主字段。所以假设你的桌子是这样的:

    您的 createUser 方法基本上如下所示

    // Create a user
    // If the account does not have any users, make this
    // user the primary account user
    public void createUser(String name) {
        String sql =
                "INSERT INTO users (name) VALUES(?)"
        try (
                Connection conn = JDBCUtility.getConnection();
                PreparedStatement ps = conn.prepareStatement(sql));
    
                ps.setString(1, name);
                ps.executeUpdate();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
    

    运行两次用户表看起来像

    |    user_id   |   is_primary   |   name   |
    +--------------+----------------+----------+
    |      1       |       y        |   Jimmy  |
    ---------------+----------------+----------+
    |      2       |       null     |   Cindy  |
    

    但是...

    但是,尽管我认为上述解决方案比原始问题中提出的解决方案更好,但我仍然认为这不是处理此问题的最佳方式。不过,在提出任何建议之前,我需要了解更多有关该项目的信息。第一个用户主要是什么?什么是主要用户?如果只有一个,为什么不能手动设置主要用户?有管理控制台吗?等等。

    如果你的问题是一个例子......

    因此,如果您的问题只是一个示例,用于说明有关事务管理的更大问题,您可以将连接上的自动提交设置为 false 并手动管理您的事务。您可以在 Oracle JDBC Java 文档中阅读有关自动提交的更多信息。此外,如上所述,您可以根据您的具体操作更改表/行级别锁定,但我认为这是解决此问题的一个非常不切实际的解决方案。您还可以将 select 作为子查询包含在内,但同样您只是在为不好的做法贴上创可贴。

    总而言之,除非您想完全改变您的主要用户模式,否则我认为这是最有效且组织良好的方式。

    【讨论】:

      【解决方案3】:

      我认为最好在一个插入查询中包含设置is_primary 值的条件:

      public void createUser(int accountId) {
          String sql = "INSERT INTO users (user_id, is_primary) " +
                       "SELECT 0, if(COUNT(*)=0, 'y', null) " +
                       "FROM users";
         //....
      }
      

      所以在多线程环境中运行你的代码是安全的,你可以摆脱getNumberOfAccountsUsers 方法。

      为了消除对并发insert 可见性的任何疑问(感谢@tsolakp comment),如the documentation 所述:

      并发 INSERT 的结果可能不会立即可见。

      您可以为您的插入语句使用相同的Connection 对象,以便 MySql 服务器将按顺序运行它们,

      is_primary 列使用unique index,(我假设ynull 是可能的值。注意所有MySql 引擎都允许多个null 值) ,因此在违反唯一约束的情况下,您只需重新运行insert 查询。这种独特的索引解决方案也适用于您现有的代码。

      【讨论】:

      • 您仍然需要表锁定以确保两个插入不会被计为 0。
      • @tsolakp,我不这么认为,看看这个answer
      • 这并不意味着insert2会看到insert1的数据,仍然可以得到0进行计数。来自 MySQL 文档:“并发 INSERT 的结果可能不会立即可见”。
      • 我可能会建议使用相同的连接,因为它可以保证顺序执行和可见性,即使我不清楚并发 INSERT 时数据的可见性或不可见性(不同连接)。