
这段时间一直在折腾一个生鲜商城系统从需求梳理、数据库设计到前后端联调算是完整走了一遍。技术栈很常规Java 8 Spring Boot 2.x 做后端Vue 2 Element UI 做前端数据库用 MySQL缓存用 Redis。很多人问我为什么要写这个项目生鲜商城和普通电商到底有什么不一样最大的差异在于商品的时效性、库存准确性和配送时窗。普通商品晚一天发货没什么问题生鲜晚半小时可能就化冻了普通商品库存对不上还能补救生鲜缺货直接取消订单。所以这个系统的核心不只是简单的 CRUD而是围绕“鲜”字做了一系列针对性的设计和优化。这篇文章会把整个系统的设计与实现过程拆开讲包括技术选型为什么这么定、数据库表怎么设计、哪些接口需要重点关注、前端页面怎么组织以及我在实际开发中遇到的几个典型坑和解决办法。如果你正在做类似的全栈项目或者准备自己从零写一套带订单、库存、营销的商城系统这篇内容应该能帮你省掉不少试错时间。1. 项目整体设计与技术选型1.1 需求拆解先把角色和业务边界搞清楚生鲜商城系统看着复杂其实抽掉业务细节后核心就是“人、货、场”三个字。我把需求拆成三类角色C端用户、商家运营、系统管理员。用户端要能浏览商品、加购物车、下单支付、查看订单和售后商家端要维护商品信息、库存、价格、活动还要处理出库和发货管理端则负责用户管理、类目管理、数据统计、基础配置。这个三角色模型是几乎所有商城系统的通用骨架。但生鲜业务有几个特殊点必须在设计阶段就定清楚保质期与批次管理同一种蔬菜早上的批次和下午的批次入库时间和剩余保质期可能不同。如果不做批次管理后续的临期商品处理、损耗统计就无从谈起。库存的实时性生鲜库存波动大用户下单后可能还要进行称重、拣货实际出库数量和数据库中的库存可能不一致。所以库存记录不能只搞一个总数还得结合锁定库存、可用库存来设计。配送时窗用户希望预约次日某个时间段送达系统必须把订单和配送时间段挂钩否则冷链物流没法排线。促销活动的可重叠性生鲜的日常促销非常频繁比如满减、第二件半价、限时秒杀往往叠加使用。商品表里写死一个价格的方案完全不可行必须设计独立的促销模块。需求理清之后我就没有急着写代码而是先画了模块图和用例图把这三类角色的核心流程走了一遍。这一步很重要前端页面展示、后端接口拆分、数据库表结构都是从这里推导出来的。1.2 技术栈选型背后的取舍后端选 Spring Boot基本没有悬念。Java 生态成熟Spring Boot 的自动配置让项目搭建非常快同时它对事务管理、定时任务、消息监控这些电商高频需求的支撑都很完善。我没有用 Spring Cloud因为单机版的 Spring Boot 完全能支撑这个体量的系统演示和中小型业务落地微服务反而会给项目部署带来不必要的复杂度。ORM 框架我在 MyBatis 和 MyBatis-Plus 之间犹豫过最后选了 MyBatis-Plus。原因很简单单表 CRUD 可以用内置方法直接搞定省掉大量写 XML 的时间复杂的多表查询、报表统计再用自定义 SQL灵活性和开发效率兼顾。前端选 Vue是因为它的响应式数据绑定和组件化开发非常适合商城这种交互频繁、界面复用的场景。用 Vue 2 Element UI 组合界面基本不用从零写表格、表单、分页、弹窗都是现成的能让我把精力集中在业务逻辑上。配合 Vue Router 做页面路由、Vuex 做全局状态存储登录态、购物车数量等前端的整体架构可以支撑多个页面共享同一份数据。数据库锁死 MySQL是因为生鲜商城对事务要求极高下单必须保证库存扣减和订单生成原子性。MySQL 的 InnoDB 引擎和事务隔离级别能满足这套需求同时它对部署环境的要求最低一台普通服务器就能跑非常适合演示和后续扩展。Redis 负责两件事一是存验证码、临时订单状态这类短生命周期数据二是做库存扣减的原子操作防止超卖。这一点后面会详细说。1.3 模块划分与整体架构后端我按业务域分包没有用传统的 controller/service/mapper 三层包结构硬套因为那样到后期类会多到难以维护。实际结构是这样com.example.freshmall ├── common // 通用响应体、异常处理、工具类 ├── config // 全局配置如拦截器、跨域、Redis序列化 ├── controller // REST接口层 ├── service // 业务逻辑接口和实现 ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体类 ├── dto // 前端参数对象、返回对象 ├── vo // 视图对象 └── task // 定时任务前端同样按业务模块拆分而不是全部写在views目录下src ├── api // 所有接口请求封装 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由表 ├── store // Vuex状态 ├── views │ ├── home │ ├── product │ ├── cart │ ├── order │ ├── user │ └── admin前后端通过 RESTful 接口通信统一返回结构是{ code: 200, message: success, data: {} }这样前端可以按 code 统一处理成功和失败不需要每个页面单独去判断异常。接口鉴权用了 JWT用户登录成功后后端签发 token前端把 token 存在 localStorage 里每次请求在拦截器里放到请求头后端拦截器统一校验。这种方案无状态、易扩展也符合商城场景。2. 数据库设计与核心表结构2.1 商品、类目与 SKU 的拆分很多新手做商城系统会把“商品”和“SKU”混在一起直接在商品表里加一个价格字段。这在实际业务里会出大问题尤其是生鲜。比如一颗白菜可能按份卖一份 500 克价格 3.9 元同一个商品也可以按 1 千克的规格卖价格 7.8 元。这就属于一个商品多个 SKU每个 SKU 都有自己的价格、库存、图片和条码。我把表结构分成三层category类目表包含 id、父级 id、名称、排序、状态。用父级 id 自关联实现多级类目。product商品表包含 id、类目 id、商品名称、商品主图、描述、上下架状态、创建时间。不存价格和库存。sku商品规格表包含 id、商品 id、规格名称如“500g/份”、原价、售价、库存、锁定库存、单位、状态。为什么要单独拆出 SKU 表因为购物车、订单明细、促销活动都需要精确到某一个具体规格的商品。把价格和库存放到 SKU 上下单时才能准确锁定库存和计算金额。这里要注意一个细节生鲜类的商品经常会有“每日上新”的概念同一种蔬菜可能每一天的批次不一样。为了兼顾演示的简单性我在实际项目中用商品 id 加生产批次字段来处理而不是引入复杂的批次库存表。如果需要精确做保质期管理可以在 SKU 表上加batch_no、production_date、expire_date字段但一旦加了批次库存扣减逻辑就得按批次先进先出复杂度会明显上升。2.2 购物车、订单和订单明细购物车表表面简单但有一个坑用户登录状态下的购物车需要与库存联动。我设计了cart表和cart_item表cart记录用户 id 和总数量cart_item记录具体的 SKU、数量、选中状态。这样即使用户把商品删了也能在详情页展示“已失效商品”列表。订单表是核心中的核心。我的设计中订单主表和订单明细表是分开的orders订单主表订单号、用户 id、总金额、优惠金额、实付金额、支付方式、支付状态、订单状态、收货人信息、配送时窗、自提点 id、备注、创建时间、支付时间、发货时间。order_item订单明细表订单号、SKU id、商品名、商品图片、单价、数量、小计金额。订单号和主键 id 必须分开。主键自增对内方便关联查询但对外不能暴露防止别人通过遍历 id 拿到所有订单数据。订单号我用“日期 随机数”生成比如20250607103001001既方便人工识别也方便后续按时间段搜索。订单状态我一开始只用了 0-未支付、1-已支付、2-已发货、3-已签收、4-已取消后来发现生鲜业务还需要处理“缺货取消”和“售后中”所以增加了一个status_code和cancel_reason。状态流转不是简单的四个方向而是有限状态机这个我在后面专门讲。2.3 配送时窗与自提点生鲜配送最怕“送达时间不明确”。我在订单表里直接存了delivery_date配送日期和delivery_time_slot时间段用户下单时必须选择一个时窗比如“09:00-11:00”“11:00-13:00”。同时维护了一张time_slot配置表用于商家配置每天可用的时间段和每个时段的最大容量。前端递归遍历时可能造成数据冗余设计时要注意。我实际开发中踩过坑配送时窗如果直接硬编码在订单表里后期商家想调整时间段就麻烦。最好在配置表里维护 id、时段名称、开始时间、结束时间、是否启用、最大订单数。下单时根据当前日期判断时段有没有满满了就不能选。自提点也是生鲜电商常见的场景尤其在疫情期间社区团购非常火。我的设计是把自提点作为配送方式的一种订单可以选择“快递到家”或“社区自提”自提点表存位置、联系人、营业时间。自提模式下不需要配送时窗只需要选择自提日期即可。配送这块我没做复杂的路线规划只做了时窗容量校验和按小区简单分组。如果要把系统做到生产级别还需要引入地图服务做范围校验和骑手路径分配这个可以在扩展阶段考虑。2.4 促销活动表设计生鲜商城要留住用户促销是躲不开的。我的活动表设计分为三层promotion活动主表活动名称、活动类型满减、折扣、秒杀、开始时间、结束时间、状态。promotion_rule活动规则表比如满 50 减 10、打 8.8 折、第 2 件半价这些规则与具体的 SKU 关联。promotion_sku活动商品关联表某个活动包含哪些 SKU以及参与活动的最低数量。为什么不直接在 SKU 表上存“促销价”因为同一种商品在同一个时间段可能参与多个活动而且活动价格需要随活动开始和结束动态切换。如果把促销价写死在商品表里活动结束时还得定时任务改回来非常容易出 bug。用关联表方式下单时根据活动优先级计算最终价格活动结束后自然失效不需要人工恢复。秒杀场景下我额外设计了一张seckill_activity表专门记录活动商品、秒杀价和秒杀库存。秒杀库存与普通库存分开防止秒杀时把正常库存全部冲掉也方便秒杀结束后回补剩余库存。3. Spring Boot 后端实现要点3.1 项目初始化与公共配置创建 Spring Boot 项目时我习惯用 Maven 管理依赖核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependencyapplication.yml里比较关键的是数据库连接池和 Redis 连接另外需要配置 MyBatis-Plus 的逻辑删除和雪花 id。spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/fresh_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 redis: host: localhost port: 6379 database: 0 mybatis-plus: global-config: db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0HikariCP 连接池的参数别乱调maximum-pool-size20对这套系统足够了调太大反而会增加数据库压力。MySQL 的serverTimezone必须写不然本地时区和数据库时区不一致会导致时间字段错乱。启动类上启动MapperScan扫描 mapper 包避免每个 Mapper 加注解SpringBootApplication MapperScan(com.example.freshmall.mapper) public class FreshMallApplication { public static void main(String[] args) { SpringApplication.run(FreshMallApplication.class, args); } }3.2 用户认证与 JWT 拦截器登录接口我设计了手机号验证码的方式这是商城系统的标配也避开了密码安全存储的很多烦恼。但在演示环境里验证码需要短信服务我没有对接第三方而是直接在 Redis 里存验证码然后把验证码返回给前端方便测试。用户表user核心字段id、手机号、昵称、头像、性别、状态、注册时间。JWT 工具类负责生成和解析 tokenpublic String createToken(Long userId, String phone) { long now System.currentTimeMillis(); Date exp new Date(now TOKEN_EXPIRE); return Jwts.builder() .setSubject(phone) .claim(userId, userId) .setIssuedAt(new Date(now)) .setExpiration(exp) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); }拦截器实现HandlerInterceptor对所有/api/**请求进行 token 校验校验通过后把 userId 放入requestattribute方便 Controller 拿到当前登录用户。同时实现一个WebMvcConfigurer注册拦截器和跨域配置Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/user/login, /api/user/sendCode, /api/product/**, /api/category/**); }跨域问题是前端联调时第一个遇到的大坑必须在后端配置允许跨域否则浏览器会直接拦截所有请求。3.3 商品列表与搜索接口商品列表接口要支持多条件查询类目 id、关键字、价格区间、上下架状态、排序方式按销量、按价格、按上架时间。我在 Controller 层用 DTO 接收参数然后用 MyBatis-Plus 的 LambdaQueryWrapper 动态构建查询条件public PageResultProductVO list(ProductQueryDTO dto) { LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); wrapper.eq(dto.getCategoryId() ! null, Product::getCategoryId, dto.getCategoryId()) .like(StringUtils.hasText(dto.getKeyword()), Product::getName, dto.getKeyword()) .eq(Product::getStatus, 1) .orderByDesc(dto.getSort() 1 ? Product::getCreateTime : Product::getSales); // 查询SKU、销量等信息 }商品列表返回的ProductVO中需要包含最低价格、库存总和、销量这些冗余数据。如果每查一次列表都去子表 count 一次数据库压力会很大。我的做法是在product表里冗余min_price和total_sales字段每次 SKU 变更或订单完成时同步更新这两个字段。这种方式牺牲了一点一致性换来了列表页的高效渲染在生鲜场景下完全值得。商品详情页面接口返回的是商品基本信息 所有启用的 SKU 列表 商家资质 促销信息。促销信息不要在商品接口里实时计算而是由后台触发生成缓存接口先从 Redis 取取不到再查数据库。3.4 购物车接口的设计细节购物车接口其实不复杂但要处理几种特殊场景添加商品时如果购物车里已经存在这个 SKU则数量累加而不是新增一条记录。所选 SKU 可能已经从商品库下架或者删除了此时购物车条目必须有“失效”状态。结算时购物车的商品总价要重新计算不能直接取前端传来的小计因为前端价格不可信。后端购物车接口我做成这样PostMapping(/api/cart/add) public ResultVoid addCart(RequestBody AddCartDTO dto) { // 校验SKU存在且上架 // 判断是否已有记录有则数量1无则新增 // 更新购物车总数量 }购物车列表接口需要连表查询把 SKU 名称、图片、售价、库存都返给前端public ListCartVO listCart(Long userId) { ListCartItem items cartItemMapper.selectByUserId(userId); // 根据skuId批量查询skuMap // 组装CartVO设置selected、stock、isInvalid等字段 }一个很容易忽略的点购物车中的商品价格要以当前 SKU 售价为准而不是在下单时存进购物车里的价格。因为用户可能过早加入购物车过了几天才结账这时候价格可能已经变了。正确的流程是加入购物车时只存数量结算时以最新价格为准并把价格变化提示给用户。3.5 下单流程事务、锁与状态机下单是整个系统中最核心、最容易出问题的环节。我从一开始就意识到这块必须用数据库事务保证数据一致性同时引入乐观锁或悲观锁防止超卖。我的下单流程拆成了 5 个步骤从购物车中取出本次结算的商品条目校验所选 SKU 的库存是否足够。创建订单主记录初始状态“待支付”。创建订单明细记录。扣减对应 SKU 的库存锁定的库存 1可用库存 -1。清空已结算的购物车条目返回订单 id 和应付金额。这里扣减库存有很多种写法最简单的悲观锁写法是Transactional(rollbackFor Exception.class) public Long createOrder(Long userId, ListCartItemVO items) { for (CartItemVO item : items) { // 使用悲观锁 lock in share mode 或 for update 锁定SKU行 Sku sku skuMapper.selectByIdForUpdate(item.getSkuId()); if (sku.getStock() item.getQuantity()) { throw new BusinessException(库存不足); } sku.setStock(sku.getStock() - item.getQuantity()); skuMapper.updateById(sku); } // 创建订单 }用for update可以确保同一时间内只有一个线程能扣减同一个 SKU 的库存。在事务内执行for update行锁要等到事务提交才释放所以当前事务执行期间其他下单请求会阻塞从而避免超卖。这种方式的缺点是并发性能低但在中小型生鲜商城系统里完全够用。高并发场景下比如秒杀我会改用 Redis 的 Lua 脚本做库存预扣减。Redis 扣减成功后才进入下单流程否则直接返回“活动太火爆”。这个后面在难点处理里细说。订单创建后我用定时任务扫单每次扫描所有超过 30 分钟仍未支付的订单将状态改为超时取消同时把锁定的库存回补。3.6 订单状态机的实现订单状态不是简单的 int 字段我把它当成有限状态机来设计。定义以下几个状态PENDING_PAYMENT待付款PENDING_DELIVERY待发货DELIVERED已发货COMPLETED已完成CANCELLED已取消REFUNDING退款中状态流转规则如下当前状态可达状态触发事件待付款已取消用户取消 / 超时取消待付款待发货支付成功待发货已发货商家发货待发货已取消缺货取消已发货已完成用户确认收货已完成退款中用户申请售后我把状态流转逻辑放在 service 层用枚举和 switch 判断避免每个接口都可以随意改状态public void updateOrderStatus(String orderNo, OrderStatus target) { Order order orderMapper.selectByOrderNo(orderNo); OrderStatus current OrderStatus.of(order.getStatus()); if (!current.canTransfer(target)) { throw new BusinessException(非法状态流转); } order.setStatus(target.getCode()); orderMapper.updateById(order); }这套状态机帮我避免了不少低级问题。比如用户在支付回调中如果又点了取消投诉率就能降下来。状态变化的记录我单独放了一张order_status_log表每次变更都在里面记一条时间线后台客服可以直观看到订单经历了什么。4. Vue 前端实现要点4.1 前端工程初始化和路由设计前端项目用 Vue CLI 脚手架初始化选装 Router、Vuex、Axios。Element UI 按需引入避免打包体积过大。目录结构我在前面已经列过这里重点说路由和权限。我设计了三类页面公开页面首页、商品列表、商品详情、登录注册。用户页面购物车、结算页、个人中心、订单列表、订单详情、售后申请。管理页面商品管理、类目管理、订单管理、用户管理、促销管理等。路由用meta.requiresAuth和meta.isAdmin做标记在全局前置守卫中根据 token 和当前用户角色判断能不能跳转router.beforeEach((to, from, next) { if (to.meta.requiresAuth !store.state.token) { next(/login?redirect to.path) return } if (to.meta.isAdmin store.state.user.role ! ADMIN) { next(/403) return } next() })这里的坑在于 Vuex 里的 user 状态在页面刷新后会丢失因为 token 存在 localStorage 里但用户信息只在内存里。解决办法是在全局守卫中判断如果 localStorage 有 token 但 Vuex 没有 user则调用/api/user/info重新拉取用户信息。4.2 首页与商品列表的组件化首页是用户进入系统看到的第一面我把它拆成了几个独立组件顶部搜索栏、轮播图、分类导航、今日爆款、每日上新、推荐商品。这样每个组件只负责一块数据互不干扰。商品列表页使用el-table会有点重更适合用户端的是卡片列表。我用el-row和el-col栅格布局每一个商品卡片组件接收一个product对象展示图片、名称、最低价、销量。点击进入详情页时在路由 query 中带上id然后调用/api/product/detail?id加载详情。商品详情页的核心是 SKU 选择器。前端渲染所有规格比如“500g/份”“1kg/份”点击后切换显示对应 SKU 的价格、库存和图片。这个功能用 Vue 的computed可以实现得非常流畅computed: { currentSku() { return this.skuList.find(s s.id this.selectedSkuId) } }选中 SKU 后底部操作栏显示“加入购物车”和“立即购买”这两个按钮分别触发不同的接口但都需要校验是否已登录。4.3 购物车与结算页的联动购物车页有一个很影响体验的细节全选按钮、单选按钮、数量增减都要实时计算总价。我把购物车列表放在 Vuex 的cartmodule 里用 getters 计算选中的商品小计和总金额selectedTotalPrice(state) { return state.items .filter(item item.selected) .reduce((sum, item) sum item.price * item.quantity, 0) }选择的 SKU 如果在后端已被标记失效前端需要在该条目上显示“失效”标签并且禁止勾选。这个数据是后端返回的invalid字段。结算页面类似购物车但要额外展示配送时窗、收货地址和优惠信息。配送时窗我做成一个下拉列表选项从后端/api/delivery/slots获取。用户选了时段后下单接口要把deliveryDate和slotId一起传过去。结算页还有一个关键操作——从购物车跳转到结算时会带一个selectedIds参数下单时只处理这些勾选的条目。需要明确的是购物车中未被勾选的商品不能在下单时被清空这部分逻辑我和后端接口约定好createOrder接收完整的购物车条目 id 列表后端只结算这些 id。4.4 后台管理页面的实现后台管理页面我单独用一套布局左侧菜单右侧内容区域。菜单项包括数据看板、用户管理、商品管理、类目管理、订单管理、促销管理、配送时窗管理。商品管理页最难的是 SKU 的动态增删。Element UI 的el-table支持动态行我实现的方式是先渲染商品基本信息的表单再渲染一个 SKU 表格组件表格支持新增、编辑、删除行最后在提交时统一组装成 JSON 传给后端。订单管理页的表单筛选比较重要需要支持按状态、按日期范围、按订单号搜索。订单详情我用一个el-dialog嵌套el-descriptions展示同时支持商家操作按钮比如“发货”“取消”。发货操作会弹出一个配送信息表单要求填写配送员或物流单号提交后调用后端接口更新订单状态。权限控制方面后端已经区分了真实角色前端只是在路由守卫里做了展示层拦截。真正防止越权操作还得靠后端在每个管理接口中校验requester的角色和 userId前端拦截只是用户体验的一部分。5. 关键难点处理库存扣减与超卖控制5.1 超卖问题的本质超卖指的是商品实际库存只有 10 件但系统同时卖出去了 12 件。这个问题在并发场景下特别容易出现。我最早用最简单的方式Sku sku skuMapper.selectById(skuId); if (sku.getStock() quantity) { int rows skuMapper.updateStock(skuId, quantity); // update stock stock - quantity if (rows 0) { throw new BusinessException(库存不足); } }这个写法在低并发下没问题但是一旦两个人同时读到了库存 10都判断库存充足于是都去执行更新库存最终变成 8但两人都下单成功变成实际超卖。原因在于这两个操作之间没有加锁读和写不是一个原子操作。5.2 乐观锁和悲观锁的适用边界解决超卖最常见的方案是乐观锁和悲观锁。悲观锁就是使用select ... for update直接锁定被操作的 SKU 行。前面提到过它的优点是简单直接绝不会超卖缺点是并发性能差。但在生鲜商城的常规购买场景同一秒钟不会有很多人抢同一个 SKU用悲观锁完全扛得住。乐观锁的思路是在 SKU 表加一个version字段每次更新时同时校验版本号update sku set stock stock - #{quantity}, version version 1 where id #{skuId} and version #{oldVersion}如果影响行数为 0说明期间有别的线程已经修改过这个 SKU当前请求需要重试或提示用户库存不足。乐观锁适用于冲突概率较低的场景不会长时间阻塞事务。我的下单逻辑采用了“悲观锁主流程 Redis 预扣减秒杀库存”的组合策略。普通下单使用悲观锁因为业务逻辑内还要校验商品状态、会员折扣信息事务时间较长但吞吐量要求不高。秒杀则用 Redis 限流和预扣减避免大量请求直接压到数据库。5.3 Redis 扣库存的 Lua 脚本秒杀接口我设计成这样活动开始前后台把秒杀商品的库存加载到 Rediskey 为seckill:skuId:stock。用户请求到来时执行一个 Lua 脚本完成“检查库存是否 0是则扣减并返回 1否则返回 0”这个操作是原子的local key KEYS[1] local stock redis.call(get, key) if tonumber(stock) 0 then return 0 end redis.call(decrby, key, 1) return 1执行脚本成功后再把用户信息放入一个 Redis 队列如seckill:user:skuId后台异步处理实际下单和生成订单。如果异步处理失败需要补偿回补 Redis 中的库存保证数据最终一致。这套方案下真正的数据库事务只给了进队列的用户流量被削掉一大截。Redis 扣库存方案看似完美但要处理一个问题如果用户被扣了库存却最终没有支付Redis 里扣掉的库存和数据库库存就不一致了。我的方案是订单超时后在回退数据库库存的同时也把 Redis 库存加回来保持两边的最终一致。5.4 事务隔离级别和超时回滚下单接口我用Transactional注解设置了回滚条件。需要注意事务的隔离级别设置如果设置成READ_UNCOMMITTED在库存扣减过程中其他事务可能读到脏数据导致判断结果错乱。我用的是 MySQL 默认的REPEATABLE_READ保证在一次事务内多次查询 SKU 库存得到的结果一致配合行锁不会出问题。事务时间如果过长会影响到其他用户下单所以下单接口中不建议包含耗时的网络操作比如调用短信、推送等。这些耗时任务应该放到事务外或者消息队列异步执行。6. 常见问题与排查技巧实录6.1 前端跨域请求失败接口 404 或 OPTIONS 请求报错这个问题几乎每个人都会遇到。前端页面跑在 8080 端口后端接口跑在 8081 端口两者不同源浏览器会发起跨域请求。解决办法是在后端加入全局跨域配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)时不能再用allowedOrigins(*)因为浏览器安全策略不允许Access-Control-Allow-Origin为*同时携带凭证。如果需要允许任意来源要用allowedOriginPatterns(*)。6.2 前端拿到的 Long 类型 id 精度丢失这个坑非常经典。数据库主键如果用的雪花 id它是一个 19 位的 Long但 JavaScript 的 Number 只能安全表示 16 位以内的整数超过 2^53 就会丢失精度。结果就是前端拿到 id 后调用详情接口发现 id 变了查询失败。解决办法有两种一是把雪花 id 转成字符串返回二是全局设置 Jackson 将 Long 序列化为 String。我选择第二种配置一下就行Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); }; } }但这种粗暴的全局序列化会把所有 Long 都变成字符串如果有些前端字段需要数字类型需要小心。更合理的方式是只在实体类 id 字段上加JsonSerialize(using ToStringSerializer.class)注解。不过我赶时间项目里直接用全局配置目前没有发现其他问题。6.3 页面刷新后登录状态丢失浏览器刷新后Vuex 里的 state 会重新执行初始值导致用户的登录信息变成空。这个问题排查起来很隐蔽页面首次访问没问题一路点击也没问题但一刷新就跳回登录页。原因是用户信息没有持久化。我的解决方案是在路由守卫里面加一个逻辑router.beforeEach(async (to, from, next) { const token localStorage.getItem(token) if (token !store.state.user) { try { const res await getUserInfo() store.commit(SET_USER, res.data) } catch (e) { localStorage.removeItem(token) } } // 后续路由判断 })这样每次刷新后只要 token 还在前端都会重新请求用户信息登录状态就不会丢。6.4 定时任务扫描未支付订单导致重复处理我用 Spring Boot 的Scheduled注解写了一个每分钟执行的任务扫描 30 分钟以上的未支付订单将其置为超时取消。第一次写这道逻辑时踩了一个大坑定时任务重启时可能上一轮任务还没执行完下一轮又开始了导致同一张订单被处理两次。解决方法是给任务加一个分布式锁单机环境直接加synchronized或者一个布尔标志位并且扫描时只更新状态为“待付款”且支付截止时间小于当前时间的订单更新时加条件“status 待付款”这样即使重复执行也不会把已取消订单再更新一遍。如果部署了多个实例需要引入 Redis 分布式锁来控制任务并发。6.5 数据库连接池占满接口全部超时有一次测试环境跑了一会儿所有接口突然开始超时排查发现数据库连接池打满了。原因是有个慢 SQL 扫了整个订单表而且事务没有及时提交连接一直占着不释放。解决办法有几个层面代码层面使用try-with-resources确保连接释放配置层面调高连接池上限SQL 层面用索引优化慢查询。最重要的一点是凡是需要在事务里做大量数据操作的地方一定要把事务拆小不要在事务里做循环查询和远程调用。7. 实际体会与扩展方向整个系统做下来我最深的感受是生鲜商城最关键的不是技术多炫而是业务闭环要完整。商品、购物车、订单、支付、库存、配送、售后每一环都要能跑通否则演示的时候就容易露怯。很多同学写这个题目只做了商品展示和购物车后台管理完全没有订单也没有状态流转这样答辩时很容易被问到产品深度不够。如果后续再做扩展我会优先考虑这几个方向增加专属客服和售后工单流程。接入真实的第三方支付和物流查询。增加简单的销售数据统计报表比如热销商品 Top10、各品类销售额占比、用户复购率。将秒杀场景用 RabbitMQ 异步化订单创建和库存扣减彻底解耦。加入商品推荐基于用户的购买历史做协同过滤。生鲜商城系统的实现难度可以说中等但涉及的领域比较广从用户交互到后台运营再到事务一致性处理都有很多值得打磨的细节。把这些做完收获的不只是一套代码更是对电商整体业务流程的理解。最后再分享一个小技巧做系统时一定要给所有表加上创建时间和更新时间字段并且让 MyBatis-Plus 的自动填充帮你处理省心很多。另外在环境允许的情况下尽早做前后端联调和接口文档维护等到所有页面写完再去对接口改起来成本会高很多。