第五天了。项目代号“智慧商城”从接到需求到现在我们已经从前期的技术选型和框架搭建真正走进了核心业务里来。今天的任务非常明确把商品数据、购物车和订单下单这条主流程串起来至少让用户从浏览商品到生成订单这一整条链路不再“断在半路”。如果你也在从零搭一个商城系统或者在公司内部负责电商类平台的开发这份笔记里的很多场景你应该会很熟悉并发下单时库存怎么控、状态字段怎么设计才不容易被业务改死、前后端联调时跨域问题为什么总是冒出来。这五天踩过的坑很可能就是你接下来马上要遇到的坑。目前的进度是基础工程框架、注册中心、网关、公共模块和商品服务的初始版本都已经就绪。前四天主要是在“搭骨架”从今天开始真正用用户视角去推动业务闭环。这也是我坚持把项目笔记写到第五天的原因——前面那些都是准备今天才是商城的第一次“能走起来”。1. 智慧商城第五天先复盘整体设计再动手1.1 前四天搭了什么第五天为什么要主攻下单链路前四天做的事情简单说就是三件事第一把微服务工程骨架立起来服务拆了商品、订单、用户、支付四个基础服务统一接入注册中心网关做了路由和初步的鉴权过滤第二把数据库脚本和公共代码模块整理完包括统一返回结果、异常处理、分布式ID生成方式第三把商品服务的增删改查接口写完了商品列表和详情能够在页面上正常展示和检索。这些工作看着基础但越往后越发现真正决定项目能不能落地下去的不是第一天能写多少接口而是边界划分和契约约定。到第五天商品查询已经是“读”的事情购物车在Redis里也只是临时数据只有订单产生才意味着资金流和货物流真正开始流转。所以今天的重心毫无悬念地落在下单链路用户选中商品、提交订单、扣减库存、生成订单记录这条链路走通智慧商城才算真正有了交易属性。1.2 模块划分的取舍不盲目追求“万物皆服务”在设计阶段我就想过要不要把库存拆成独立服务。很多成熟的电商系统确实会单独维护库存中心因为库存要支撑采购、仓储、多渠道销售、锁单、售罄通知这些复杂动作。但对一个第五天还在打通主流程的项目来说独立的库存服务意味着一次下单要跨两个事务边界库存扣减和订单生成一旦分布在不同服务里就需要处理分布式事务、最终一致性、消息重试等问题。这些技术本身没有问题但在当前阶段会直接拖慢核心闭环的交付。所以我的决定是这一个阶段库存数据仍然放在独立的库存表里但扣减库存的数据库操作放在订单服务的事务中执行。商品服务只负责维护商品基本信息和可售状态库存额度由订单服务在提交订单时直接操作。这样做的好处非常实际下单操作要么全部成功要么全部回滚不需要引入额外的消息中间件或者分布式事务框架。等到并发量真正上来、供应链体系变得复杂之后再把库存抽离成独立服务这是演进式设计而不是第一天就要把所有架构重量都背在身上。这个选择也让我反思了一件事技术选型不是越复杂越好而是越契合当前阶段目标越好。商城项目里所有模块边界都应该围绕“用户能下单”这个最小闭环来推进边界拆得越碎联调成本就越高。第五天其实已经出现端倪同样的字段定义商品服务和订单服务各自理解不一样就会引发数据对不上的问题。2. 核心细节订单状态机与库表设计2.1 订单状态机不能拍脑袋定义订单状态机是整个订单模块最核心的规则如果这里设计得乱后面接支付、发货、退款、售后时会非常痛苦。我第一版设计时没有认真想只是随手定义了“未支付、已支付、已取消、已完成”几个状态结果写支付回调业务时发现少了一个“已关闭”写退款功能时又发现漏了“退款中”的状态。所谓“状态机”其实就是先穷举业务里会出现哪些状态再定义这些状态之间允许发生哪些跳转。智慧商城第五天最终确定的状态如下待支付、已支付、已发货、已完成、已关闭、退款处理中、已退款。其中待支付可以直接到已支付也可以超时到已关闭已支付之后可以发货也可以发起退款已发货只能到已完成或者退款处理中退款处理中最终到已退款。下面这一段是订单状态的枚举定义我刻意在code里用了10、20、30这种间隔比较大的数字避免以后在中间插入新状态时要把所有枚举值推倒重来。public enum OrderStatusEnum { WAIT_PAY(10, 待支付), PAID(20, 已支付), SHIPPED(30, 已发货), COMPLETED(40, 已完成), CLOSED(50, 已关闭), REFUNDING(60, 退款处理中), REFUNDED(70, 已退款); private final Integer code; private final String desc; // 构造方法、getter 略 }实际开发中要特别注意状态字段不要用0、1、2这种没有任何业务含义的数字也不要直接存中文描述。代码里写死了数字三个月后再回来看根本不知道它代表什么。用语义化的枚举code配合注释排查问题时会轻松很多。2.2 订单表字段设计金额精度和状态流转审计字段订单表的核心字段我列在下面这张表里这些字段基本经历过多次打磨每删掉一个字段可能都会在后续某个功能里后悔。一开始我连address_snapshot都嫌冗余觉得地址信息可以从用户服务查出来后来才意识到订单生成之后用户地址可能修改快递公司需要的永远是“下单那一刻的收货地址”所以订单表里必须保留一份收货地址快照。字段名类型说明idbigint主键order_snvarchar(64)业务订单号唯一索引user_idbigint下单用户IDorder_statustinyint订单状态枚举codetotal_amountdecimal(12,2)商品总金额discount_amountdecimal(12,2)优惠金额pay_amountdecimal(12,2)实付金额address_snapshotvarchar(500)收货地址快照payment_timedatetime支付时间close_timedatetime关闭时间finish_timedatetime完成时间created_atdatetime创建时间updated_atdatetime更新时间金额字段必须用decimal而不是浮点类型这是电商领域的老生常谈。浮点数在计算机中本身就是近似值0.1加0.2都不精确金额稍微累加就会产生一分钱的误差。我见过有人用double存金额结果对账时差了零点几元排查了整整半天。数据库层面用decimal(12,2)Java层面对应BigDecimal传递和计算都使用字符串方式精度就不会丢。还要强调一个容易被忽视的设计订单表不要只放created_at和updated_at最好把关键流转动作的时间字段单独拎出来比如payment_time、close_time、finish_time。理由是后续做超时关单、售后时限、财务对账时都需要精准知道某个状态发生的时间点如果只保留一个“最后更新时间”很多分析SQL根本写不出来。2.3 冗余明细表与库存表设计订单明细表t_order_item和订单主表是一对多关系字段包括order_sn、product_id、product_name、product_image、price、quantity、total_amount。为什么商品名称、图片这些信息要在明细表里冗余一份因为商品信息可能被运营修改比如商品改名、换图、调价已经产生的历史订单不能被这些修改影响所以下单那一刻的商品快照必须记录下来。这与地址快照的思路完全一致。库存表我单独建了一张t_product_stock表结构非常简单product_id、stock、version。以前我习惯直接把库存数量写在商品表里后来发现库存的写入频率和商品基本信息完全不同商品名称可能一个月不改一次库存每秒钟都可能被扣减如果放在同一张表里锁竞争和热点更新问题会非常明显。单独拆表还有一个好处未来如果要做锁定库存、预占库存、维护安全库存都可以在这张表上继续扩展字段而不用动商品主表。3. 实操过程从加购到支付前下单链路实现3.1 提交订单接口的完整流程前端提交订单时会传三块信息商品列表、收货地址ID、买家备注。后端拿到这些参数后需要完成下面这一串操作校验商品是否在售、批量查询商品信息、计算商品金额和实付金额、生成订单号、扣减库存、插入订单主表和明细表、异步清空购物车、发送订单创建事件。这里最关键的一点是校验商品和查商品信息一定要用批量查询不要循环单查数据库。比如用户提交了10个商品你循环查10次数据库性能损耗大不说还会增加数据库连接压力。批量查一次内存里组装成Map按productId取用代码也更清晰。下面这段是服务层的大致骨架我略去了很多防御性检查重点展示下单主流程的先后顺序。注意方法上标注了Transactional因为库存扣减和订单插入必须处于同一个数据库事务中只要任何一步失败整个订单都不能落库。Transactional(rollbackFor Exception.class) public OrderSubmitResult submit(OrderSubmitDTO dto) { // 1. 检查幂等避免重复提交 checkIdempotent(dto); // 2. 批量查询商品信息 ListProduct products productMapper.selectBatchByIds(dto.getProductIds()); // 3. 计算金额生成订单号 BigDecimal totalAmount calculateAmount(products, dto); String orderSn orderNumberGenerator.generate(); // 4. 扣减库存库存不足则抛出业务异常事务回滚 boolean stockResult stockMapper.deductStock(dto.getProductStockList()); if (!stockResult) { throw new BizException(库存不足); } // 5. 插入订单主表和明细表 orderMapper.insert(OrderDO.buildNewOrder(...)); orderItemMapper.batchInsert(orderItemList); // 6. 释放幂等标识异步清理购物车 return OrderSubmitResult.success(orderSn); }3.2 并发超卖问题为什么扣库存必须用条件更新第五天联调时我专门用并发脚本压了一下提交订单接口目的就是验证超卖问题是否被挡住。超卖的本质是“查询到的库存和实际扣减时的库存不一致”两个请求同时读到库存还有10件都判断可以扣减结果都下单成功账面库存可能变成负数。解决直觉上最容易想到的办法是先查再判断但查询和更新之间有时间窗口并发场景下仍然会被穿透。真正可靠的做法是把库存的判断放在UPDATE语句里用条件更新保证原子性核心SQL如下UPDATE t_product_stock SET stock stock - #{count}, version version 1 WHERE product_id #{productId} AND stock #{count}这条SQL的巧妙之处在于“库存是否足够”的判断和“库存扣减”是在数据库层面原子完成的。MySQL执行UPDATE时会对命中的行加锁其他事务必须等待当前事务提交或回滚后才能继续操作所以不会出现两个请求同时通过判断的情况。Java代码只需要检查update的影响行数影响行数是1说明扣减成功是0说明库存不足直接抛出业务异常。我用50个并发线程模拟下单每个请求购买2件商品总库存只有20件最终结果非常干净10个请求成功40个请求返回“库存不足”。这里补充一句乐观锁控制在库存充足的普通商品场景下非常合适性能和准确性都能兼顾。但如果是秒杀这种极端流量场景更常见的是在库存服务层加分布式锁甚至用Redis预扣库存。第五天还是先把乐观锁方案做扎实秒杀机制属于后续优化的专项问题不建议在打通主流程的阶段就上重型方案。3.3 前后端联调与跨域问题处理接口写好之后前端同事开始联调页面。测试过程中第一个拦路虎就是跨域问题。当前端页面跑在http://localhost:5173后端服务跑在http://localhost:8080浏览器会认为这是两个不同源默认情况下POST请求会直接失败。解决方案有两类一类是后端开启CORS配置一类是前端搭建代理转发。我的建议是两者结合本地联调时前端走Vite代理因为代理方案最贴近生产部署形态如果需要直接暴露后端服务地址再配合后端的CORS配置兜底。下面这段是一个基础的CORS配置类注意allowedOriginPatterns不要直接写死某个端口否则换一个端口联调就要改代码。allowCredentials(true)表示允许携带Cookie这个配置在处理用户会话登录时非常关键。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(http://localhost:*, http://127.0.0.1:*) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true) .maxAge(3600); } }如果项目网关也做了跨域配置就要特别小心重复设置响应头。我见过的情况是网关加了一次跨域头下游服务又加了一次浏览器直接报多个Access-Control-Allow-Origin错误。所以跨域配置最好只在一个入口统一处理建议放在网关层下游服务就不需要再配置了。4. 常见问题与排查技巧实录4.1 事务失效方法内部调用把事务“吞了”联调过程中遇到一个典型的诡异问题下单接口在扣库存成功之后插入订单明细时故意抛了一个异常结果库存竟然没有回滚。第一反应是检查事务注解确认Transactional已经在方法上但问题依然存在。后来排查发现我把扣库存逻辑抽到了OrderServiceImpl的另一个私有方法里然后在submit方法里直接通过this.deductStock()调用。这种调用方式不会经过Spring的事务代理所以事务注解根本不会生效。Spring事务是基于AOP动态代理实现的只有外部调用经过代理对象时事务切面才会被织入。同一个类的内部方法调用走的是this对象代理逻辑没有机会介入事务自然就失效了。解决办法有两种一种是把扣库存逻辑抽到独立的Service Bean里通过Spring注入后调用另一种是使用TransactionTemplate编程式事务把需要原子性保护的代码块手动包裹起来。不管哪种方式都要记住事务边界和代码的物理归属不是一回事别把内部方法调用当成理所当然。排查这个问题的过程中我还总结出一个快速定位技巧。开启MyBatis的SQL日志后正常的事务开启和提交会看到类似Creating a new SqlSession、Registering transaction synchronization的日志。如果一个事务方法里执行了两条SQL却只看到一条事务同步日志基本可以断定事务边界没有覆盖全部操作。4.2 订单号重复与重复提交幂等联调页面时发现用户点击“提交订单”按钮后因为后端响应稍微慢了一点前端做了重试结果同一个用户生成了两笔一模一样的订单。这暴露了两个问题一是前端按钮没有做防重复提交处理二是后端缺少幂等机制。前端解决这个问题通常是在点击后立即禁用按钮但网络重试、超时重发这类场景前端无法完全控制所以后端必须做幂等兜底。我的做法是在下单接口入口位置增加幂等校验。前端提交订单时请求头带一个由用户ID、商品摘要和时间戳生成的Idempotent-Key后端先查询一张幂等记录表存在就直接返回上一次处理结果不存在才继续执行下单逻辑。幂等校验的核心原则是“校验必须在业务处理之前”而且幂等表的主键要设置唯一索引利用数据库的唯一约束配合重复插入的异常捕获确保高并发下也不会漏掉重复请求。// 幂等记录表插入重复插入会触发唯一约束异常 try { idempotentMapper.insert(new IdempotentRecord(key, orderSn, now)); } catch (DuplicateKeyException e) { OrderDO existOrder orderMapper.selectByOrderSn(existOrderSn); return OrderSubmitResult.success(existOrder.getOrderSn()); }4.3 超时未支付订单的处理方案选择订单生成后如果用户在30分钟内没有完成支付系统需要自动关闭订单并把扣减的库存回补回去。这个功能初看很简单一个定时任务扫描就行了但真的实现起来还是有几个细节容易踩坑。最简单的方案是在订单服务里加一个定时任务每隔30秒扫描一次待支付订单把创建时间超过30分钟的订单批量改成已关闭同时回补库存。Scheduled(fixedDelay 30000) public void closeExpiredOrders() { ListOrderDO expiredOrders orderMapper.selectWaitPayOlderThan(now.minusMinutes(30)); for (OrderDO order : expiredOrders) { int updated orderMapper.compareAndSetStatus( order.getId(), OrderStatusEnum.WAIT_PAY.getCode(), OrderStatusEnum.CLOSED.getCode()); if (updated 0) { stockMapper.restoreStock(order.getOrderItems()); } } }这里最关键的一行是compareAndSetStatus也就是通过UPDATE ... WHERE id ? AND order_status 待支付来更新状态。为什么要这样因为订单可能刚好在这一秒被用户支付了如果定时任务无脑把状态改成已关闭就会出现“用户已支付但订单显示关闭”的严重资损事故。只有通过条件更新检查状态确保只有待支付订单可以被关闭才能避免和支付回调产生竞争。定时任务方案当然不是唯一选择更精细的方案是把订单号发到消息队列的延迟队列里30分钟后消费消息检查订单状态。两种方案各有取舍定时任务实现简单、依赖少但扫描存在一定延迟而且全表扫描在高订单量下性能堪忧延迟队列精准度高但需要依赖中间件。第五天的体量下定时任务完全够用我先把功能跑通未来订单量大之后再切换到延迟消息方案。4.4 缓存穿透不存在的商品频繁打到数据库商品详情页联调时测试人员输入一个不存在的商品ID后端每次都会查一次数据库返回空数据。正常情况下这没有问题但如果被人为恶意调用大量不存在的ID数据库压力会瞬间飙升这就是经典的缓存穿透。解决办法很多人知道是“缓存空值”但真正实现起来有几个细节需要留意。当我发现数据库查出来的商品为null时会在Redis里缓存一个空对象过期时间设置得很短比如60秒。这样后续相同ID的请求会直接命中缓存的空值而不会继续穿透到数据库。同时为了防患于未然我还做了一层很简单的参数校验对明显不合理的请求直接拒绝返回参数错误。这个方案虽然简单但对于一个正在进行第五天的项目来说已经能够挡住绝大部分风险。缓存动手时还要注意一个坑不要把所有的商品都设置同一个过期时间。比如把所有商品缓存都设置为30分钟过期那30分钟那一瞬间会发生缓存雪崩大量请求同时回源到数据库。合理的做法是给过期时间加一个随机扰动比如30分钟加上0到5分钟的随机值让缓存过期时间点分散开。这是缓存设计里非常实用的小技巧。写在第五天结束时的话下单链路跑通之后我站在用户视角重新体验了一整遍流程搜索商品、查看详情、加入购物车、提交订单、收到订单创建成功通知。整个过程看起来简单但背后涉及商品快照、库存原子扣减、状态机流转、幂等控制、超时关单这些环环相扣的设计任何一个环节疏忽都会在后续的支付、发货、对账中放大成问题。这一天的笔记到这里已经远远超出了写代码的范畴。我个人最大的体会是智慧商城这类项目真正考验脑力的不是页面布局有多少新意而是数据一致性和状态流转的严谨程度。下单时你多扣了一次库存后面可能要对账很久才能发现少关闭了一笔超时订单就可能面临库存被长期白白占用。这些细节只有在完整走完一次链路后才会懂。最后再分享一个实用的收尾建议。如果你也在做类似的商城项目第五天这个节点除了跑通功能之外一定要留出时间把接口文档和数据字典完整整理一遍。前四天各服务之间自己写自己的今天联调时已经出现了字段含义不一致、状态值对不上的情况。把这些契约用文档固定下来后面的支付模块、营销模块、售后模块接入时会帮你省下大把无谓的沟通成本。