
简介面向 Java Web 学习与毕业设计的外卖点餐系统设计与实现文档采用 JAVA MySQL SSM 框架围绕 B/S 架构下的管理员与用户双角色展开覆盖餐厅信息、菜品分类及菜品信息、订单管理、订单评价、我的收藏、购物车、后台管理等核心模块既能帮助初学者梳理系统需求与编码实现思路也可作为课程设计或论文撰写的直接参考。资源仅含 1 个 docx 文档大小 10.02MB内容完整、排版适合直接阅读和打印目前已有 126 人学习下载。文档从系统背景、开发目的与优势入手详细说明了管理员和用户两侧的功能模块并给出了数据库选型、SSM 分层开发、系统实现流程与测试等关键环节同时包含中英文摘要与关键词。通过这份资料读者可以掌握外卖点餐系统的设计结构、主要技术栈和完整开发流程为后续独立完成类似项目或毕业设计提供清晰的框架与素材支撑。1. Java 外卖点餐系统为什么最值得亲手做一遍在我带过的 Java 后端项目里外卖点餐系统是典型的“看着简单、做着不简单”的选题。用户点餐、商家出餐、骑手配送这几件事背后要把库存扣减、支付回调、订单状态流转全部串起来恰好覆盖了后端面试里最常被追问的几类业务场景。用 Java 做外卖点餐系统不是实现几张表的增删改查而是把一个完整订单链路从设计到落地跑通。这篇文章不会只给结论会把表结构、核心代码、防超卖方案和排查思路按顺序讲清楚适合正在做 Java 外卖点餐系统设计与实现的同学也适合想借这个小而全的场景验证 Spring Boot、Redis 和消息推送能力的从业者。2. 领域边界与表结构设计先定角色再写建表 SQL2.1 四类角色的权限边界怎么划外卖点餐系统的第一个设计决策不是技术选型而是角色划分。常见做法是分成顾客、商家、骑手、平台管理员四类数据边界也按这四类角色隔离。顾客关心的是浏览菜品、加购物车、下单支付、查订单。商家关心的是菜品上下架、接单拒单、备餐出餐。骑手关心的是抢单、更新配送状态。管理员关心的是审核商家资质、处理异常订单退款。权限边界如果不提前定清楚后面会出现两种很常见的翻车情况。第一种是前端把按钮藏起来就当做了权限控制实际上用 Postman 直接调接口就能越权操作第二种是在 Service 层每个方法里都忘了按 userId 过滤结果顾客 A 能查到顾客 B 的订单地址。我一般把权限校验统一放在拦截器里做所有 Controller 方法只在参数里拿 userId不在方法内部重新判断角色。角色与接口的对应关系可以简单梳理成下面这样角色典型接口范围关键约束顾客登录、查菜品、购物车、下单、支付回调只能操作自己的购物车和订单商家菜品管理、接单、备餐只能操作自己门店的菜品和订单骑手抢单、状态更新只能更新被派给自己的订单管理员商家审核、全局订单查询、退款处理需要后台独立权限位2.2 四组核心表菜品、购物车、订单、配送角色定完以后表结构就顺着业务链路出来了。我见过不少新手直接把“订单表”设计成一张大宽表所有字段全塞进去结果菜品改价之后历史订单金额跟着变骑手信息变更后历史配送记录也没了。正确的做法是把订单主表、订单明细表、商品表拆开用快照字段保存下单那一刻的菜名和价格。下面这套建表 SQL 是外卖点餐系统里比较通用的骨架以 MySQL 8 为例-- 商户表 CREATE TABLE merchant ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(128) NOT NULL, address VARCHAR(255) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审核 1正常 2关闭, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; -- 菜品表 CREATE TABLE dish ( id BIGINT NOT NULL AUTO_INCREMENT, merchant_id BIGINT NOT NULL, name VARCHAR(128) NOT NULL, price INT NOT NULL COMMENT 价格单位分, stock INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 0下架 1上架, PRIMARY KEY (id), KEY idx_merchant_status (merchant_id, status) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; -- 购物车表 CREATE TABLE cart ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, PRIMARY KEY (id), UNIQUE KEY uk_user_dish (user_id, dish_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; -- 订单主表 CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL, merchant_id BIGINT NOT NULL, rider_id BIGINT DEFAULT NULL, amount INT NOT NULL COMMENT 订单金额单位分, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1待接单 2备餐 3配送 4完成 5取消 6退款, address VARCHAR(255) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status), KEY idx_merchant_status (merchant_id, status) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; -- 订单明细表 CREATE TABLE order_detail ( id BIGINT NOT NULL AUTO_INCREMENT, order_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, dish_name VARCHAR(128) NOT NULL COMMENT 菜品快照, price INT NOT NULL COMMENT 下单时单价快照, quantity INT NOT NULL, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;几个容易忽略的设计点**金额用 int 不用 decimal。**外卖订单里频繁出现单价乘数量再累加用 decimal 在 Java 和 MySQL 之间容易遇到精度和比较问题。统一用“分”存整数前端展示时除以 100是最省心的做法。**订单明细保存 dish_name 和 price 快照。**商家改价、改菜名都不应该影响历史订单。这是订单系统里最常见的面试追问点也是不少网上的外卖项目代码里做错的地方。**订单表冗余 merchant_id。**虽然订单明细能反查出商家但按商家维度查订单、做商家端统计直接走订单主表的联合索引比 join 明细表快一个量级。购物车表使用UNIQUE KEY uk_user_dish用户重复加同一道菜时用INSERT ... ON DUPLICATE KEY UPDATE quantity quantity 1就能合并避免先查再更新的两步竞态。2.3 MyBatis-Plus 落库建表 SQL 和实体类怎么保持一致新手经常纠结一个问题是先写 Java 实体类还是先写 SQL 建表网上搜索“mybatisplus 根据 java 实体类生成创建表的 sql 语句”能看到各种自动建表方案但我的建议很明确外卖点餐系统这种业务表就别指望实体类自动生成建表 SQL。常见做法是先维护 SQL 建表脚本再用 MyBatis-Plus 的代码生成器反向生成 Entity、Mapper 和 Service。MyBatis-Plus 的实体类映射约定很直接字段下划线自动转驼峰TableName(dish) Data public class Dish { TableId(type IdType.AUTO) private Long id; private Long merchantId; private String name; private Integer price; private Integer stock; private Integer status; }对应关系merchant_id列自动映射到merchantId属性不用在每个字段上写TableField。全局配置里确认map-underscore-to-camel-case是 true默认就是开启的真正需要写TableField的只有自定义列名或者逻辑删除字段。分页查询是 MyBatis-Plus 高频功能需要先注册分页插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }DbType.MYSQL必须和你实际数据库一致填错会生成错误的分页语句。分页查询用起来是这个形态PageDish page new Page(current, size); LambdaQueryWrapperDish queryWrapper Wrappers.lambdaQuery(); queryWrapper.eq(Dish::getMerchantId, merchantId) .eq(Dish::getStatus, 1) .orderByDesc(Dish::getId); PageDish result dishMapper.selectPage(page, queryWrapper);Page构造器的两个参数是当前页和每页条数都从 1 开始计数不要在前端把current传成 0。返回的result.getRecords()是当前页数据result.getTotal()是总条数。3. 订单状态机与防超卖并发场景下如何保证不翻车3.1 订单状态机别把 if/else 写散外卖订单的状态流转是整个系统里最容易出逻辑 bug 的地方。状态跳变有明确方向待支付可以走到待接单或已取消但已完成的订单绝不应该再变回备餐中。用状态机约束流转而不是在业务代码里到处写if status 1是最稳的做法。先定义状态枚举和合法流转规则public enum OrderStatus { WAIT_PAY(0, 待支付), WAIT_ACCEPT(1, 待接单), COOKING(2, 备餐中), DELIVERING(3, 配送中), FINISHED(4, 已完成), CANCELLED(5, 已取消), REFUNDED(6, 已退款); public final int code; public final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public static boolean canTransfer(int from, int to) { if (from WAIT_PAY.code (to WAIT_ACCEPT.code || to CANCELLED.code)) { return true; } if (from WAIT_ACCEPT.code (to COOKING.code || to CANCELLED.code)) { return true; } if (from COOKING.code to DELIVERING.code) { return true; } if (from DELIVERING.code to FINISHED.code) { return true; } if (from WAIT_PAY.code to REFUNDED.code) { return true; } if (from WAIT_ACCEPT.code to REFUNDED.code) { return true; } return false; } }这个枚举本质上就是状态设计模式在 Java 里最轻量的落地。所有状态变更统一走同一个入口方法先调canTransfer校验再执行更新非法跳转直接抛业务异常。这样设计的好处是将来接支付回调、退款、异常订单处理时都不需要重新梳理全链路的状态分支。转到数据库层状态更新必须带条件防止并发下状态被覆盖int rows orderMapper.compareAndSetStatus(orderNo, fromStatus, toStatus); if (rows 0) { throw new BizException(订单状态已变化请刷新); }对应的 SQL 是UPDATE orders SET status #{toStatus} WHERE order_no #{orderNo} AND status #{fromStatus}WHERE里的status #{fromStatus}是乐观锁的简化写法。两个请求同时到达时数据库行锁会让其中一个更新成功另一个更新 0 行业务层拿到 0 就知道状态已经变了直接拒绝操作。3.2 防超卖扣库存的三种常见方案外卖点餐系统最典型的并发问题就是超卖高峰期大量用户同时下单库存瞬间被扣成负数。扣库存我按场景分三种方案第一种是纯数据库条件更新适合中小流量。核心是把“查出库存再判断”变成一条原子 SQLUPDATE dish SET stock stock - #{quantity} WHERE id #{dishId} AND stock #{quantity}受影响行数为 1 表示扣减成功0 表示库存不足。stock #{quantity}就是隐式乐观锁数据库行锁保证同一时间只有一条更新能成功。第二种是增加 version 字段的乐观锁适合需要记录修改次数的场景。表里加version列更新时WHERE version #{oldVersion}更新成功后 version 加 1。这和第一种方案本质一致但多一个字段便于追溯并发冲突次数。第三种是 Redis 预扣库存适合压测时发现数据库成为瓶颈的大流量场景。用 Lua 脚本保证判断和扣减是一个原子操作if redis.call(get, KEYS[1]) tonumber(ARGV[1]) then return redis.call(decrby, KEYS[1], ARGV[1]) end return -1注意 Redis 预扣只负责“入口拦截”最终库存还是要以数据库为准。下单未支付时 Redis 里的库存已经被扣掉所以必须配合超时释放逻辑否则会出现大量“假库存不足”。我的习惯是数据库条件更新为主Redis 预扣只在高并发秒杀场景开启并且把 Redis 和数据库库存的一致性校验做成定时对账任务。3.3 完整下单流程购物车到订单的 Service 实现把前面几个点拼起来一个完整的下单 Service 长这样Transactional(rollbackFor Exception.class) public OrderCreateDTO createOrder(Long userId, Long merchantId, ListCartItem items) { String orderNo generateOrderNo(); ListOrderDetail detailList new ArrayList(); int totalAmount 0; // 1. 先锁住菜品行锁定后读到的库存才是准确的 for (CartItem item : items) { Dish dish dishMapper.selectByIdForUpdate(item.getDishId()); if (dish null || dish.getStatus() ! 1) { throw new BizException(菜品已下架); } if (dish.getStock() item.getQuantity()) { throw new BizException(dish.getName() 库存不足); } detailList.add(buildDetail(orderNo, dish, item)); totalAmount dish.getPrice() * item.getQuantity(); } // 2. 再次扣库存实际以这个条件更新为准 for (OrderDetail detail : detailList) { int rows dishMapper.deductStock(detail.getDishId(), detail.getQuantity()); if (rows 0) { throw new BizException(下单高峰期部分菜品库存不足); } } // 3. 写入订单主表和明细表 Orders order new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setMerchantId(merchantId); order.setAmount(totalAmount); order.setStatus(OrderStatus.WAIT_PAY.code); orderMapper.insert(order); for (OrderDetail detail : detailList) { detail.setOrderId(order.getId()); orderDetailMapper.insert(detail); } // 4. 清理购物车中已下单的商品 cartMapper.deleteByUser(userId, items.stream() .map(CartItem::getDishId) .collect(Collectors.toList())); return new OrderCreateDTO(orderNo, totalAmount); }Transactional(rollbackFor Exception.class)是必须的默认配置只回滚 RuntimeException自定义业务异常如果不写rollbackFor事务不会回滚会出现库存扣了但订单没生成的脏数据。selectByIdForUpdate是SELECT ... FOR UPDATE的行锁它会锁住菜品行直到事务提交或回滚。如果所有菜品都走这个锁并发度会下降所以我在第一步锁行确认库存第二步再用条件更新deductStock兜底。这两步配合能挡住绝大多数超卖场景。generateOrderNo()常见做法是用 Redis INCR 生成自增序号再拼上时间戳和随机数保证全局唯一。业务订单号用于对外展示和支付回调匹配不应直接用数据库自增主键。4. 后端 API 设计与安全JWT 双校验和 WebSocket 推送4.1 接口清单与统一返回值约定外卖点餐系统的接口设计要同时照顾小程序端、管理后台端和商家端接口命名统一走 RESTful 风格。核心接口清单大致如下接口方法功能鉴权/api/v1/user/loginPOST验证码或密码登录无需/api/v1/dish/listGET按商家查上架菜品顾客/api/v1/cart/addPOST加入购物车顾客/api/v1/cart/listGET查购物车顾客/api/v1/order/createPOST下单顾客/api/v1/order/{orderNo}GET查订单详情顾客/商家/api/v1/order/{orderNo}/cancelPOST取消订单顾客/商家/api/v1/order/{orderNo}/acceptPOST商家接单商家/api/v1/payment/notifyPOST支付回调无需返回值统一用一个 Result 包装类Data public class ResultT { private int code; private String message; private T data; public static T ResultT ok(T data) { ResultT result new Result(); result.setCode(0); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(int code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }code 的约定要统一我习惯用 0 表示成功401 表示未登录403 表示无权限500 表示服务端异常。业务失败也用 500 或自定义错误码但不要和 HTTP 状态码混为一谈。前端判断只看 Result.codeHTTP 状态码只用于传输层。4.2 JWT Redis 双重校验的拦截器实现外卖点餐系统的登录态有一个比普通后台系统更特殊的需求用户可能在两个设备同时登录或者被商家端强制下线。单纯用 JWT 做不到主动失效因为它本身就是无状态的。我的做法是 JWT 验签名Redis 验登录态两层都通过才算有效。拦截器代码public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); } try { Claims claims Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); Long userId Long.valueOf(claims.get(uid).toString()); // Redis 里的 token 必须和当前 token 完全一致 String redisToken redisTemplate.opsForValue() .get(LoginConstant.TOKEN_PREFIX userId); if (!token.equals(redisToken)) { throw new BizException(登录已失效请重新登录); } request.setAttribute(userId, userId); return true; } catch (JwtException | BizException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(JSON.toJSONString(Result.error(401, 未登录或登录过期))); return false; } } }这里最关键的是 Redis 双重校验。登录成功后把 token 写入 Redis过期时间和 JWT 的过期时间保持一致用户修改密码、被强制下线、或者在新设备登录时直接删掉或覆盖 Redis 里的旧 token旧 token 立刻失效。这就解决了 JWT 无法主动失效的先天问题。拦截器注册时注意排除登录接口和支付回调接口Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/v1/user/login, /api/v1/payment/notify); } }4.3 跨域配置与 WebSocket 订单状态推送前后端分离项目一定会遇到跨域问题。小程序端没有跨域限制但管理后台如果跑在localhost:8081后端跑在8080跨域配置就必须做对。常见做法Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:8081, https://admin.example.com) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)时allowedOrigins不能写*必须写具体域名。拦截器里也要对OPTIONS请求直接放行否则浏览器预检请求会在拦截器层被 401 挡掉。订单状态从“待支付”变成“备餐中”再变成“配送中”靠前端轮询也能做但外卖场景下商家和骑手需要实时感知WebSocket 是更常见的选择。Spring 里实现一个订单状态推送的 WebSocket HandlerComponent public class OrderWebSocketHandler extends TextWebSocketHandler { private static final MapLong, WebSocketSession SESSION_POOL new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) { Long userId (Long) session.getAttributes().get(userId); SESSION_POOL.put(userId, session); } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { SESSION_POOL.values().remove(session); } public void pushStatus(Long userId, String message) { WebSocketSession session SESSION_POOL.get(userId); if (session ! null session.isOpen()) { session.sendMessage(new TextMessage(message)); } } }SESSION_POOL用用户 ID 映射会话同一用户多个设备登录时后面的会话会覆盖前面的这在 Demo 项目里可接受生产环境一般改成映射到用户 ID 的会话列表。推送时机放在商家接单、骑手取餐这些业务动作之后序列化成 JSON 发给前端。5. 高频踩坑与排查超卖、状态倒流、缓存击穿和 JDK 版本5.1 库存被扣成负数现象并发下单压测后dish.stock出现负数订单却能正常创建。原因扣库存的 SQL 写成了UPDATE dish SET stock stock - 1 WHERE id ?没有带stock 1条件两个请求同时读到库存为 1各自都执行了扣减。解决改成UPDATE dish SET stock stock - #{quantity} WHERE id #{dishId} AND stock #{quantity}受影响行数为 0 时抛出库存不足异常。已经产生负数数据的先补一个重置逻辑把负数统一归零再人工核对订单。5.2 订单状态倒流或重复回调覆盖现象用户先取消订单状态变成已取消支付回调稍后到达又把状态改成待接单。原因状态更新没有前置状态校验也没有做幂等。支付平台在超时或网络抖动时会重复推送回调回调里如果没有判断当前状态就会覆盖用户主动取消的结果。解决所有状态变更走compareAndSetStatus(orderNo, fromStatus, toStatus)条件更新UPDATE orders SET status #{toStatus} WHERE order_no #{orderNo} AND status #{fromStatus}。支付回调里只允许“待支付”状态流转到“待接单”已经是取消状态的订单直接返回成功应答不再执行后续业务。5.3 定时扫描超时订单把数据库拖垮现象系统每隔一分钟全表扫描所有待支付订单一旦订单量增大数据库慢查询日志全是SELECT * FROM orders WHERE status 0。原因外卖点餐系统的订单有明确的超时时间比如 15 分钟未支付自动取消。全表扫描方案在小数据量时不明显数据量上来以后问题集中爆发。解决优先用延迟队列。没有 MQ 中间件时用 Redis ZSET 也能实现精确到秒的延迟任务。订单创建时把“待取消时间戳”作为 score 写入 ZSET后台每秒轮询一次取出 score 小于当前时间的订单执行取消。执行时先删除 ZSET 里的记录再更新数据库多实例并发时只有删除成功的那一台才执行取消。5.4 缓存击穿导致数据库瞬时过载现象菜品列表的 Redis 缓存刚好在高峰期过期同一秒大量并发请求打到数据库数据库 CPU 瞬间飙高。原因单纯缓存过期后没有保护机制所有请求都去查数据库重建缓存。解决用互斥锁保护缓存重建拿不到锁的请求短暂等待后重试public ListDishDTO listDishes(Long merchantId) { String key dish:merchant: merchantId; String json redisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { return JSON.parseArray(json, DishDTO.class); } String lockKey lock:dish: merchantId; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (!Boolean.TRUE.equals(locked)) { Thread.sleep(100); return listDishes(merchantId); } try { ListDishDTO list dishMapper.selectList( Wrappers.DishlambdaQuery().eq(Dish::getMerchantId, merchantId)); redisTemplate.opsForValue().set(key, JSON.toJSONString(list), Duration.ofMinutes(30)); return list; } finally { redisTemplate.delete(lockKey); } }setIfAbsent对应 Redis 的SET NX命令只有第一个请求能拿到锁其他请求睡 100 毫秒后重新读缓存。锁加 5 秒过期时间是为了防止持锁线程崩溃导致死锁。这里的递归重试要注意设置次数上限避免极端情况下栈溢出。5.5 JDK 和 Spring Boot 版本不匹配现象本地开发用 Java 8服务器上装的是 JDK 17打包后运行报java.lang.NoClassDefFoundError或者 Spring 启动直接失败。原因Spring Boot 2.7 和 3.x 对 JDK 版本要求不同依赖里混用了javax和jakarta命名空间打包环境用的 Maven 版本也依赖 JDK 版本。解决先明确组合Spring Boot 2.7 配 JDK 8 或 11Spring Boot 3.x 必须用 JDK 17。代码里全部用jakarta.servlet.*时检查第三方依赖是否还是javax.*不一致就会报类找不到。环境变量配置方面多个 JDK 共存时直接把JAVA_HOME指到当前项目需要的版本再在 IDE 的 Project Structure 里单独指定项目 SDK避免被全局环境变量干扰。更省心的做法是用 Docker 多阶段构建把 JDK 版本固化到镜像里。6. 压测验证与进阶从能跑到能扛的最后一公里6.1 用 JMeter 验证下单接口的并发上限外卖点餐系统做压测目标不是“能跑通”而是知道自己的系统在多少并发下开始交出性能。JMeter 里我通常这样配置线程数 100Ramp-Up 10 秒循环 5 次先测单接口。聚合报告里重点看三组数据压测场景线程数预期 P95 响应时间预期错误率参考结论低峰50小于 800ms小于 1%正常高峰150小于 2000ms小于 1%正常极端300稳定或开始排队小于 3%需要限流压测之后不要只看响应时间还要检查数据库里的库存是否出现负数、订单状态是否有异常。用一个SELECT把所有扣减记录拉出来统计或者把压测前后的库存总和做对账比看监控图表更直接。6.2 用 Redis ZSET 实现订单超时取消定时任务扫描订单表很费库换成 Redis ZSET 做延迟队列代价极小收益很明显。核心代码public void scheduleCancel(String orderNo, LocalDateTime expireTime) { long score expireTime.atZone(ZoneId.systemDefault()) .toInstant().toEpochMilli(); redisTemplate.opsForZSet().add(ORDER_CANCEL_DELAY_KEY, orderNo, score); } Scheduled(fixedDelay 1000) public void scanExpiredOrders() { long now System.currentTimeMillis(); SetString orderNos redisTemplate.opsForZSet() .rangeByScore(ORDER_CANCEL_DELAY_KEY, 0, now); for (String orderNo : orderNos) { Long removed redisTemplate.opsForZSet() .remove(ORDER_CANCEL_DELAY_KEY, orderNo); if (removed ! null removed 0) { cancelOrderOnTimeout(orderNo); } } }score是到期执行的毫秒时间戳rangeByScore(0, now)取出所有已到期的订单号。remove返回值大于 0 才执行取消这是多实例部署时避免重复取消的关键。cancelOrderOnTimeout内部还是条件更新int rows orderMapper.updateStatusByOrderNo(orderNo, OrderStatus.CANCELLED.code, OrderStatus.WAIT_PAY.code); if (rows 0) { // 订单已支付或已处理无需再取消 log.info(订单 {} 超时取消失败状态已变化, orderNo); }6.3 给服务加一个能确认存活的健康检查项目上线后运维或云平台的健康检查需要一个稳定端点。Spring Boot Actuator 是最省事的方案加依赖后配置两个参数就行management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: always/actuator/health会返回 UP 或 DOWNDocker 的HEALTHCHECK和云平台的探针都可以直接用。/actuator/metrics/jvm.memory.used能在压测时快速看 JVM 堆内存水位。线上部署时的几个常见做法JVM 参数固定-Xms和-Xmx为相同值避免堆动态伸缩影响性能--spring.profiles.activeprod指定生产配置日志单独落盘并按天切割。我自己接手外卖项目时最后一步从来不是写代码而是把压测报告、库存对账脚本、健康检查三样准备好再交付这个习惯帮我避开了很多“本地能跑线上翻车”的尴尬。订单系统这种链路长、状态多的项目每一步细节都可能成为线上事故的导火索能多验证一层是一层。希望帮到你。本文还有配套的精品资源点击获取