小超市收银系统实战项目:避开5个让你加班到凌晨的坑
官方文档往往冗长枯燥,抓不住重点。很多新手在写【小超市收银系统】这个经典【实战项目】时,容易陷入“代码能跑但逻辑全错”的陷阱。今天不讲高深理论,直接拆解我在一线带团队时,见过最频发的5个致命坑。这些坑不解决,你的系统上线三天必崩,维护成本翻倍。
坑一:浮点数精度丢失,收银台变“吞钱机”
现象
这是最让店长崩溃的坑。用户买两瓶水,单价2.5元,总价5.0元,找零0.0元。但在某些极端组合下,比如0.1 + 0.2,程序计算出0.30000000000000004,导致找零少了一分钱,或者数据库里存了5.00000001。
根本原因
计算机底层是二进制,0.1在二进制下是无限循环小数,无法精确存储。IEEE 754标准(浮点数算术标准)决定了这种误差不可避免。官方文档在Python的float文档中明确警告:不要将浮点数用于货币计算。
错误写法 vs 正确写法
❌ 错误:直接使用浮点数
price = 2.5
count = 2
total = price * count
change = 10 - total # 结果可能是 5.000000000000001
print(f"找零: {change}")
✅ 正确:使用Decimal或整数分单位
from decimal import Decimal# 方法一:使用Decimal模块
price = Decimal('2.5')
count = 2
total = price * count
change = Decimal('10') - total
print(f"找零: {change}") # 输出: 找零: 5.0# 方法二:数据库和计算全部用“分”作为单位(推荐)
price_cents = 250 # 2.5元 = 250分
total_cents = price_cents * 2
change_cents = 1000 - total_cents
print(f"找零: {change_cents / 100:.2f}元") # 输出: 找零: 5.00元
复现与修复
在Python中直接运行0.1 + 0.2 == 0.3,结果为False。在Java中,double d = 0.1 + 0.2;打印出来是0.30000000000000004。修复方案只有两条路:要么用语言提供的BigDecimal(Java)、Decimal(Python)、Decimal.js(JS);要么全系统统一用最小货币单位(分)进行整数运算,展示时再除以100。
规避建议
- 数据库设计:金额字段务必使用
DECIMAL(10,2)类型,严禁使用FLOAT或DOUBLE。MySQL官方文档明确指出FLOAT和DOUBLE是近似数值类型,不适合精确计算。 - 前端展示:JavaScript中
0.1 + 0.2同样有问题,建议在计算前将金额转换为整数分,或使用BigNumber.js库。 - 代码规范:团队内约定,所有涉及金额的变量命名必须带
_cents或_fen后缀,从命名上杜绝误用浮点数。
坑二:并发库存扣减,超卖导致库存为负
现象
双11大促或超市打折时,两个用户同时抢购最后一箱牛奶。系统显示库存为1,两人同时点击“购买”,最终库存变成-1,或者一人支付成功但货物已空。
根本原因
典型的“竞态条件”(Race Condition)。读-改-写操作不是原子性的。用户A读到库存1,用户B也读到库存1,A扣减为0并写入,B扣减为0并写入,最终库存为0但卖了2份。
错误写法 vs 正确写法
❌ 错误:先查后改,无锁机制
// Java伪代码
int stock = getStockFromDB(); // 查库存
if (stock > 0) {stock = stock - 1;updateStockToDB(stock); // 更新库存// 此时另一个线程可能也完成了同样的操作
}
✅ 正确:数据库乐观锁或悲观锁
// 方法一:乐观锁(版本号机制)
// SQL: UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = 1 AND version = 1 AND stock > 0;
int affectedRows = dao.updateStockWithVersion(productId, 1);
if (affectedRows == 0) {throw new Exception("库存不足或并发冲突,请重试");
}// 方法二:悲观锁(SELECT FOR UPDATE)
// 事务内执行
transaction.begin();
Product product = dao.lockProduct(productId); // SELECT ... FOR UPDATE
if (product.getStock() <= 0) {transaction.rollback();throw new Exception("库存不足");
}
product.setStock(product.getStock() - 1);
dao.updateProduct(product);
transaction.commit();
复现与修复
在高并发测试工具(如JMeter)下,发送100个并发请求抢购1个库存商品。无锁版本必然出现超卖。修复后,观察数据库日志,确保每次扣减都有对应的版本校验或行锁等待。
规避建议
- 优先使用数据库原子操作:
UPDATE table SET stock = stock - 1 WHERE id = ? AND stock > 0,利用数据库的行锁保证原子性,性能优于应用层加锁。 - 引入Redis预扣减:高并发场景下,先在Redis中扣减库存,再异步落库。Redis单线程模型天然支持原子性
DECR命令。 - 幂等性设计:订单号必须唯一,防止重复支付导致多次扣减。
坑三:支付回调重复,订单状态错乱
现象
微信支付成功后,回调接口被调用两次。第一次更新订单为“已支付”,第二次又更新一次,可能导致积分重复发放、优惠券重复核销。
根本原因
支付平台为了保证可靠性,会多次重试回调。如果服务端没有做幂等处理,每次回调都会执行业务逻辑。
错误写法 vs 正确写法
❌ 错误:直接执行业务逻辑
def handle_wechat_callback(request):order_id = request.data['out_trade_no']order = Order.objects.get(id=order_id)order.status = 'PAID'order.save()# 重复发放积分user = User.objects.get(id=order.user_id)user.points += 10user.save()
✅ 正确:状态机+幂等性校验
def handle_wechat_callback(request):order_id = request.data['out_trade_no']transaction = Transaction.objects.select_for_update().get(order_id=order_id)# 检查当前状态,如果已经是已支付,直接返回成功if transaction.status == 'PAID':return JsonResponse({'code': 'SUCCESS', 'msg': '已处理'})if transaction.status != 'PENDING':return JsonResponse({'code': 'ERROR', 'msg': '状态异常'})# 原子性更新状态transaction.status = 'PAID'transaction.save()# 发放积分(建议在独立事务或消息队列中处理,保证最终一致性)# ...
复现与修复
手动多次调用回调接口,或使用Postman模拟重试。观察数据库,看points字段是否增加了多次。修复后,无论调用多少次,积分只增加一次。
规避建议
- 状态机严格管控:定义清晰的状态流转图,只有从
PENDING到PAID才执行业务逻辑,其他状态直接拦截。 - 唯一索引防重:在支付流水表中,对
transaction_id建立唯一索引,利用数据库唯一约束兜底。 - 异步化非核心业务:发积分、发短信等非核心逻辑,放入消息队列(如RabbitMQ、Kafka),避免阻塞回调响应,同时通过消费端幂等保证只执行一次。
坑四:长事务锁表,系统整体卡顿
现象
收银台偶尔卡死,其他用户无法结账。查看监控发现,数据库连接池耗尽,大量连接处于“等待锁”状态。
根本原因
在事务中执行了耗时操作,如调用第三方API(短信、物流)、复杂的循环计算、或大量数据插入。导致行锁或表锁长时间不释放,阻塞其他事务。
错误写法 vs 正确写法
❌ 错误:事务中包含远程调用
@Transactional
public void createOrder(OrderDTO dto) {Order order = saveOrder(dto); // 1. 插入订单// 2. 调用第三方接口发送短信(耗时500ms-2s)smsService.sendSms(order.getUserPhone());// 3. 更新库存(此时锁一直持有)stockService.decrease(order.getProductId());
}
✅ 正确:拆分事务,远程调用移出事务
public void createOrder(OrderDTO dto) {// 1. 短事务:插入订单 + 更新库存Order order = transactionTemplate.execute(status -> {Order newOrder = saveOrder(dto);stockService.decrease(newOrder.getProductId());return newOrder;});// 2. 事务外:发送短信(失败可重试,不影响主流程)try {smsService.sendSms(order.getUserPhone());} catch (Exception e) {log.error("短信发送失败", e);// 记录失败日志,后续补偿}
}
复现与修复
在创建订单接口中,故意让短信接口sleep 5秒。观察数据库SHOW PROCESSLIST,会发现该连接长时间持有锁。修复后,锁持有时间降至毫秒级。
规避建议
- 事务粒度最小化:事务内只做数据库CRUD,严禁包含RPC调用、HTTP请求、复杂计算。
- 设置事务超时:在Spring中配置
timeout,如@Transactional(timeout = 3),防止意外长时间持有锁。 - 监控慢事务:配置数据库慢查询日志,阈值设为500ms,定期分析长事务原因。
坑五:日志缺失,问题排查靠猜
现象
用户投诉“支付成功但没收到货”,开发查了半天日志,发现只有“请求进入”,没有“订单创建”、“库存扣减”、“支付回调”的关键节点日志,最终靠猜代码逻辑定位问题,耗时半天。
根本原因
日志打印随意,缺乏关键业务节点的TraceId,日志级别滥用,敏感信息明文打印。
错误写法 vs 正确写法
❌ 错误:无TraceId,日志碎片化
print("User login")
print("Order created")
print("Payment success")
# 无法关联同一笔业务的所有日志
✅ 正确:结构化日志+TraceId
import logging
import uuidlogger = logging.getLogger(__name__)def process_order(order_id):trace_id = str(uuid.uuid4())# 将trace_id放入上下文或MDClogger.info("Order Processing Start", extra={"trace_id": trace_id, "order_id": order_id})try:# ... 业务逻辑 ...logger.info("Stock Deducted", extra={"trace_id": trace_id, "product_id": 101})logger.info("Payment Confirmed", extra={"trace_id": trace_id})except Exception as e:logger.error("Order Processing Failed", exc_info=True, extra={"trace_id": trace_id})raise
复现与修复
在测试环境故意制造异常,观察日志是否能通过TraceId串联起整个请求链路。使用ELK(Elasticsearch, Logstash, Kibana)或Loki进行日志聚合查询。
规避建议
- 强制TraceId:网关层生成TraceId,透传到所有微服务,所有日志必须包含TraceId。
- 关键节点必打日志:订单创建、支付回调、库存变动、异常抛出,这四个节点必须有INFO或ERROR级别日志。
- 敏感信息脱敏:手机号、身份证号、卡号在日志中必须脱敏,如
138****1234,遵守《个人信息保护法》。
总结与互动
以上5个坑,覆盖了【小超市收银系统】这个【实战项目】中最常见的资金安全、并发、性能、可维护性问题。官方文档虽然权威,但往往缺乏场景化的避坑指南。真正的经验,来自无数次生产事故的复盘。
你公司项目里是怎么处理浮点数精度和并发库存的?是直接用BigDecimal还是自己封装了工具类?欢迎在评论区分享你的实战经验,咱们一起避坑。