一、核心原则:先理解业务,再写代码
电商交易的核心流程通常包括:
下单 → 支付 → 发货 → 售后,每个环节都涉及资金、库存、状态机的强一致性要求。
牢记三不要:
- 不要假设数据库是“单机玩具”
- 不要忽略并发场景下的数据竞争
- 不要让业务逻辑散落在 SQL 里
二、数据库设计规范
1. 表结构设计
- 主键必须用
BIGINT UNSIGNED AUTO_INCREMENT
避免INT溢出(订单量超 21 亿时会崩) - 金额字段用
DECIMAL(18,2)
禁止用FLOAT/DOUBLE(精度丢失会导致资损) - 状态字段用
TINYINT+ 注释枚举
例:order_status TINYINT NOT NULL COMMENT '0-待支付 1-已支付 2-已取消...' - 关键索引必须覆盖查询条件
如订单表:(user_id, create_time)、(order_no)、(pay_status, update_time)
2. 避免“宽表陷阱”
- 订单主表只存核心字段(用户 ID、订单号、金额、状态)
- 扩展信息(收货地址、商品快照)拆到
order_ext表 - 商品详情、SKU 信息不要冗余到订单表(用快照表解耦)
三、Java 事务与并发控制
1. 事务边界必须清晰
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderRequest request) {
// 1. 扣库存(带锁)
inventoryService.lockStock(request.getSkuId(), request.getQuantity());
// 2. 创建订单
Order order = buildOrder(request);
orderMapper.insert(order);
// 3. 发送MQ(异步解耦,避免事务过长)
mqProducer.send(new OrderCreatedEvent(order.getId()));
}
}
2. “先读再写”必须加锁!
❌ 错误示例(超卖风险)
// 查询库存
int stock = inventoryMapper.selectStock(skuId);
if (stock < buyQty) throw new BizException("库存不足");
// 扣减库存
inventoryMapper.updateStock(skuId, stock - buyQty);
✅ 正确做法(行级锁)
@Transactional
public void deductStock(Long skuId, Integer qty) {
// 加锁读取(当前读)
Inventory inventory = inventoryMapper.selectForUpdate(skuId);
if (inventory.getStock() < qty) {
throw new BizException("库存不足");
}
inventoryMapper.updateStock(skuId, inventory.getStock() - qty);
}
MyBatis Mapper 示例:
<select id="selectForUpdate" resultType="Inventory">
SELECT * FROM inventory WHERE sku_id = #{skuId} FOR UPDATE
</select>
3. 高频场景用乐观锁
适用于低冲突场景(如用户积分变动):
// 数据库字段: version INT DEFAULT 0
int updated = userMapper.updatePoints(
userId,
newPoints,
currentVersion // WHERE version = #{currentVersion}
);
if (updated == 0) throw new BizException("数据已被修改,请重试");
四、防幻读:范围操作必须显式加锁
电商场景常见需求:“同一用户 5 分钟内不能重复下单相同商品”
❌ 无锁查询(幻读风险)
// 普通查询不会加间隙锁!
List<Order> orders = orderMapper.selectRecentOrders(userId, skuId);
if (!orders.isEmpty()) throw new BizException("请勿重复下单");
orderMapper.insert(order);
✅ RR 隔离级别下正确做法
@Transactional
public void createOrder(Order order) {
// 显式加锁(触发 Next-Key Lock)
List<Order> lockedOrders = orderMapper.selectRecentOrdersForUpdate(
order.getUserId(),
order.getSkuId()
);
if (!lockedOrders.isEmpty()) {
throw new BizException("请勿重复下单");
}
orderMapper.insert(order);
}
Mapper SQL:
SELECT * FROM orders
WHERE user_id = #{userId}
AND sku_id = #{skuId}
AND create_time > NOW() - INTERVAL 5 MINUTE
FOR UPDATE -- 关键!
💡 前提:
user_id + sku_id + create_time必须有联合索引,否则锁会退化为表锁!
五、支付状态一致性:最终一致性方案
支付回调可能重复、延迟,绝对不要在回调里直接改订单状态!
推荐流程:
关键代码:
// 支付回调入口(幂等处理!)
public void handlePayCallback(PayCallbackDTO dto) {
// 1. 验签(略)
// 2. 幂等:用 order_no + trade_no 作为唯一键存入 redis/setnx
if (idempotentService.isProcessed(dto.getOrderNo(), dto.getTradeNo())) {
return; // 已处理过
}
// 3. 发送MQ(不直接操作订单)
mqProducer.send(new PaymentResultEvent(dto));
}
六、性能与可维护性
1. 禁止 N+1 查询
// ❌ 错误:循环查数据库
List<Order> orders = orderMapper.selectAll();
for (Order order : orders) {
order.setUserInfo(userMapper.selectById(order.getUserId())); // N+1!
}
// ✅ 正确:JOIN 或批量查询
List<OrderVO> orders = orderMapper.selectWithUserInfo();
2. 大表分页用“游标分页”
-- ❌ 深分页性能差
SELECT * FROM orders LIMIT 100000, 20;
-- ✅ 用上一次最大ID
SELECT * FROM orders WHERE id > #{lastId} ORDER BY id LIMIT 20;
3. 敏感操作留痕
- 订单状态变更必须记录
order_status_log - 库存变动必须记录
inventory_log - 字段:操作人、旧值、新值、时间、业务单据号
七、新人避坑清单
| 问题 | 后果 | 解决方案 |
|---|---|---|
用 SELECT 代替 SELECT ... FOR UPDATE | 超卖/重复下单 | 明确“读-写”依赖必须加锁 |
| 事务里调用远程接口(如支付) | 事务长时间不提交,DB 连接耗尽 | 异步化:事务提交后再发 MQ |
| 忽略 MySQL 隔离级别 | 幻读导致数据错乱 | 确认环境为 REPEATABLE READ |
金额用 double 计算 | 资损(0.1+0.2≠0.3) | 用 BigDecimal + DECIMAL 字段 |
| 没有幂等设计 | 重复支付/重复发货 | 关键接口用唯一键 + redis/setnx |
八、推荐工具链
- SQL 审核:
SOAR/Yearning(上线前自动检查) - 慢查询监控:
Arthas+SkyWalking - 数据一致性核对:每日跑
订单-支付-库存对账任务 - 压测验证:用
JMeter模拟秒杀场景(重点测库存扣减)
结语
电商交易系统是资金与信任的载体,每一行代码都可能影响真金白银。
记住:
“宁可慢一点,不可错一步” —— 数据一致性永远优先于性能优化。
新人遇到不确定的场景,务必:
- 画时序图/状态机
- 和 DBA 讨论索引与锁
- 写单元测试覆盖并发 case
附:MySQL 隔离级别检查命令
SELECT @@transaction_isolation; -- 确保是 REPEATABLE-READ SHOW ENGINE INNODB STATUS\G -- 查看锁等待
1201

被折叠的 条评论
为什么被折叠?



