Skip to content

并发情况下的数据丢失问题 #18

@Winniekun

Description

@Winniekun

背景

在做库存系统的时候,我们经常会遇到这样的问题:
库存有很多种,比如可售库存、锁定库存、在途库存、售后入库库存等,这些库存的口径(也就是怎么算)经常会改。
有时候是业务变了,比如:

  • 新增了“海外仓”;
  • 改了“在途库存”的计算逻辑;
  • 或者某个维度(比如仓库、站点)要拆开计算。

如果每次改口径都去改底层代码、改 SQL、改任务,工作量会非常大,也容易出错。于是我们在系统里搞了一张逻辑库存表,专门用来 配置库存口径、指标定义、所属环境(测试/预发/生产) 这些信息。这样就不需要每次都去改底层代码,只要更新配置,系统就能自动同步出底层对应的实现。但是最近出现了一次诡异的问题,两次并发请求下之后,配置的数据丢失了。。。

SQL

MySQL5.7、InnDB引擎、RR隔离级别

CREATE TABLE stock_metric_impl (
  id                  BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '库存指标实现ID',
  logic_stock_id      BIGINT NOT NULL COMMENT '关联逻辑库存表ID',
  metric_def_id       BIGINT NOT NULL COMMENT '指标定义ID',
  data_source         VARCHAR(64) COMMENT '数据来源(表名或视图名)',
  calc_sql            TEXT COMMENT '计算SQL或表达式',
  enable_flag         TINYINT DEFAULT 1 COMMENT '是否启用(1:启用,0:禁用)',
  create_user         VARCHAR(64),
  gmt_create          DATETIME DEFAULT CURRENT_TIMESTAMP,
  gmt_modified        DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  key KEY idx_metric_def_id (metric_def_id, logic_stock_id)
) COMMENT='库存指标实现表(原子层执行配置)';

代码示例

/**
 * 将库存逻辑配置同步到原子库存指标实现表中。
 * 用于当库存口径、维度或计算规则发生变化时,系统自动重建底层执行配置。
 */
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> syncLogicStockToAtomic(Map<String, Object> logicStockConfig, String erp) {
    // 获取环境信息(test / pre / prod)
    String env = (String) logicStockConfig.get("envType");

    // 获取或创建逻辑库存表记录(如果不存在则新增)
    Long logicStockId = getOrCreateLogicStockId(erp, logicStockConfig, EnvType.of(env));

    // 删除旧的库存指标实现(防止老口径数据残留)
    stockMetricMapper.deleteByLogicStockIds(Collections.singletonList(logicStockId));

    // 从逻辑配置中解析出最新的库存指标定义(例如可售、锁定、在途等)
    List<StockMetricBO> newMetricList = parseStockMetrics(logicStockConfig, logicStockId);

    // 查询数据库中已有的库存指标实现(可能包含系统保留项)
    List<StockMetricRelBO> existingMetrics = stockMetricMapper.getMetricsByLogicStockId(logicStockId);
    Set<Long> existingMetricDefIds = existingMetrics.stream()
    .map(StockMetricRelBO::getMetricDefId)
    .collect(Collectors.toSet());

    // 过滤出需要新增的库存指标(防止重复写入)
    List<StockMetricBO> addList = newMetricList.stream()
    .filter(s -> !existingMetricDefIds.contains(s.getMetricDefId()))
    .collect(Collectors.toList());

    // 插入新的库存指标实现
    if (!addList.isEmpty()) {
        addStockMetrics(addList);
    }

        // 返回结果信息
        ...
}
事务1 事务2
10:20:18:112 事务开始 10:20:18:118 事务开始
10:20:18:490
10:20:18:490 delete from stock_metric_impl where logic_stock_id = 123 10:20:18:537 delete from stock_metric_impl where logic_stock_id = 123
10:20:18:583 select stock_metric_impl where logic_stock_id = 123(读出0条数据) 阻塞 (logic_stock_id非索引,触发表锁)
10:20:18:894 insert into stock_metric_impl
结束
10:20:19:015 删除成功
10:20:19:123 select stock_metric_impl where logic_stock_id = 123 (读出3条数据)
10:20:19:315 读出3条数据,得出无新增指标,不执行新增操作
结束

