Skip to content

总结

1.1 订单支付幂等

从“查状态再更新”升级到“数据层保证”,本质上是从逻辑判断原子性保障的进阶。


  1. 为什么“查状态再更新”不够安全?(存在并发幻读风险)

很多初学者的逻辑是这样的(伪代码):

  1. 收到支付回调。
  2. SELECT status FROM orders WHERE order_id = '123';
  3. if (status == '待支付') {
  4. UPDATE orders SET status = '已支付' WHERE order_id = '123';
  5. }

风险点: 在高并发环境下,如果支付平台在极短时间内发送了两个相同的回调请求(请求 A 和请求 B)。

  • A 进入,查到是“待支付”。
  • 在 A 还没来得及更新状态时,B 进入,也查到是“待支付”。
  • 结果:A 更新了一次,B 也更新了一次。虽然状态最终都是“已支付”,但如果后面跟着发货逻辑赠送积分逻辑,这些逻辑就会被触发两次

  1. 升级方案:条件更新 (Optimistic Locking 思路)

通过 SQL 语句本身的原子性来解决。不再先查后改,而是直接带条件改

SQL

UPDATE orders 
SET status = '已支付', pay_time = NOW() 
WHERE order_id = '123' AND status = '待支付';

原理:

  • 数据库会给这一行加行锁。
  • 请求 A 执行成功,受影响行数为 1。
  • 请求 B 紧随其后,虽然 order_id 对了,但此时 status 已经是“已支付”,不满足 AND status = '待支付' 的条件,受影响行数为 0。
  • 你的程序根据受影响行数来判断是否执行后续的业务(如发券)。

  1. 升级方案:回调记录 + 唯一约束 (The "Fence" Strategy)

为了彻底封死重复处理的可能性,通常会建立一张“支付流水表”或“回调记录表”。

  • 表结构: 创建一张 pay_log 表,给 transaction_id(支付平台生成的交易号)或者 order_id 加上唯一索引 (Unique Index)
  • 执行流程:
    1. 开启数据库事务。
    2. 尝试插入记录INSERT INTO pay_log (order_id, ...) VALUES ('123', ...);
    3. 如果插入成功,继续更新订单状态;如果插入失败(报 Duplicate Key 异常),说明该通知已经处理过,直接返回成功给支付平台,不再执行后续逻辑。

方案核心原理优点
查状态再更新靠 Java 代码逻辑判断简单,但不防并发
条件更新利用 SQL 原子性性能高,代码简洁。
唯一约束记录利用数据库索引硬限制最稳妥,且留下了完整的支付审计日志。

1.2 数据库锁

我们可以从粒度兼容性实现思想三个维度来系统拆解。


1. 按“粒度”划分:锁住多大的地方?

这是性能与安全的博弈。

  • 行级锁 (Row Lock)
    • 特点:只锁定当前操作的那一行数据。
    • 优点:并发度最高,适合“苍穹外卖”这种大量用户同时下单的场景(你下你的单,我下我的单,互不干扰)。
    • 缺点:获取锁消耗资源多。注意:MySQL 的行锁是加在索引上的,如果没有索引,行锁会升级为表锁。
  • 表级锁 (Table Lock)
    • 特点:直接锁定整张表。
    • 优点:开销小,加锁快,不会出现死锁。
    • 缺点:并发度极低。通常用于数据库迁移或全表备份。
  • 页级锁 (Page Lock):介于行锁与表锁之间(BDB 引擎使用,InnoDB 较少提及)。

2. 按“兼容性”划分:别人还能读写吗?

这是最常见的分类方式,主要指 共享锁排他锁

锁类型缩写描述兼容性
共享锁 (Shared Lock)S 锁读锁。允许其他事务也加 S 锁来读。S 与 S 兼容,S 与 X 冲突
排他锁 (Exclusive Lock)X 锁写锁。一旦加上,其他事务既不能读也不能写。X 与任何锁都冲突

意向锁 (Intention Lock)

这是 InnoDB 自动加的“哨兵”。比如你想给表加“表锁”,你不需要逐行检查有没有行锁,只需要看一眼表上的“意向锁”标志位就行了。


3. 按“实现思想”划分:乐观还是悲观?

这是面试官最喜欢问的“灵魂拷问”。

