库存对不上金额差几分钱 MySQL事务与锁的实战避坑指南
那天下午,财务小姐姐推着我工位,指着屏幕上一行红色的数字,声音都在抖:”小陈,你这库存怎么又对不上了?差了3分钱,你说这账我怎么跟老板交代?”
我盯着那3分钱,脑瓜子嗡嗡的。3分钱?这玩意儿说小不小,说大不大,但卡在账上就是扎眼。查了整整一天,最后发现不是程序写得不对,而是MySQL事务和锁的使用姿势出了问题。
今天就把这个坑摊开来聊,顺便把能踩的坑都给你标出来。
先说清楚,那3分钱到底是怎么没了的
故事得从一个简单的”扣库存”场景说起。
你想象一下,你在卖限量球鞋,库存就100双。同时有两个人下单,每人1双,每双3.33元。订单表里记的是3.33,但实际扣库存的时候,数据库里记录的是3.3299999…
这就是经典问题——浮点数精度丢失。
但别急着怪浮点数,让我先把场景完整还原一遍。
我们有个订单表,有个库存表:
-- 订单表
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
user_id INT NOT NULL,
product_id INT NOT NULL,
amount DECIMAL(10, 2) NOT NULL, -- 金额,单位元
status TINYINT DEFAULT 0, -- 0: 待支付 1: 已支付 2: 已取消
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 库存表
CREATE TABLE stock (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_id INT NOT NULL,
total_stock INT NOT NULL DEFAULT 0, -- 总库存
locked_stock INT NOT NULL DEFAULT 0, -- 已锁定库存
available_stock INT AS (total_stock - locked_stock) STORED -- 可用库存
);
看到没,金额用了DECIMAL,这是对的。库存用INT,也没毛病。
但问题出在业务代码里。
第一个坑:你以为开启了事务就万事大吉
很多开发者一遇到问题,第一反应就是:”加事务不就行了?”
@Transactional
public void placeOrder(Long userId, Long productId, int quantity, BigDecimal amount) {
// 1. 扣减库存
stockMapper.lockStock(productId, quantity);
// 2. 创建订单
ordersMapper.insert(order);
// 3. 更新用户余额
balanceMapper.deduct(userId, amount);
}
看着挺完美是吧?三个操作在同一事务里,要么全成功,要么全回滚。
但这里有个隐蔽的bug——隔离级别。
MySQL默认隔离级别是REPEATABLE READ(可重复读),在某些场景下,它帮不了你挡住并发问题。
想象一下这个场景:
时间线:
T1: 用户A查询库存,available_stock = 1
T2: 用户B查询库存,available_stock = 1
T3: 用户A扣减库存,available_stock = 0
T4: 用户B扣减库存,available_stock = -1 ← 超卖了!
在RR隔离级别下,用户A和用户B看到的库存快照都是1,各自都认为还有货,然后各自扣减,导致超卖。
这就是为什么很多老鸟说:“光靠事务隔离级别防不住并发,你得加锁”。
第二个坑:悲观锁的姿势,你可能一直用错了
加锁的方式,我们一般用悲观锁。但悲观锁也有好几种写法,差别很大。
写法一:SELECT FOR UPDATE(最常用)
-- Java代码里
String sql = "SELECT * FROM stock WHERE product_id = ? FOR UPDATE";
Stock stock = jdbcTemplate.queryForObject(sql, rowMapper, productId);
if (stock.getAvailableStock() < quantity) {
throw new RuntimeException("库存不足");
}
stockMapper.lockStock(productId, quantity);
对应的SQL大概是:
UPDATE stock
SET locked_stock = locked_stock + 1,
available_stock = available_stock - 1
WHERE product_id = 1001
AND available_stock >= 1;
这种写法有个问题——锁的粒度。
SELECT FOR UPDATE会锁住整行记录,如果库存表里同一个商品有多条记录(比如分仓库存储),你只锁了其中一条,另一条还是并发访问的。
写法二:SELECT … LOCK IN SHARE MODE
这个是共享锁,允许其他事务读,但不允许写。适合只读场景,但不适合扣库存,因为多个事务可以同时获取共享锁,还是挡不住超卖。
写法三:直接UPDATE,不SELECT
UPDATE stock
SET locked_stock = locked_stock + #{quantity},
available_stock = available_stock - #{quantity}
WHERE product_id = #{productId}
AND available_stock >= #{quantity};
然后查影响行数:
int rows = stockMapper.lockStock(productId, quantity);
if (rows == 0) {
throw new RuntimeException("库存不足");
}
这种写法才是正确的姿势。为什么?
第一,它用数据库自带的行锁,不需要手动SELECT FOR UPDATE,减少了一次查询。
第二,WHERE条件里的available_stock >= #{quantity}是判断库存是否充足的关键。如果库存不足,UPDATE不会执行任何行,影响行数为0。
第三,MySQL的行锁是在UPDATE执行时加的,不是在整个事务期间一直持有。这意味着锁的持有时间更短,并发性能更好。
第三个坑:事务里的”最后一步”最容易出问题
假设你的业务流程是这样的:
@Transactional
public void payOrder(Long orderId) {
Order order = orderMapper.selectById(orderId);
// 1. 验证库存
Stock stock = stockMapper.selectForUpdate(productId);
if (stock.getAvailableStock() < order.getQuantity()) {
throw new RuntimeException("库存不足");
}
// 2. 扣减库存
stockMapper.deductStock(productId, order.getQuantity());
// 3. 更新订单状态
orderMapper.updateStatus(orderId, Status.PAID);
// 4. 调用第三方支付
paymentService.pay(order.getAmount());
// 5. 记录支付日志
paymentLogMapper.insert(paymentLog);
}
看着没问题是吧?但第4步调用了第三方支付,这一步是外部依赖,可能很慢,也可能失败。
如果支付成功,但第5步记录日志失败,事务会回滚。但支付已经在外面完成了,数据库里却回滚了,这就出现了数据不一致。
更糟糕的是,如果第4步支付失败,事务回滚,库存也回滚了,但用户已经下单成功,订单状态变成了”已取消”,库存也恢复了。这时候如果用户重新下单,可能还会遇到刚才说的并发问题。
正确的做法是:把外部调用移出事务。
@Transactional
public void payOrder(Long orderId) {
Order order = orderMapper.selectById(orderId);
// 1. 验证库存(用行锁保证并发安全)
int affected = stockMapper.deductStock(productId, order.getQuantity());
if (affected == 0) {
throw new RuntimeException("库存不足");
}
// 2. 更新订单状态
orderMapper.updateStatus(orderId, Status.PENDING_PAY);
}
// 事务外调用支付
public void handlePayment(Long orderId) {
Order order = orderMapper.selectById(orderId);
boolean paid = paymentService.pay(order.getAmount());
if (paid) {
orderMapper.updateStatus(orderId, Status.PAID);
paymentLogMapper.insert(paymentLog);
} else {
// 支付失败,释放库存
orderMapper.updateStatus(orderId, Status.CANCELLED);
stockMapper.releaseStock(productId, order.getQuantity());
}
}
把事务控制在最小范围,只包含数据库操作,外部调用放在事务外。这样即使外部调用失败,数据库事务已经提交了,不会来回滚。
第四个坑:DECIMAL精度问题,你真的了解吗
回到那3分钱的问题。
假设某商品单价是3.33元,买了3件,总价应该是9.99元。但如果计算过程用了浮点数:
// 错误示范
double price = 3.33;
int quantity = 3;
double total = price * quantity; // 结果可能是 9.989999999999998
然后把这个值存入DECIMAL字段,可能就变成了9.98,差了1分钱。
解决方案:全程用BigDecimal,并且指定精度和舍入模式。
BigDecimal price = new BigDecimal("3.33");
int quantity = 3;
BigDecimal total = price.multiply(BigDecimal.valueOf(quantity))
.setScale(2, RoundingMode.HALF_UP); // 结果:9.99
但更根本的解决方案是:在数据库层面保证精度。
-- 订单明细表,每条记录都是精确的
CREATE TABLE order_items (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
product_id INT NOT NULL,
unit_price DECIMAL(10, 4) NOT NULL, -- 单价,保留4位小数避免中间计算精度丢失
quantity INT NOT NULL,
subtotal DECIMAL(10, 2) NOT NULL, -- 小计 = unit_price * quantity
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
注意unit_price用了4位小数,而subtotal用2位。这样在计算过程中不会丢失精度,最后存入的时候再四舍五入到2位。
在Java代码里,计算小计的时候:
BigDecimal unitPrice = orderItem.getUnitPrice(); // 3.3300
int quantity = orderItem.getQuantity(); // 3
BigDecimal subtotal = unitPrice.multiply(BigDecimal.valueOf(quantity))
.setScale(2, RoundingMode.HALF_UP); // 9.99
第五个坑:分布式场景下的库存问题,本地事务根本不够用
如果你的系统拆成了多个服务,比如订单服务、库存服务、支付服务,各自有独立的数据库,那本地事务就搞不定了。
这时候要用分布式事务,或者用最终一致性的方案。
方案一:Seata AT模式
Seata是一个开源的分布式事务框架,AT模式对业务代码侵入小。
@GlobalTransactional
public void placeOrder(Long userId, Long productId, int quantity) {
// 1. 扣减库存(跨服务调用)
stockService.deductStock(productId, quantity);
// 2. 创建订单
orderService.createOrder(userId, productId, quantity);
// 3. 扣减用户余额
balanceService.deduct(userId, amount);
}
Seata会在三个服务里各开启一个本地事务,然后协调它们一起提交或回滚。
但这种方式有性能开销,不适合高并发场景。
方案二:本地消息表 + 最终一致性
这是更推荐的做法,核心思想是:用本地事务保证操作和消息的原子性,然后通过消息队列保证最终一致性。
@Transactional
public void placeOrder(Long userId, Long productId, int quantity) {
// 1. 扣减库存
stockService.deductStock(productId, quantity);
// 2. 创建订单
Order order = orderService.createOrder(userId, productId, quantity);
// 3. 写入本地消息表(与订单在同一事务)
localMessageMapper.insert(new LocalMessage(
"ORDER_CREATED",
order.getId(),
order.getOrderId()
));
}
本地消息表是一个普通数据库表,和订单表在同一数据库。这样,扣减库存、创建订单、写入消息这三步在一个事务里,要么全成功,要么全失败。
然后有一个定时任务,扫描本地消息表,把消息发到MQ:
@Scheduled(fixedDelay = 5000)
public void sendMessageToMQ() {
List<LocalMessage> messages = localMessageMapper.selectUnsent();
for (LocalMessage message : messages) {
try {
mqProducer.send(message);
localMessageMapper.updateStatus(message.getId(), Status.SENT);
} catch (Exception e) {
// 发送失败,下次重试
log.error("发送消息失败: {}", message, e);
}
}
}
库存服务消费MQ消息,确认订单创建成功后,才正式扣减库存(如果之前只是预扣减的话)。
这种方案的优点是没有分布式事务的开销,缺点是最终一致性,库存可能需要几秒甚至更久才能完全一致。
第六个坑:锁超时和死锁
在高并发场景下,锁争抢是很常见的。如果处理不当,就会出现锁超时或死锁。
锁超时
@Transactional
public void deductStock(Long productId, int quantity) {
// 尝试加锁,设置超时时间
Stock stock = stockMapper.selectForUpdateWithTimeout(productId, 3000);
if (stock == null) {
throw new RuntimeException("系统繁忙,请稍后重试");
}
stockMapper.deductStock(productId, quantity);
}
对应的SQL:
SELECT * FROM stock WHERE product_id = ? FOR UPDATE NOWAIT;
-- 或者
SET innodb_lock_wait_timeout = 3;
SELECT * FROM stock WHERE product_id = ? FOR UPDATE;
死锁
死锁的经典场景是两个事务互相等待对方持有的锁。
事务A: 锁住产品1的库存 → 等待产品2的库存
事务B: 锁住产品2的库存 → 等待产品1的库存
解决死锁的关键是固定加锁顺序。如果所有事务都按照产品ID从小到大的顺序加锁,就不会形成环路:
public void placeOrder(Long userId, List<OrderItem> items) {
// 按product_id排序,保证加锁顺序一致
items.sort(Comparator.comparingLong(OrderItem::getProductId));
for (OrderItem item : items) {
stockMapper.selectForUpdate(item.getProductId());
}
for (OrderItem item : items) {
stockMapper.deductStock(item.getProductId(), item.getQuantity());
}
orderMapper.createOrder(userId, items);
}
第七个坑:对账场景下的3分钱问题
回到最初的问题——库存对不上,差了3分钱。
这种问题通常出现在对账环节。支付平台返回的金额是9.99元,但数据库里记录的是9.98元。差了1分钱。
排查思路:
检查计算逻辑:是否在某些环节用了浮点数?是否在某些环节做了四舍五入?
检查数据库字段类型:金额字段是否用的是DECIMAL?如果是FLOAT或DOUBLE,那就是精度问题。
检查事务边界:是否在事务提交后做了金额修改?
检查分布式调用:是否在某些服务间传输金额时丢失了精度?
这里给你一个实用的对账SQL,帮助快速定位问题:
-- 找出金额不一致的订单
SELECT
o.order_no,
o.amount AS order_amount,
p.amount AS payment_amount,
ABS(o.amount - p.amount) AS diff
FROM orders o
LEFT JOIN payments p ON o.id = p.order_id
WHERE ABS(o.amount - p.amount) > 0.01
ORDER BY diff DESC;
如果找到差1分钱的订单,再查订单明细:
SELECT
order_id,
product_id,
unit_price,
quantity,
subtotal,
(unit_price * quantity) - subtotal AS calc_diff
FROM order_items
WHERE order_id IN (/* 上一步查出的order_id */);
看calc_diff是不是接近0.01,如果是,那就是浮点数计算的问题。
实战:一个完整的扣库存方案
把上面说的坑都避开,给出一个完整的方案:
1. 数据库设计
-- 库存表,使用DECIMAL记录金额(如果需要记录金额的话)
CREATE TABLE stock (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_id INT NOT NULL,
warehouse_id INT NOT NULL,
total_stock INT NOT NULL DEFAULT 0,
locked_stock INT NOT NULL DEFAULT 0,
available_stock INT AS (total_stock - locked_stock) STORED,
version INT NOT NULL DEFAULT 0, -- 乐观锁版本号
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_product_warehouse (product_id, warehouse_id)
);
-- 订单表
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL UNIQUE,
user_id INT NOT NULL,
total_amount DECIMAL(10, 2) NOT NULL,
status TINYINT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 订单明细表,每条记录都记录精确的单价和金额
CREATE TABLE order_items (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
product_id INT NOT NULL,
unit_price DECIMAL(10, 4) NOT NULL, -- 保留4位小数
quantity INT NOT NULL,
subtotal DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_order_id (order_id)
);
-- 本地消息表
CREATE TABLE local_messages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
msg_type VARCHAR(64) NOT NULL,
business_id BIGINT NOT NULL,
content TEXT,
status TINYINT DEFAULT 0, -- 0: 待发送 1: 已发送 2: 已处理
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_status (status, created_at)
);
2. 扣库存的Service
@Service
public class StockService {
@Autowired
private StockMapper stockMapper;
@Autowired
private OrderMapper orderMapper;
@Autowired
private LocalMessageMapper localMessageMapper;
/**
* 下单时预扣减库存
*/
@Transactional(rollbackFor = Exception.class)
public Order placeOrder(Long userId, List<OrderItemDTO> items) {
// 1. 按product_id排序,保证加锁顺序一致,防止死锁
items.sort(Comparator.comparing(OrderItemDTO::getProductId));
// 2. 预扣减库存(使用行锁)
for (OrderItemDTO item : items) {
int affected = stockMapper.preDeductStock(
item.getProductId(),
item.getQuantity()
);
if (affected == 0) {
throw new RuntimeException("库存不足: 商品" + item.getProductId());
}
}
// 3. 创建订单
Order order = new Order();
order.setUserId(userId);
order.setOrderNo(generateOrderNo());
order.setStatus(OrderStatus.PENDING_PAY);
order.setTotalAmount(calculateTotalAmount(items));
orderMapper.insert(order);
// 4. 创建订单明细
for (OrderItemDTO item : items) {
OrderItem orderItem = new OrderItem();
orderItem.setOrderId(order.getId());
orderItem.setProductId(item.getProductId());
orderItem.setUnitPrice(item.getPrice());
orderItem.setQuantity(item.getQuantity());
orderItem.setSubtotal(item.getPrice()
.multiply(BigDecimal.valueOf(item.getQuantity()))
.setScale(2, RoundingMode.HALF_UP));
orderItemMapper.insert(orderItem);
}
// 5. 写入本地消息,用于后续支付回调
LocalMessage message = new LocalMessage();
message.setMsgType("ORDER_CREATED");
message.setBusinessId(order.getId());
message.setContent(JSON.toJSONString(order));
localMessageMapper.insert(message);
return order;
}
/**
* 支付成功后的处理
*/
public void handlePaySuccess(Long orderId) {
// 1. 更新订单状态
orderMapper.updateStatus(orderId, OrderStatus.PAID);
// 2. 更新本地消息状态
localMessageMapper.updateStatusByBusinessId("ORDER_CREATED", orderId, Status.SENT);
// 3. 释放预扣减的库存(如果需要的话,某些场景下预扣减就是正式扣减)
// 这里假设预扣减就是正式扣减,不需要额外操作
}
/**
* 定时任务:发送本地消息到MQ
*/
@Scheduled(fixedDelay = 5000)
public void sendLocalMessages() {
List<LocalMessage> messages = localMessageMapper.selectUnsent();
for (LocalMessage message : messages) {
try {
mqProducer.send(message.getMsgType(), message.getContent());
localMessageMapper.updateStatus(message.getId(), Status.SENT);
} catch (Exception e) {
log.error("发送消息失败: messageId={}", message.getId(), e);
}
}
}
}
3. Mapper接口
@Mapper
public interface StockMapper {
/**
* 预扣减库存,使用行锁保证并发安全
* @return 影响行数,0表示库存不足
*/
@Update("UPDATE stock SET locked_stock = locked_stock + #{quantity} " +
"WHERE product_id = #{productId} " +
"AND available_stock >= #{quantity}")
int preDeductStock(@Param("productId") Long productId, @Param("quantity") int quantity);
/**
* 释放库存(订单取消时)
*/
@Update("UPDATE stock SET locked_stock = locked_stock - #{quantity} " +
"WHERE product_id = #{productId} " +
"AND locked_stock >= #{quantity}")
int releaseStock(@Param("productId") Long productId, @Param("quantity") int quantity);
}
4. 对账工具
@Component
public class ReconciliationJob {
@Autowired
private OrderMapper orderMapper;
@Autowired
private OrderItemMapper orderItemMapper;
@Scheduled(cron = "0 2 0 * * ?") // 每天凌晨2点执行
public void reconcile() {
// 1. 检查订单金额和明细金额是否一致
List<Order> orders = orderMapper.selectUnreconciled();
for (Order order : orders) {
List<OrderItem> items = orderItemMapper.selectByOrderId(order.getId());
BigDecimal totalFromItems = items.stream()
.map(OrderItem::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (totalFromItems.compareTo(order.getTotalAmount()) != 0) {
log.error("订单金额不一致: orderId={}, orderAmount={}, itemsTotal={}",
order.getId(), order.getTotalAmount(), totalFromItems);
// 2. 记录差异到对账差异表
reconciliationMapper.insert(new ReconciliationDiff(
order.getId(),
order.getTotalAmount(),
totalFromItems,
order.getTotalAmount().subtract(totalFromItems)
));
}
}
// 3. 发送告警
if (reconciliationMapper.countTodayDiffs() > 0) {
alertService.sendAlert("对账发现差异,请及时处理");
}
}
}
总结:记住这几条原则
金额字段必须用DECIMAL,不能用FLOAT/DOUBLE。DECIMAL是精确存储,FLOAT/DOUBLE是近似存储,后者会有精度丢失。
事务范围尽量小。把外部调用(支付、短信、日志)移出事务,避免事务持有时间过长。
加锁顺序要固定。防止死锁,所有事务按相同的顺序加锁。
用UPDATE代替SELECT FOR UPDATE。直接UPDATE,通过影响行数判断是否成功,锁的粒度更小,性能更好。
分布式场景用本地消息表。不要用分布式事务,除非业务真的需要强一致性。
定期对账。自动化对账脚本每天跑一遍,发现差异及时告警。
那3分钱的问题,归根结底是精度丢失和并发控制两个问题叠加导致的。只要这两方面处理好了,类似的问题基本不会再出现。
希望这篇指南能帮到你。如果还有问题,欢迎留言讨论。
