如何安全地在 MySQL 和 Java 中更新共享数据

本文介绍在多用户并发场景下,如何通过数据库层面的原子性操作确保票务系统中座位分配的安全性,重点对比 sql 条件更新与服务层校验两种方案,并推荐基于 `update ... where` 的行级乐观锁实践。

在高并发票务、预约或库存类系统中,“重复抢占同一资源”(如两个用户同时抢同一个座位)是典型的竞态条件问题。核心矛盾在于:业务逻辑的“读-判-写”三步操作不具备原子性。若在 Service 层先查再更新(即“检查后更新”模式),即使中间仅毫秒级间隔,也可能被其他事务插入并修改,导致脏写覆盖。

✅ 推荐方案:在 SQL 层完成原子化条件更新
使用带约束的 UPDATE 语句,将“是否可更新”的判断与数据修改合并为单条数据库操作,由 MySQL 引擎保证原子性与隔离性:

UPDATE ticket 
SET user_user_id = ? 
WHERE pla

ce = ? AND user_user_id IS NULL;

该语句仅当目标座位(place)当前未被占用(user_user_id IS NULL)时才生效。执行后,JDBC 可通过 PreparedStatement.executeUpdate() 返回的 受影响行数(int) 判断结果:

public void assignSeat(String place, Long userId) throws DAOException {
    String sql = "UPDATE ticket SET user_user_id = ? WHERE place = ? AND user_user_id IS NULL";
    try (PreparedStatement ps = connection.prepareStatement(sql)) {
        ps.setLong(1, userId);
        ps.setString(2, place);
        int updated = ps.executeUpdate();
        if (updated == 0) {
            throw new DAOException("Seat '" + place + "' is already taken or does not exist");
        }
    } catch (SQLException e) {
        throw new DAOException("Failed to assign seat", e);
    }
}

⚠️ 不推荐方案:服务层“先查后更”(Read-Then-Update)
虽然逻辑直观,但存在固有缺陷:

  • 即使加 SELECT ... FOR UPDATE(悲观锁),也会显著降低并发吞吐;
  • 若未加锁,则必然存在时间窗口(T1 查询 → T2 查询 → T1 更新 → T2 更新),导致后提交者静默覆盖前提交者结果;
  • 额外一次数据库往返,增加延迟与连接开销。

? 补充建议

  • 索引优化:确保 place 字段上有索引(如唯一索引或普通索引),避免全表扫描,提升 WHERE 条件匹配效率;
  • 事务边界:该 UPDATE 应置于显式事务中(如 Spring @Transactional),确保失败时可回滚;
  • 扩展性考虑:如需记录抢占时间、支持抢占重试或分布式锁,可在该原子更新基础上叠加版本号(version 字段)实现乐观锁升级,但对简单座位分配场景,IS NULL 判断已足够高效可靠。

综上,数据库原生条件更新是安全、简洁、高性能的首选实践——它把并发控制交还给最擅长此事的组件:关系型数据库本身。