结论:

  • 请求2删除操作被阻塞,直到请求1执行完整个方法
  • 请求2删除后再查询当前数据,发现数据已经存在,不进行新增,最后当两个请求执行完成,查询出的数据仍然为空

原因剖析

记下来逐个分析这两个结论。

结论一

结论一的原因是锁机制导致的互斥,所以复习下MySQL中锁的机制

锁粒度 共享锁(S锁) 排它锁(X锁) 意向锁
表级 表S锁(Lock Tables ... READ) 表X锁(LOCK TABELS ... WRITE) 意向共享锁(IS)、意向排他锁(IX)
行级 SELECT ... LOCK IN SHARE MODE SELECT ... FOR UPDATE
Gap 范围查找时涉及 间隙X锁(防止其他事务插入相同间隙)
Next-Key Next-Key锁(行锁 + 间隙锁)

删除操作根据最左匹配原则,无法使用索引,此时使用的锁为表级别X锁,所以事务2的删除操作才会阻塞,等到事务1执行完再继续执行

img

结论二

img

一句话:ACID 的核心是一致性,其他三个特性都是为了实现它的手段。

  • 一致性(Consistency):一致性确保事务将数据库从一个一致的状态转变到另一个一致的状态。即使在多个事务同时执行的情况下,数据库也能保持数据的一致性。
  • 原子性(Atomicity):事务是 "不可分割的工作单元"(要么全成,要么全败),是一致性的前提(如果步骤能拆分,中间失败就会破坏一致性)。
  • 隔离性(Isolation):通过控制多事务并发规则,避免互相干扰,是一致性的保障(并发混乱会直接破坏一致性)。
  • 持久性(Durability):事务提交后结果永久保存,是一致性的最终落点(否则重启后数据丢失,之前的一致性白搭)。

InnoDB 的 4 种隔离级别,本质是用 "数据可见性" 换 "并发性能"的选择:

隔离级别 解决的问题 无法解决的问题 典型场景
读未提交(RU) 脏读不可重复读幻读 实时监控(允许脏数据)
读已提交(RC) 脏读 不可重复读幻读 互联网普通业务
可重复读(RR,默认) 脏读不可重复读 幻读MVCC 解决读写下的幻读问题Next-Key Lock解决写写下的幻读问题 金融交易、库存管理
串行化(Serializable) 所有并发问题 银行对账(无并发需求)

MVCC

详细内容见,这里只介绍一些核心的内容

此处为语雀内容卡片,点击链接查看:https://www.yuque.com/weikunkun/iz00uv/iolr4gadbgz1tbck

每个事务启动时,会拿到一个全局递增的事务 ID(trx_id)。每行数据隐藏 3 个字段:

  • DB_TRX_ID:最后修改该行的事务 ID;
  • DB_ROLL_PTR:指向 undo 日志的指针(存储历史版本);
  • DB_DELETED:标记是否删除(逻辑删除)。

快照读(普通 SELECT):只看 "事务 ID ≤ 自己 ID" 且 "未被删除" 的版本,完全不加锁。 例:事务 A(ID=100)查询时,会忽略所有被 ID>100 的事务修改的数据。包含 4 个核心字段:

  • m_ids:生成 Read View 时,当前活跃的事务 ID 列表(未提交的事务)。
  • min_trx_idm_ids中最小的事务 ID。
  • max_trx_id:下一个将要分配的事务 ID(非活跃事务 ID,仅用于判断 “未来事务”)。
  • creator_trx_id:生成该 Read View 的事务自身 ID。

可见性比较规则:

判断步骤 条件 含义 结果
row.trx_id < min_trx_id 修改它的事务早就提交了 ✅ 可见
row.trx_id >= max_trx_id 修改它的事务在我之后才开始 ❌ 不可见
min_trx_id <= row.trx_id < max_trx_id 修改它的事务和我重叠(并发事务) ⬇️ 看情况
对③的情况,若 row.trx_id ∈ m_ids 该事务还没提交 ❌ 不可见
对③的情况,若 row.trx_id ∉ m_ids 该事务已经提交 ✅ 可见

