总结
1.1 订单支付幂等
从“查状态再更新”升级到“数据层保证”,本质上是从逻辑判断向原子性保障的进阶。
- 为什么“查状态再更新”不够安全?(存在并发幻读风险)
很多初学者的逻辑是这样的(伪代码):
- 收到支付回调。
SELECT status FROM orders WHERE order_id = '123';if (status == '待支付') {UPDATE orders SET status = '已支付' WHERE order_id = '123';}
风险点: 在高并发环境下,如果支付平台在极短时间内发送了两个相同的回调请求(请求 A 和请求 B)。
- A 进入,查到是“待支付”。
- 在 A 还没来得及更新状态时,B 进入,也查到是“待支付”。
- 结果:A 更新了一次,B 也更新了一次。虽然状态最终都是“已支付”,但如果后面跟着发货逻辑或赠送积分逻辑,这些逻辑就会被触发两次。
- 升级方案:条件更新 (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。 - 你的程序根据受影响行数来判断是否执行后续的业务(如发券)。
- 升级方案:回调记录 + 唯一约束 (The "Fence" Strategy)
为了彻底封死重复处理的可能性,通常会建立一张“支付流水表”或“回调记录表”。
- 表结构: 创建一张
pay_log表,给transaction_id(支付平台生成的交易号)或者order_id加上唯一索引 (Unique Index)。 - 执行流程:
- 开启数据库事务。
- 尝试插入记录:
INSERT INTO pay_log (order_id, ...) VALUES ('123', ...); - 如果插入成功,继续更新订单状态;如果插入失败(报
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 锁):
- 开启事务:
BEGIN; - 查询并加锁:
SELECT * FROM orders WHERE id = 1 FOR UPDATE;(此时其他事务无法改动这一行) - 执行业务:比如修改库存、计算金额。
- 提交事务:
COMMIT;(此时才会释放锁)
- 优点:安全性极高,完全由数据库保证一致性。
- 缺点:并发性能差,如果事务处理时间长,会阻塞大量请求。
2. 乐观锁 (Optimistic Locking)
- 核心理念:“大家都挺自觉的”。假定不会发生冲突,只有在提交更新时才检查数据是否被动过。
- 实现:通常不靠数据库自带的锁,而是靠程序逻辑(如增加
version字段)。 - 场景:读多写少。
实现方式:版本号 (Version) 或 时间戳
这是最经典的做法。在表中增加一个 version 字段。
读取数据:
SELECT id, status, version FROM orders WHERE id = 1;- 假设拿到:
version = 1
- 假设拿到:
业务逻辑:在内存中计算好要修改的值。
提交更新(核心步骤):执行带版本校验的 SQL。
SQLUPDATE orders SET status = 2, version = version + 1 WHERE id = 1 AND version = 1; -- 关键:必须匹配刚才拿到的版本号判断结果:
- 如果受影响行数 (Affected Rows) = 1,说明成功。
- 如果 = 0,说明在你操作期间,别人已经改过了(版本变了)。你需要重试或报错。
4. 间隙锁 (Gap Lock)
为了解决“幻读”问题,InnoDB 引入了间隙锁。
- 例子:你执行
UPDATE orders SET ... WHERE id BETWEEN 10 AND 20;。 - 作用:它不仅锁住 10-20 这几行,还会锁住它们之间的“间隙”,防止别人在这个范围内插入新数据(防止幻读)。
5. 总结:如何选锁?
- 高频更新订单状态:用 行锁(通过索引触发)。
- 防止超卖:可以用
UPDATE stock SET num = num - 1 WHERE id = 1 AND num > 0;(利用行锁的原子性)。 - 大批量导入数据:可以手动申请 表锁 提高效率。
6. 索引加锁
MySQL在进行DML操作时,如果语句没有使用到索引,此次更新行锁会变成表锁。
-- 假设 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)。 |
具体使用:
-- 定义好映射关系
<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 | 套餐名称 | 菜品名称 |
|---|---|---|---|
| 1 | 1 | 情侣双人餐 | 宫保鸡丁 |
| 2 | 1 | 情侣双人餐 | 手撕包菜 |
| 3 | 1 | 情侣双人餐 | 珍珠奶茶 |
| 4 | 2 | 全家桶套餐 | 吮指原味鸡 |
| 5 | 2 | 全家桶套餐 | 香辣鸡腿堡 |
| 6 | 2 | 全家桶套餐 | 薯条 |
| 7 | 2 | 全家桶套餐 | 可乐 |
如果你在 SQL 后面直接加 LIMIT 0, 5(也就是想要前 5 条数据):
- 数据库的操作:它不管什么是套餐,它只数行数。数到第 5 行,切断!
- 结果:你拿到了“情侣双人餐”的所有数据,但“全家桶套餐”你只拿到了前 2 个菜品。
- Java 组装后:你的
List<SetmealVO>确实有两个对象,但第二个全家桶的菜品列表是残缺的。
2. 解决方案 A:驱动表分页(嵌套 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 分页,而是分两步走:
查主表(分页):
执行
SELECT * FROM setmeal LIMIT 0, 2。得到:
List<Setmeal>(只有套餐基本信息)。查子表(批量):
提取上面 2 个套餐的 ID (1, 2),执行
SELECT * FROM setmeal_dish WHERE setmeal_id IN (1, 2)。得到:所有的菜品。
代码胶水(组装):
在 Java Service 层,循环套餐 List,把对应的菜品塞进每个套餐的
dishes属性里。
为什么选这个?
- 简单:SQL 非常简单,不容易写错。
- 高效:
IN查询走索引极快,而且避开了Join大表产生的笛卡尔积。 - 兼容性:完美适配所有分页插件。
- 坑点:
Join查询会把一行变多行,导致Limit截断了对象的数据完整性。 - 核心思路:“先分页,再关联”。要么在 SQL 里用子查询先分页,要么在 Java 里分两步查。