1. 悲观锁 (Pessimistic Locking)

  • 核心理念:“总有刁民想害朕”。假定每次拿数据都会被别人修改,所以先拿锁,再办事
  • 实现:使用 SQL 里的 SELECT ... FOR UPDATE
  • 场景:并发极高、冲突严重的情况。
实现方式:FOR UPDATE

在查询时就直接加排他锁(X 锁):

  1. 开启事务BEGIN;
  2. 查询并加锁SELECT * FROM orders WHERE id = 1 FOR UPDATE; (此时其他事务无法改动这一行)
  3. 执行业务:比如修改库存、计算金额。
  4. 提交事务COMMIT; (此时才会释放锁)
  • 优点:安全性极高,完全由数据库保证一致性。
  • 缺点:并发性能差,如果事务处理时间长,会阻塞大量请求。

2. 乐观锁 (Optimistic Locking)

  • 核心理念:“大家都挺自觉的”。假定不会发生冲突,只有在提交更新时才检查数据是否被动过。
  • 实现:通常不靠数据库自带的锁,而是靠程序逻辑(如增加 version 字段)。
  • 场景:读多写少。
实现方式:版本号 (Version) 或 时间戳

这是最经典的做法。在表中增加一个 version 字段。

  1. 读取数据SELECT id, status, version FROM orders WHERE id = 1;

    • 假设拿到:version = 1
  2. 业务逻辑:在内存中计算好要修改的值。

  3. 提交更新(核心步骤):执行带版本校验的 SQL。

    SQL
    UPDATE orders 
    SET status = 2, version = version + 1 
    WHERE id = 1 AND version = 1; -- 关键:必须匹配刚才拿到的版本号
  4. 判断结果

    • 如果受影响行数 (Affected Rows) = 1,说明成功。
    • 如果 = 0,说明在你操作期间,别人已经改过了(版本变了)。你需要重试报错

4. 间隙锁 (Gap Lock)

为了解决“幻读”问题,InnoDB 引入了间隙锁。

  • 例子:你执行 UPDATE orders SET ... WHERE id BETWEEN 10 AND 20;
  • 作用:它不仅锁住 10-20 这几行,还会锁住它们之间的“间隙”,防止别人在这个范围内插入新数据(防止幻读)。

5. 总结:如何选锁?

  1. 高频更新订单状态:用 行锁(通过索引触发)。
  2. 防止超卖:可以用 UPDATE stock SET num = num - 1 WHERE id = 1 AND num > 0;(利用行锁的原子性)。
  3. 大批量导入数据:可以手动申请 表锁 提高效率。

6. 索引加锁

MySQL在进行DML操作时,如果语句没有使用到索引,此次更新行锁会变成表锁。

sql
-- 假设 orders 表只有 id 是主键,phone 字段没建索引。
-- 事务 A
UPDATE orders SET status = 1 WHERE phone = '13800138000'; 
-- 此时因为找不到 phone 索引,InnoDB 会锁定聚簇索引(全表记录)!

-- 事务 B
UPDATE orders SET status = 1 WHERE id = 99; 
-- 即使 B 改的是另一行,也会被 A 阻塞,因为全表都被锁了。

1.3 SQL N+1查询

1. 定义:

执行 1 次主查询获取 $N$ 条记录,但为了获取关联数据,又额外执行了 $N$ 次子查询。

  • 总 SQL 数 = $1 + N$。
  • 后果:数据库连接开销巨大,随数据量增加性能呈线性下降。

2. 场景复现(苍穹外卖):

  • 查询 10 个套餐(1 次 SQL)。
  • 循环这 10 个套餐,分别去数据库查每个套餐里的菜品(10 次 SQL)。

3. 核心解决方案:

  • 连接查询 (Join):使用 LEFT JOIN 一次性查出所有平铺数据(1 条 SQL)。
  • IN 子句批量查询:先查出 $N$ 个主表 ID,再用 WHERE id IN (...) 一次性查出所有关联项(共 2 条 SQL)。

1.4 SQL复杂对象的查询(ResultMap)

ResultMap 是 MyBatis 的“对象组装工厂”,负责将数据库的平铺行折叠成 Java 的嵌套对象

标签对应关系Java 表现适用场景
<id>主键映射唯一标识必填! MyBatis 据此判断是否为同一个对象,防止重复封装。
<result>基本字段普通属性映射列名与成员变量名。
<association>一对一单个对象订单 (Order) 属于 某个用户 (User)。
<collection>一对多List 集合套餐 (Setmeal) 包含 多个菜品 (Dish)。

具体使用:

sql
-- 定义好映射关系
<resultMap id="setmealWithDishMap" type="com.sky.vo.SetmealVO">
    <id column="id" property="id"/>
    <result column="name" property="name"/>
    <result column="price" property="price"/>
    
    <collection property="setmealDishes" ofType="com.sky.entity.SetmealDish">
        <id column="sd_id" property="id"/>
        <result column="dish_id" property="dishId"/>
        <result column="sd_name" property="name"/>
    </collection>
</resultMap>
-- 使用resultMap做结果映射
<select id="getByIdWithDish" resultMap="setmealWithDishMap">
    SELECT 
        s.*, 
        sd.id as sd_id, sd.name as sd_name, sd.dish_id 
    FROM setmeal s
    LEFT JOIN setmeal_dish sd ON s.id = sd.setmeal_id
    WHERE s.id = #{id}
</select>

组装原理(折叠机制):

MyBatis 遍历结果集时,如果发现连续几行的 <id> 值相同,它会认为这是同一个主对象,并把这些行里属于 <collection> 的部分不断 add 到主对象的 List 属性中。


1.5 一对多分页查询的“坑”

查一个复杂对象(该对象成员对应多条记录)时,如果选择了连接查询,会返回多行记录,不满足PageHelper的分页逻辑了(直接拼装limit),需要注意该问题。

我们用一个最直观的例子来拆解:假设我们要查询**“前2个套餐”**,每个套餐里有多个菜品。


1. 为什么直接分页(Limit)会出错?

想象你的数据库里有以下数据(LEFT JOIN 后的结果):

行号套餐ID套餐名称菜品名称
11情侣双人餐宫保鸡丁
21情侣双人餐手撕包菜
31情侣双人餐珍珠奶茶
42全家桶套餐吮指原味鸡
52全家桶套餐香辣鸡腿堡
62全家桶套餐薯条
72全家桶套餐可乐

如果你在 SQL 后面直接加 LIMIT 0, 5(也就是想要前 5 条数据):

  • 数据库的操作:它不管什么是套餐,它只数行数。数到第 5 行,切断!
  • 结果:你拿到了“情侣双人餐”的所有数据,但“全家桶套餐”你只拿到了前 2 个菜品。
  • Java 组装后:你的 List<SetmealVO> 确实有两个对象,但第二个全家桶的菜品列表是残缺的。

2. 解决方案 A:驱动表分页(嵌套 SQL)

这是“治本”的方法:先确定我要哪几个套餐,再去关联它们的菜品。

SQL 逻辑:

sql
SELECT s.*, sd.* FROM (
    -- 第一步:先从主表里查出我要的那 2 个套餐
    SELECT * FROM setmeal LIMIT 0, 2
) s
LEFT JOIN setmeal_dish sd ON s.id = sd.setmeal_id;
  • 原理:括号里的子查询(驱动表)先保证了我们拿到的就是 ID 为 1 和 2 的两个完整套餐。
  • 外层的 JOIN:再把这两个套餐对应的所有菜品(一共 7 行)全部抓出来。
  • 结果:MyBatis 拿到这 7 行,按照我们之前说的 ResultMap 组装规则,完美合并成 2 个完整的 Java 对象。

3. 解决方案 B:业务层异步组装(最推荐,最灵活)

在“苍穹外卖”这种实际项目中,为了让分页插件(如 PageHelper)好使,通常不写复杂的 Join 分页,而是分两步走:

  1. 查主表(分页)

    执行 SELECT * FROM setmeal LIMIT 0, 2

    得到:List<Setmeal>(只有套餐基本信息)。

  2. 查子表(批量)

    提取上面 2 个套餐的 ID (1, 2),执行 SELECT * FROM setmeal_dish WHERE setmeal_id IN (1, 2)

    得到:所有的菜品。

  3. 代码胶水(组装)

    在 Java Service 层,循环套餐 List,把对应的菜品塞进每个套餐的 dishes 属性里。

为什么选这个?

  • 简单:SQL 非常简单,不容易写错。
  • 高效IN 查询走索引极快,而且避开了 Join 大表产生的笛卡尔积。
  • 兼容性:完美适配所有分页插件。

  • 坑点Join 查询会把一行变多行,导致 Limit 截断了对象的数据完整性。
  • 核心思路“先分页,再关联”。要么在 SQL 里用子查询先分页,要么在 Java 里分两步查。