事务日志

  • redo log(重做日志)

    • 作用:崩溃后恢复未写入磁盘的数据(保证 durability)。
    • WAL:事务提交时,数据先写 redo log(内存 + 磁盘),再异步刷到数据文件
    • 为什么快?redo log 是顺序写(磁盘顺序写比随机写快 100 倍 +)。
  • undo log(回滚日志)

    • 作用:保存数据修改前的版本,用于事务回滚(保证 atomicity)和 MVCC 快照读。
    • 注意:undo log 会被 purge 线程定期清理(当没有事务需要旧版本时)。

事务设计规范

  • 凡是不需要事务的操作,坚决不用(如日志插入可关闭自动提交,批量提交)。
  • 凡是能在 RC 解决的,尽量不升 RR(用业务逻辑防不可重复读)。
  • 凡是大事务,必拆分成 "读 - 算 - 写" 三步(读阶段不加锁,算阶段在应用层,写阶段用最短事务加锁)。

记住:事务的本质不是 "约束",而是 "工具"—— 能解决问题的最简单事务,才是最好的事务。

实践

为了方便理解, 在原逻辑开始前,引入查询操作

事务隔离级别RR

先写入样例数据

INSERT INTO stock_metric_impl 
  (logic_stock_id, metric_def_id, data_source, calc_sql, enable_flag, create_user) 
VALUES
(1001, 2001, 'stock_num', 'SELECT sku_id, warehouse_code, (actual_stock - lock_stock) AS sale_stock FROM stock_num', 1, 'kk'),
(1001, 2002, 'stock_order', 'SELECT sku_id, (transfer_out - transfer_in) AS transit_stock FROM stock_order', 1, 'kk'),
(1001, 2003, 'children_task', 'SELECT sku_id, SUM(return_in_stock) AS aftersale_stock FROM hildren_task WHERE biz_type = 1701', 1, 'kk'),
(1001, 2004, 'stock_num', 'SELECT sku_id, lock_stock AS locked_stock FROM stock_num', 1, 'kk'),
(1001, 2005, 'stock_diff', 'SELECT sku_id, diff_qty AS abnormal_stock FROM stock_diff WHERE diff_type IN (''LOSS'', ''OVER'')', 1, 'kk');

基于案例的事务模拟:

begin;
select * from stock_metric_impl where logic_stock_id = 1001;

delete from stock_metric_impl where logic_stock_id = 1001;

select * from stock_metric_impl where logic_stock_id = 1001;

insert into stock_metric_impl 
  (logic_stock_id, metric_def_id, data_source, calc_sql, enable_flag, create_user) 
values 
(1001, 2001, 'stock_num', 'SELECT sku_id, warehouse_code, (actual_stock - lock_stock) AS sale_stock FROM stock_num', 1, 'kk'),
(1001, 2002, 'stock_order', 'SELECT sku_id, (transfer_out - transfer_in) AS transit_stock FROM stock_order', 1, 'kk'),
(1001, 2003, 'children_task', 'SELECT sku_id, SUM(return_in_stock) AS aftersale_stock FROM hildren_task WHERE biz_type = 1701', 1, 'kk'),
(1001, 2004, 'stock_num', 'SELECT sku_id, lock_stock AS locked_stock FROM stock_num', 1, 'kk'),
(1001, 2005, 'stock_diff', 'SELECT sku_id, diff_qty AS abnormal_stock FROM stock_diff WHERE diff_type IN (''LOSS'', ''OVER'')', 1, 'kk');

 commit;
begin;
select * from stock_metric_impl where logic_stock_id = 1001; 

delete from stock_metric_impl where logic_stock_id = 1001;

select * from stock_metric_impl where logic_stock_id = 1001;

 commit;
Image

如图,事务2在事务1执行结束后,删除操作才继续,虽然显示删除成功,但是再次查询时,数据还在,但是两个事务都COMMIT之后, logic_stock_id = 1001的数据都不在了

Image

解决方法

  • 在事务外加个分布式锁,确保同一时间只有一个业务操作执行

    • 接口RT增大
  • 专门写个对应的查询语句,声明为S锁

    • 存在长事务问题,不推荐
  • 遵循规则 读-算-写

    • 从根源解决,需要进行改造历史代码

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions