简介:一套面向Spring Boot项目实战的“苍穹外卖”完整开发资源,适合系统学习前后端分离项目的初学者及需要参考企业级案例的开发者。内容覆盖项目从规划设计到落地实现的核心环节:Markdown讲义用于理解需求与技术选型,SQL脚本定义数据库结构,Java后端代码实现业务逻辑,Vue前端代码构建用户界面,另附产品原型和JSON格式接口定义,便于前后端对照联调。包体共2000个文件,以7z格式打包,除上述核心类型外,还包含png/svg图片素材、CSS样式、配置文件、说明文档等,压缩包整体约69.96MB,目录结构清晰,可按模块快速定位。资源发布以来已有2547人浏览学习,对想通过真实项目串起“设计—建模—编码—联调”全流程的开发者而言,这套资料能直接作为实操参考,省去大量环境搭建与资料搜集时间。
1. 苍穹外卖是什么:一个外卖全链路系统,能跑通才算数
有一些项目,表面上是课程作业,实际是一整套可运行的外卖业务系统。「苍穹外卖」就是我最近用业余时间从零复跑完的一套:用户端小程序、商家管理后台、后端服务、数据库脚本全部自己搭,最后在本地真实跑通了点餐、下单、接单、派送、完成的完整闭环。对一个想熟悉全栈开发流程的人来说,这套系统最有价值的不是某个炫技框架,而是它逼你把「业务模块拆分、表结构设计、接口约定、状态流转」都想清楚。文章里我会按我自己的落地顺序写:数据模型先定,接口跟着表走,前端按接口联调,最后把最常见的一批坑单独列出来。适合正在找练手项目、想理解外卖业务从需求到代码如何落地的开发者,也适合想拿自己搭的系统去面试讲思路的人。
2. 数据模型先行:把外卖业务拆成九张表,建表 SQL 直接可用
先想清楚:用户端动手之前,到底要存哪些数据?这个问题直接决定后续接口能不能写顺。
2.1 业务对象梳理:从下单到送达,谁和谁产生关系
一个外卖平台的业务闭环是这样走的:用户浏览分类和菜品,把菜品加入购物车,选择地址后提交订单,商家接单、出餐、派送,用户确认收货。把这个流程翻译成数据对象,至少需要「用户、地址、分类、菜品、套餐、购物车、订单、订单明细、员工」这九类核心实体;如果后面要加营业统计,再补一张日汇总表即可,初期不需要过度设计。
我的习惯是先画对象关系再建表,而不是边写接口边加字段。表之间的关系很清晰:「用户」拥有「地址」,「分类」下面挂「菜品」,「套餐」由多个「菜品」组成,「购物车」是用户点餐过程中的临时数据,下单后把购物车转成「订单 + 订单明细」。注意「订单明细」是必须存在的,因为一个订单可以包含多个菜品;而订单里又要冗余一份收货人、电话和地址快照,防止用户下单后改地址导致历史订单数据被篡改。
2.2 核心表结构:用户、地址、订单、订单明细
直接给出最终确定的表结构。用户表、地址簿、订单、订单明细是最核心的四张,我分别给出建表语句,后面的分类、菜品、购物车表再以同样风格列出。
这里有一张非常重要的表:订单表。订单号要建唯一索引,金额用 DECIMAL,状态用 TINYINT 枚举,收货信息做快照。以下是四张表的完整 SQL。
CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `name` VARCHAR(32) DEFAULT NULL COMMENT '昵称', `gender` TINYINT DEFAULT 1 COMMENT '1 男 2 女 0 未知', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像地址', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1 正常 0 禁用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';CREATE TABLE `address_book` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL COMMENT '关联 user.id', `consignee` VARCHAR(32) NOT NULL COMMENT '收货人', `phone` VARCHAR(20) NOT NULL COMMENT '联系电话', `province` VARCHAR(32) DEFAULT NULL COMMENT '省', `city` VARCHAR(32) DEFAULT NULL COMMENT '市', `district` VARCHAR(32) DEFAULT NULL COMMENT '区', `detail` VARCHAR(128) NOT NULL COMMENT '详细地址', `label` VARCHAR(16) DEFAULT '家' COMMENT '标签:家/公司/学校', `is_default` TINYINT DEFAULT 0 COMMENT '1 默认地址', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='地址簿表';CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `number` VARCHAR(32) NOT NULL COMMENT '订单号,业务可见', `user_id` BIGINT NOT NULL COMMENT '下单用户', `address_book_id` BIGINT NOT NULL COMMENT '地址簿 id', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 待付款 2 待接单 3 已接单 4 派送中 5 已完成 6 已取消', `pay_status` TINYINT DEFAULT 0 COMMENT '0 未支付 1 已支付 2 退款', `order_time` DATETIME NOT NULL COMMENT '下单时间', `checkout_time` DATETIME DEFAULT NULL COMMENT '支付时间', `pay_method` TINYINT DEFAULT 1 COMMENT '1 微信 2 支付宝 3 现金', `amount` DECIMAL(10,2) NOT NULL COMMENT '订单总金额', `remark` VARCHAR(128) DEFAULT NULL COMMENT '订单备注', `consignee` VARCHAR(32) NOT NULL COMMENT '收货人快照', `phone` VARCHAR(20) NOT NULL COMMENT '电话快照', `address` VARCHAR(255) NOT NULL COMMENT '地址快照', `estimated_delivery_time` DATETIME DEFAULT NULL COMMENT '预计送达时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_number` (`number`), KEY `idx_user_id` (`user_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';CREATE TABLE `order_detail` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL COMMENT '关联订单 id', `dish_id` BIGINT DEFAULT NULL COMMENT '菜品 id', `setmeal_id` BIGINT DEFAULT NULL COMMENT '套餐 id,与菜品二选一', `name` VARCHAR(64) NOT NULL COMMENT '菜品名快照', `image` VARCHAR(255) DEFAULT NULL COMMENT '图片快照', `number` INT NOT NULL COMMENT '份数', `amount` DECIMAL(10,2) NOT NULL COMMENT '小计金额', PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';先说为什么用户表不存密码也不存微信登录凭证,只留手机号。苍穹外卖的练手定位决定了认证方式越简单越好,手机号 + 验证码足够支撑完整流程,需要接三方登录时,再在 user 表加一个字段存三方平台返回的用户标识,不影响现有逻辑。
订单表的收货信息是快照,这一点经常被漏掉。我见过同学在订单表里只存address_book_id,用户改完地址后,历史订单的收货地址跟着变,这在真实业务里是不能接受的。所以consignee/phone/address这三个字段在下单那一刻从地址簿复制进来,之后永远不回写。
2.3 分类、菜品、购物车表:三张业务支撑表的字段约定
剩下的表我按字段清单说明,建表时直接照抄字段定义即可。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| category | id, name, type, sort, status | type 用 1 表示菜品分类、2 表示套餐分类;sort 决定展示顺序 |
| dish | id, category_id, name, price, image, description, status | price 用 DECIMAL(10,2);status 1 起售 0 停售 |
| setmeal | id, category_id, name, price, status, image | 套餐本身是一个可售商品 |
| setmeal_dish | id, setmeal_id, dish_id, copies | 套餐和菜品的多对多关系表 |
| shopping_cart | id, user_id, dish_id, setmeal_id, number, amount | 菜品和套餐二选一,另一个字段保持 NULL |
这里有两个容易纠结的小点。第一,分类表为什么不直接写死成枚举:因为外卖系统的菜品分类是运营人员动态配置的,比如「热销榜」「主食」「饮品」都是后天加进去的,枚举没法满足。第二,setmeal_dish是关系表,多对多离不开它,不要试图在setmeal表里用逗号分隔菜品的 id 列表,那会让更新套餐和统计菜品销量都变成噩梦。
2.4 数据字典与字段规范:金额、时间、状态用「约定俗成」
数据库规范直接决定后面接口能不能少返工。我给自己定了三条硬规矩:金额一律DECIMAL(10,2),时间一律 DATETIME,状态一律 TINYINT。这套约定在前后端联调时特别省事,前端拿到的金额就是两位小数的字符串或数字,不用再做特殊的精度转换。状态用 TINYINT 而不是 VARCHAR 存中文,好处是数据库体积小、查询快,也避免「已付款」和「已支付」这种叫法不统一的问题。状态枚举值在接口文档里写清楚即可,比如订单状态 1 到 6 的含义,后续所有代码都只认数字,不认文字。
提示:给订单明细表加
dish_id和setmeal_id两个可空字段,是为了兼容「单点菜品」和「点套餐」两种场景。校验时至少保证一个不为空,否则一条明细既不指向菜品也不指向套餐,统计销量时就会漏数据。
建表完成后,建议顺手往测试库里灌一批模拟数据:三到五个分类、每个分类十来个菜品、两三个套餐、几个用户和地址。测试数据的重要性在接口阶段就会显现——购物车、下单、销量统计这些接口,没有数据根本没法验证边界情况。
3. 后端接口体系:JWT 登录、购物车加购与订单提交的代码实现
表定完了,后端就按「模块」一个个开接口。整个后端我建议用 Spring Boot 2.x + MyBatis-Plus 起步,事务管理、参数校验、接口文档都能省不少事。下面按业务顺序给出核心实现。
3.1 接口清单设计:用户端与管理端分开看
接口不按表来设计,按业务场景设计。用户端需要这些接口:验证码登录、分类列表、菜品/套餐查询、购物车增删改查、地址簿增删改查、提交订单、订单列表、订单状态查询。管理端需要:员工登录、菜品分类管理、菜品上下架、套餐管理、订单接单/派送/完成、营业数据统计。这两组接口在工程上可以放同一个应用里,通过接口路径前缀区分,前端也按前缀请求。
RESTful 风格我按最小约定来:资源用名词复数,动作交给 HTTP 方法和业务路径。比如POST /api/shoppingCart/add表示加购,GET /api/user/orders表示查询订单列表。前端不用关心后端内部怎么查表,只要 URL、请求参数、返回结构稳定即可。返回结构统一用{code: 1, data: ..., msg: "ok"}这个风格,code 用 1 表示成功、0 表示失败,比 HTTP 状态码更符合业务系统的使用习惯,前端判断也简单。
3.2 JWT 登录:用 token 串起用户与订单
登录是第一个要写的接口,因为后续所有接口都要带身份信息。方案上我没有引入 Spring Security 全家桶,用 JWT + 拦截器就够:登录成功后,把userId放进 claims,签发一个有效期为 7 天的 token;客户端每次请求在Authorization头里带上Bearer ${token};后端拦截器解析 token,把 userId 放进 ThreadLocal 供业务方法读取。这样能省掉大量 Session 管理代码,也让系统天然支持多端同时登录。
@PostMapping("/login") public Result<String> login(@RequestBody LoginDTO dto) { User user = userService.lambdaQuery() .eq(User::getPhone, dto.getPhone()) .one(); String code = redisTemplate.opsForValue().get("verify:" + dto.getPhone()); if (code == null || !code.equals(dto.getCode())) { return Result.error("验证码错误或已过期"); } if (user == null) { user = new User(); user.setPhone(dto.getPhone()); user.setName("用户" + dto.getPhone().substring(7)); userService.save(user); } String token = JwtUtil.createToken(user.getId(), "USER", 7 * 24 * 3600L); return Result.success(token); }这段逻辑里有几个参数值得说。验证码在 Redis 中的 key 是verify:{手机号},TTL 设为 300 秒;JWT 过期时间 7 天,是为了减少用户反复登录的打扰;首次登录时如果查不到用户,就自动注册一个,这是外卖小程序常见的「手机号即账号」策略。
拦截器的关键代码只有几行:从请求头取 token,去掉前 7 个字符(即 Bearer ),再调用 JwtUtil 校验。校验失败直接返回 401,业务代码里用BaseContext.getCurrentId()取当前用户。这里很容易踩的一个点是:JWT 的 payload 不能放敏感信息,放了 userId 和角色就够,放手机号、住址属于过度设计。
3.3 购物车接口:加购、改数量、清空的幂等设计
购物车的核心是「加购」的幂等判断。用户反复点同一道菜,结果应该是数量累加而不是生成两条记录。
@Override public void add(ShoppingCart cart) { Long userId = BaseContext.getCurrentId(); cart.setUserId(userId); ShoppingCart exist = lambdaQuery() .eq(ShoppingCart::getUserId, userId) .eq(cart.getDishId() != null, ShoppingCart::getDishId, cart.getDishId()) .eq(cart.getSetmealId() != null, ShoppingCart::getSetmealId, cart.getSetmealId()) .one(); if (exist != null) { exist.setNumber(exist.getNumber() + 1); updateById(exist); } else { cart.setNumber(1); save(cart); } }注意这里的eq(条件, 列, 值)三参写法:第一个参数是布尔条件,为 true 时拼接该列的条件。这样dish_id和setmeal_id就形成了二选一的查询逻辑,不会出现「一道菜和一个套餐因为其中一个字段都为空而被判定为同一个商品」的乌龙。
改数量和清空购物车相对简单。清空按钮对应DELETE /api/shoppingCart/clean,后端直接按当前 userId 执行批量删除;要注意清空操作必须限定 userId,否则会把别的用户购物车删掉。购物车的每一行保存了name/image/amount这几个冗余字段,是为了列表渲染时免去二次查表和联表查询,加购时从菜品表复制进来即可。
3.4 下单接口:订单号生成与金额计算
提交订单是整个系统里事务最重的一个接口。它要做四件事:生成订单号、写入订单主表、把购物车明细转成订单明细、清空购物车。这四步必须在一个事务里,任何一个失败都要全部回滚。
@Transactional(rollbackFor = Exception.class) public Long submit(OrderSubmitDTO dto) { Orders order = new Orders(); order.setNumber(genOrderNumber()); order.setUserId(BaseContext.getCurrentId()); order.setAddressBookId(dto.getAddressBookId()); order.setStatus(1); order.setPayStatus(0); order.setOrderTime(LocalDateTime.now()); order.setAmount(dto.getAmount()); order.setRemark(dto.getRemark()); order.setPayMethod(dto.getPayMethod()); AddressBook address = addressBookMapper.selectById(dto.getAddressBookId()); order.setConsignee(address.getConsignee()); order.setPhone(address.getPhone()); order.setAddress(joinAddress(address)); orderMapper.insert(order); copyCartToOrderDetail(order.getId()); cartMapper.deleteByUserId(order.getUserId()); return order.getId(); }订单号生成是这里面最容易出问题的一块。我采用「时间戳 + 三位随机数」的组合:yyyyMMddHHmmss加三位随机数,基本保证单机不冲突;数据库里再对number建唯一索引兜底。真要遇到唯一键冲突,业务层捕获后重新生成一次即可,不要无限重试。金额计算不建议由前端拼好传后端,下单接口里应当根据购物车明细重新汇总一遍金额,再和前端传上来的金额做比对,哪怕前端多传了 0.01 元也应该直接拒绝或以后端计算为准。
private String genOrderNumber() { return DateTimeFormatter.ofPattern("yyyyMMddHHmmss") .format(LocalDateTime.now()) + String.format("%03d", ThreadLocalRandom.current().nextInt(1000)); }在下单接口的复制明细逻辑里,我还会顺带处理一个「当日沽清」的库存场景:对dish表执行update dish set stock = stock - #{number} where id = #{id} and stock >= #{number},返回影响行数如果为 0,说明库存不足,直接抛异常。这比先查再减要稳,因为两条并发请求同时读到库存为 1 时,先查再减会两次都成功,条件更新只会成功一次,这就是乐观锁的核心思路。
3.5 管理端接口:菜品上下架与派单流程
管理端的核心动作是「菜品状态变更」和「订单派送」。
@Transactional(rollbackFor = Exception.class) public void updateStatus(Long dishId, Integer status) { dishMapper.update(null, new LambdaUpdateWrapper<Dish>() .eq(Dish::getId, dishId) .set(Dish::getStatus, status)); Dish dish = dishMapper.selectById(dishId); String cacheKey = "cache:dish:category:" + dish.getCategoryId(); redisTemplate.delete(cacheKey); }这里删除缓存的时机很关键:先更新数据库,再删缓存,是「旁路缓存」的标准做法。如果先删缓存再更新数据库,极端情况下会有线程把旧数据写回缓存,导致门店已经停售的菜在小程序里还能被下单。派单接口则是一条状态更新:从「已接单」改为「派送中」,同时记录estimated_delivery_time。这里不需要复杂状态机,后面第 5 章专门讲状态转移的合法路径。
4. 双端页面联调:小程序点餐端与管理后台的落地细节
后端接口就位后,前端的工作量其实不小:小程序要覆盖用户主流程,管理后台要覆盖商家主流程。很多项目死在前后端「各自为战」——后端字段命名和前端对不上,页面改了三轮还联不通。这一章讲我如何把双端真正跑通。
4.1 小程序端页面结构:首页、点餐、购物车、我的
小程序端我用原生框架加一个统一的request.js封装,tabBar 四个页签分别是:首页、点餐、购物车、我的。点餐页按左侧分类、右侧菜品列表的经典布局;点菜后购物车图标上会显示角标,点击进入购物车页面改数量或提交订单。这一套页面结构在多数外卖小程序里都成立,适合作为练手模板。
关键辅助函数是请求封装,它决定所有接口能不能顺利调通。baseURL 指向本地后端地址,每次请求自动带上登录后存下来的 token;遇到 401 就清理本地登录态并跳回登录页。以下是小程序端加购的核心调用。
// api/shoppingCart.js const addToCart = (dishId, number = 1) => { return request({ url: '/api/shoppingCart/add', method: 'POST', data: { dishId, number } }) } // pages/point/point.js 中调用 async function handleAddDish(e) { const dish = e.currentTarget.dataset.dish const res = await addToCart(dish.id) if (res.code === 1) { wx.showToast({ title: '已加入购物车', icon: 'success' }) } }request封装里我做了三件事:把Authorization: Bearer头塞进每个请求;把后端返回的{code, data, msg}统一解包;对 401 做全局拦截。小程序里没有浏览器跨域的概念,但要小心开发工具里的「不校验合法域名」开关,这个下面排雷章节会细讲。
4.2 管理后台用 Vue3 + Element Plus 搭出业务闭环
管理后台我用 Vue 3 + Element Plus,页面不多但必须闭环:登录页、菜品分类管理、菜品列表(含上下架和新增)、套餐管理、订单列表(含接单/派送操作)、营业统计首页。菜品管理的表单要覆盖 name、categoryId、price、image、status 几个字段。
// views/dish/DishForm.vue 要点 const dishForm = reactive({ name: '', categoryId: null, price: 0, status: 1 }) function saveDish() { dishApi.save({ ...dishForm, price: Number(dishForm.price).toFixed(2) }).then(() => { ElMessage.success('保存成功') emit('refresh') }) }这里特意强调金额的处理:表单里让用户输入普通字符串,提交前用toFixed(2)规范成两位小数,再传给后端。JavaScript 的浮点计算对 0.1 + 0.2 这类操作会产生误差,绝不能在前端直接做金额累加;展示金额用Number(price).toFixed(2)兜底,后端才做真正的计算。
4.3 前后端联调:三个变量名不一致导致的返工
联调阶段最容易翻车的不是逻辑,是字段名。后端把套餐 id 命名成setmealId,前端写成了mealId;后端返回的时间格式是2026-06-01T12:00:00,前端却按2026-06-01 12:00:00去格式化,显示直接异常。我的建议是开工前先花半天定一份接口字段表,把每个接口的入参、出参、字段类型、字段含义都列出来,用格式化好的文档或 markdown 表即可,不用上重型工具。联调时前端只认文档,后端只认文档,谁改谁先更新文档。
4.4 本地联调环境配置:域名、端口与代理
本地联调最省心的组合是:后端跑在127.0.0.1:8080,前端小程序用开发者工具指向同一个地址,后台管理页面通过 Vite 的 proxy 把/api代理到后端。后端要允许跨域,或者前后端统一走代理。我用 Vite 配置实现代理转发,小程序端不走代理,直接配 baseURL。管理端生产构建后部署到 nginx,/api/用proxy_pass转发到后端;图片上传后的回显路径也交给 nginx 统一映射,避免前后端各自存一套地址。
提示:本地联调时给小程序配
https://127.0.0.1是无效的,小程序开发者工具里要在「本地设置」勾选不校验合法域名,否则 wx.request 直接进不了本地后端。
5. 并发、精度与缓存:5 个高频排查点与避坑记录
双端跑通只是开始。上线前你会发现真正的麻烦集中在这五类问题上,我把现象、原因和解决办法一条条写清楚,都是我自己踩过或帮朋友排查过的血泪经验。
5.1 订单状态机:待付款到已完成,哪些路径合法
订单状态流转不是任意跳的。合法的路径是:待付款 -> 待接单;待付款 -> 已取消(用户主动取消);待接单 -> 已接单;已接单 -> 派送中;派送中 -> 已完成;待接单/已接单 -> 已取消(商家拒单或超时)。另外还有一条:待付款超时关单,从待付款直接到已取消。我在代码里不会引入复杂状态机框架,而是给每个状态变更接口加一行合法前置校验,比如接单接口只允许当前状态是 2。
if (!order.getStatus().equals(2)) { throw new BusinessException("当前订单状态不允许接单"); }这套写法的好处是逻辑直观、好排查。缺点是需要人工维护状态表,如果以后状态多了,再考虑引入状态机引擎也不迟。排查状态问题时,先看数据库里当前 status 的值,再对照合法路径判断是代码问题还是数据被手工改坏。
5.2 坑一:热门餐厅爆单,库存扣成负数
现象:午高峰 100 个用户同时提交包含同一道菜的订单,后台发现库里这道菜库存变成了 -3。 原因:下单明细的扣库存逻辑写成了「先 select 库存,判断 > 0,再 update 减 1」;两个线程同时读到库存 1,都认为够卖,结果都执行更新,库存变成 -1。 解决:把扣减改成一条限定条件更新语句,库存不是负数时才会真的减。
int rows = dishMapper.deductStock(dishId, number); if (rows == 0) { throw new BusinessException(dishName + " 已沽清"); }UPDATE dish SET stock = stock - #{number} WHERE id = #{dishId} AND stock >= #{number};这段 SQL 只有库存足够时才更新成功,影响行数是 0 就说明当前并发下库存不足。要让这个方法生效,事务里必须加@Transactional(rollbackFor = Exception.class),否则先扣的订单在后面的订单抛异常回滚时,会把已经扣成功的库存也一起回滚。
5.3 坑二:Double 传金额,平账会平不掉
现象:用 Double 存订单金额,订单表里金额总和与明细对不上,差 0.03 元,怎么调都调不平。 原因:Double 是二进制浮点数,0.1 在内存里是无限循环小数,累加多次必然出现舍入误差。 解决:数据库一律 DECIMAL;Java 实体用 BigDecimal;接口入参也用 BigDecimal 或字符串接收;计算金额时统一setScale(2, RoundingMode.HALF_UP)之后再做加法。前端不做金额计算,只做展示。哪怕支付平台将来要的金额单位是「分」,也是后端计算好之后再去单位转换,全程不碰浮点。
BigDecimal total = BigDecimal.ZERO; for (OrderDetail detail : detailList) { total = total.add(detail.getAmount()); } total = total.setScale(2, RoundingMode.HALF_UP);5.4 坑三:下单写库成功,Redis 缓存没删掉
现象:管理后台把某道菜停售,小程序端点这道菜仍然能正常下单,过了十几分钟才恢复正常。 原因:菜品信息缓存了 Redis,更新数据库后没有删除缓存,导致小程序读到的还是旧状态。 解决:更新菜品状态和删除缓存放进同一个事务方法,先更新库再删缓存;同时给菜品缓存设置短 TTL(比如 10 分钟)作为兜底,即使删除失败,最多脏 10 分钟。如果要求更高,可以在删除缓存时引入延迟双删:先删一次,更新数据库,500 毫秒后再删一次,防止并发读把旧值回填。外卖场景的实时性要求没到那么绝对,短 TTL 兜底已经够用。
5.5 坑四:本地微信调试失效,token 突然 401
现象:小程序在开发者工具里点击登录,接口报 401;或者登录成功后过一会儿又 401。 原因:请求头里的 token 没带上去,或者本地环境没勾选「不校验合法域名」导致请求走了 mock 数据,根本没到后端;另一类是 JWT 里的 claims 解析失败,前端把 token 存在了 storage 里但格式被截断了。 解决:先看控制台有没有 request 请求发出、URL 是不是后端地址;再确认 request 封装里有没有把 storage 里的 token 拼进 header;最后后端日志里查看 JwtUtil 解析异常的具体信息,是过期还是签名不对。按这三步走,绝大多数 401 都能定位,不全是「后端 token 校验」的锅。
5.6 坑五:图片上传回显 403,读到的路径和浏览器不一致
现象:上传菜品图片成功后,后台回显正常,部署后浏览器访问图片地址返回 403。 原因:开发时图片存的本地绝对路径,部署后路径不含/images/前缀;或者 nginx 对/images/路径的alias配置指向了不存在的目录。 解决:图片上传接口返回相对 URL,比如/images/2026/dish_123.png,后端在返回给前端前拼好完整访问地址;部署时用 nginx 把/images/指向本地上传目录。前端只存储和展示后端返回的完整 URL,不要自己拼路径。
location /images/ { alias /data/takeout/upload/; expires 7d; }配置示意图说明:alias 后面必须以/结尾,否则访问/images/xxx.png时 nginx 会拼成/data/takeout/uploadxxx.png,表现就是 404 或 403。这类玄学错误多数不是权限,而是末尾少了斜杠。
6. 跑通后还能怎么长本事:提测清单、压测脚本与慢 SQL 定位
系统能点能下单,只是基本完成。我后面的习惯是加一套「上线前验证动作」,把可运行升级为可交代。
6.1 上线前必查的提测清单
我给自己定的清单只有五条,每一条都对应一类真实事故:第一,重复提交订单是否产生重复扣款,验证方式是在下单接口上连续点两次提交按钮,确认第二次被拦截;第二,金额计算是否前后端一致,用购物车里 0.1 元、0.2 元、0.3 元的组合分别下单,核对订单金额;第三,停售菜品是否还能被下单,先停售再回到小程序端刷新点餐页;第四,超时或取消订单后库存是否回补;第五,耗时超过 1 秒的接口是否走了缓存。这五条查完,基本能挡住大部分低级事故。
6.2 压力测试脚本:模拟 100 个并发用户同时下单
下单接口是最容易出问题的高频接口。我最常用的压测工具是 ab,它一条命令就能看出吞吐和错误率。
ab -n 200 -c 50 \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -p order.json \ -T application/json \ http://127.0.0.1:8080/api/order/submit参数含义:-n 200表示总请求 200 次,-c 50表示并发 50;-p order.json是请求体文件,里面放一个合法的下单 JSON;-H传 token 头。跑完之后重点看两个数:Requests per second和Failed requests。如果失败数不为零,再到后端日志里看是数据库连接不够、事务死锁还是库存条件冲突。压测脚本的请求体里每次下单的addressBookId要准备多个值,避免所有请求都压同一个地址簿记录。
6.3 慢 SQL 定位:不要瞎加索引
压测出现慢接口时,先开 MySQL 慢查询日志,再拿慢 SQL 去 EXPLAIN。
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SHOW VARIABLES LIKE 'slow_query_log%';EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 2;EXPLAIN 结果里重点看 type 和 key:type 出现 ALL 说明全表扫描;key 为空说明没用到索引。比如订单列表查询要同时按 user_id 和 status 过滤,那就建复合索引(user_id, status),而不是分别建两个单列索引。慢 SQL 定位起来并不难,难的是忍住「给所有查询字段都建索引」的冲动,那是拿写放大换读性能,外卖这种写频繁的系统并不划算。
这次复跑苍穹外卖,最大的教训是:数据模型先定对,后面所有接口和页面都顺;数据模型定了但状态流转没定,联调时就反复改。现在我每接一个类似系统,都是先列业务对象,再列状态流转,最后才写建表 SQL。希望帮到你。
本文还有配套的精品资源,点击获取