从零搭建点餐小程序后端:Spring Boot + MyBatis-Plus 实战

项目实战 · 约 36 分钟 · 更新于

点餐系统下单与库存扣减流程

图示展示本文的核心技术问题:如何通过条件更新和事务保证库存与订单的一致性。

我做过一个家庭点餐小程序,后端是 Spring Boot。这篇教程把它的核心后端抽出来,带完全没做过的新人从零搭一个能跑的点餐后端:装环境 → 初始化项目 → 写「商品列表」和「下单」接口。重点是下单时怎么用一条 SQL 防止库存超卖——这是电商/点餐类系统最容易踩的坑。

做完你将得到:启动一个 Spring Boot 服务,用 curl 能查到商品、能下单、下单后库存正确减少;而且就算两个人同时下单最后一件商品,也不会卖出两份。技术栈是 Spring Boot 3.2.0 + Java 17 + MyBatis-Plus 3.5.5 + MySQL 8。

第 1 步:装环境

后端跑在 JVM 上、用 Maven 管依赖、数据存到 MySQL。装三样东西:

  • JDK 17:到 adoptium.net 下载 JDK 17,一路下一步装完。终端输入 java -version,能看到 17.x 就行。
  • Maven:到 maven.apache.org 下载二进制包,解压后把 bin 目录加进系统 PATH。终端输入 mvn -v 能出版本号即可。
  • MySQL 8:为了省事,推荐用 Docker 一行起一个(没装 Docker 就去官网装 Docker Desktop):
docker run -d --name order-mysql \
  -e MYSQL_ROOT_PASSWORD=root \
  -e MYSQL_DATABASE=order_demo \
  -p 3306:3306 \
  mysql:8.0

这条命令的含义:后台跑一个 MySQL 8 容器,名字叫 order-mysql,root 密码设为 root,启动时自动建一个 order_demo 库,并把容器的 3306 端口映射到本机 3306。之后我们用 root/root 连它。

如果不想用 Docker,也可以去 mysql.com 下载 MySQL 8 安装包,装时设个 root 密码记牢即可。

第 2 步:初始化 Spring Boot 项目

Spring Boot 是 Java 生态最主流的后端框架,帮你把内嵌服务器、依赖管理都配好,写少量代码就能起一个 Web 服务。有两种初始化方式,任选其一:

方式 A(推荐新手):打开 start.spring.io,这样选:

  • Project: Maven,Language: Java,Spring Boot: 3.2.0,Group: com.example,Artifact: order-demo,Packaging: Jar,Java: 17
  • 依赖右边「Add Dependencies」勾:Spring Web、Validation、Lombok、MySQL Driver
  • 点 Generate 下载 zip,解压后就是项目骨架。

方式 B:自己建个空目录,放一个 pom.xml(Maven 的依赖清单),完整内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>order-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!-- MyBatis-Plus:在 Spring Boot 上操作数据库的增强工具,少写大量 SQL -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>3.5.5</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

注意:Spring Boot 3.x 要用 mybatis-plus-spring-boot3-starter(带 boot3),不是老的 mybatis-plus-boot-starter,否则起不来。

项目里 Java 文件统一放这个包路径下:src/main/java/com/example/orderdemo/,子目录分别建 entity、mapper、service、controller、dto、common。

第 3 步:配数据库连接 + 建表

在 src/main/resources/ 下建 application.yml(Spring Boot 启动时自动读这个配置文件):

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/order_demo?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowPublicKeyRetrieval=true
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true   # 数据库下划线字段 → Java 驼峰属性,自动对应
  global-config:
    db-config:
      id-type: auto                      # 主键用数据库自增

logging:
  level:
    com.example.orderdemo: debug          # 打开自己包的日志,方便看 SQL

再建一个 init.sql,用来建表和塞两条示例数据。可以用 MySQL 命令行执行,也可以用图形工具(如 DBeaver)连上后执行:

CREATE DATABASE IF NOT EXISTS order_demo DEFAULT CHARSET utf8mb4;
USE order_demo;

CREATE TABLE t_product (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(64) NOT NULL,
  price DECIMAL(10,2) NOT NULL,
  stock INT NOT NULL DEFAULT 0,          -- 库存
  sales INT NOT NULL DEFAULT 0,          -- 已售
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE t_order (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  product_id BIGINT NOT NULL,
  quantity INT NOT NULL,
  total_price DECIMAL(10,2) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO t_product (name, price, stock, sales) VALUES
('红烧肉套餐', 28.00, 10, 0),
('清炒时蔬', 12.00, 20, 0);

金额用 DECIMAL 不用浮点,因为 float/double 有精度误差,钱不能错。

第 4 步:写实体类(对应数据库表)

「实体类」就是一个 Java 类,字段和数据库表的列一一对应。MyBatis-Plus 靠它知道把哪张表的哪列映射到哪个属性。

entity/Product.java:

package com.example.orderdemo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;

@Data                              // Lombok 注解:自动生成 get/set/toString
@TableName("t_product")           // 告诉 MyBatis-Plus 这个类对应 t_product 表
public class Product {
    @TableId(type = IdType.AUTO)   // 主键,由数据库自增
    private Long id;
    private String name;
    private BigDecimal price;
    private Integer stock;
    private Integer sales;
}

entity/Order.java:

package com.example.orderdemo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;

@Data
@TableName("t_order")
public class Order {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long productId;        // 对应列 product_id,下划线转驼峰自动对应
    private Integer quantity;
    private BigDecimal totalPrice; // 对应列 total_price
}

有了第 3 步配的 map-underscore-to-camel-case,product_id 列会自动映射到 productId 属性,不用手写注解。

第 5 步:写 Mapper(操作数据库的接口)

Mapper 是个接口,MyBatis-Plus 的 BaseMapper 已经帮你写好了「按 id 查、查全部、插入、更新、删除」等常用方法,大部分情况你一行 SQL 都不用写。只有特殊的——比如扣库存——才需要自己加一条。

mapper/ProductMapper.java:

package com.example.orderdemo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.orderdemo.entity.Product;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

public interface ProductMapper extends BaseMapper<Product> {
    // 关键:带 WHERE stock >= #{qty} 条件的扣库存,返回受影响行数
    @Update("UPDATE t_product SET stock = stock - #{qty}, sales = sales + #{qty} WHERE id = #{id} AND stock >= #{qty}")
    int deductStock(@Param("id") Long id, @Param("qty") int qty);
}

mapper/OrderMapper.java(只用到插入,继承 BaseMapper 就够了):

package com.example.orderdemo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.orderdemo.entity.Order;

public interface OrderMapper extends BaseMapper<Order> {
}

为什么这条 SQL 能防超卖?因为 MySQL 执行一条 UPDATE 是原子的:stock >= qty 这个条件在一次执行里判断和扣减不会被打断。如果库存只剩 1、有人想下 2 份,WHERE stock >= 2 不成立,受影响行数为 0——我们据此就知道这次没扣成功。这比「先 SELECT 查库存、再 UPDATE 扣」安全得多,后者在两次查询之间可能被别的请求插队,导致超卖。

第 6 步:写业务逻辑(下单)

把下单逻辑放在 Service 层,并用 @Transactional 包成一个事务:扣库存和写订单要么都成功,要么都失败(回滚),不会出现「库存扣了但订单没生成」。

先写接收请求参数的小类 dto/OrderRequest.java:

package com.example.orderdemo.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

@Data
public class OrderRequest {
    @NotNull(message = "商品 id 不能为空")
    private Long productId;
    @NotNull(message = "数量不能为空")
    @Min(value = 1, message = "数量至少为 1")
    private Integer quantity;
}

@NotNull、@Min 是参数校验注解,后面 Controller 上加一个 @Valid 就会自动校验,不合法直接返回错误,不用自己写 if 判断。

service/ProductService.java:

package com.example.orderdemo.service;

import com.example.orderdemo.entity.Order;
import com.example.orderdemo.entity.Product;
import com.example.orderdemo.mapper.OrderMapper;
import com.example.orderdemo.mapper.ProductMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service                           // 让 Spring 管理这个类,用的时候自动注入
@RequiredArgsConstructor          // Lombok:给 final 字段生成构造方法注入
public class ProductService {
    private final ProductMapper productMapper;
    private final OrderMapper orderMapper;

    @Transactional                // 事务:方法内任何异常都整体回滚
    public Order placeOrder(Long productId, int quantity) {
        Product product = productMapper.selectById(productId);  // BaseMapper 自带:按 id 查
        if (product == null) {
            throw new RuntimeException("商品不存在");
        }
        // 关键:用条件 UPDATE 扣库存,看返回的受影响行数
        int affected = productMapper.deductStock(productId, quantity);
        if (affected == 0) {
            throw new RuntimeException("库存不足,下单失败");
        }
        // 库存扣成功,写订单
        Order order = new Order();
        order.setProductId(productId);
        order.setQuantity(quantity);
        order.setTotalPrice(product.getPrice().multiply(BigDecimal.valueOf(quantity)));
        orderMapper.insert(order);                              // BaseMapper 自带:插入
        return order;
    }
}

第 7 步:写 Controller(对外暴露接口)和统一返回格式

Controller 把 Service 的能力暴露成 HTTP 接口。先把返回格式统一成一个壳子 {code, message, data},前端好处理。建 common/Result.java:

package com.example.orderdemo.common;

import lombok.Data;

@Data
public class Result<T> {
    private int code;
    private String message;
    private T data;

    public static <T> Result<T> ok(T data) {
        Result<T> r = new Result<>();
        r.code = 0;
        r.message = "ok";
        r.data = data;
        return r;
    }

    public static <T> Result<T> error(String msg) {
        Result<T> r = new Result<>();
        r.code = -1;
        r.message = msg;
        r.data = null;
        return r;
    }
}

再写一个全局异常处理 common/GlobalExceptionHandler.java:任何接口抛异常都会被它兜住,转成统一格式,前端不会收到一串堆栈:

package com.example.orderdemo.common;

import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice            // 全局异常处理:所有 Controller 抛的异常都走这里
public class GlobalExceptionHandler {
    @ExceptionHandler(RuntimeException.class)
    public Result<?> handle(RuntimeException e) {
        return Result.error(e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)   // 参数校验不通过
    public Result<?> handle(MethodArgumentNotValidException e) {
        FieldError fe = e.getBindingResult().getFieldError();
        String msg = fe != null ? fe.getDefaultMessage() : "参数错误";
        return Result.error(msg);
    }
}

controller/ProductController.java(商品列表 / 详情):

package com.example.orderdemo.controller;

import com.example.orderdemo.common.Result;
import com.example.orderdemo.entity.Product;
import com.example.orderdemo.mapper.ProductMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
    private final ProductMapper productMapper;

    @GetMapping
    public Result<List<Product>> list() {
        return Result.ok(productMapper.selectList(null));   // null 表示无查询条件,查全部
    }

    @GetMapping("/{id}")
    public Result<Product> get(@PathVariable Long id) {
        Product p = productMapper.selectById(id);
        if (p == null) throw new RuntimeException("商品不存在");
        return Result.ok(p);
    }
}

controller/OrderController.java(下单):

package com.example.orderdemo.controller;

import com.example.orderdemo.common.Result;
import com.example.orderdemo.dto.OrderRequest;
import com.example.orderdemo.entity.Order;
import com.example.orderdemo.service.ProductService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
    private final ProductService productService;

    @PostMapping
    public Result<Order> place(@Valid @RequestBody OrderRequest req) {
        Order order = productService.placeOrder(req.getProductId(), req.getQuantity());
        return Result.ok(order);
    }
}

最后是启动类 DemoApplication.java(放 com.example.orderdemo 包根下)。@MapperScan 告诉 MyBatis-Plus 去哪个包扫 Mapper 接口:

package com.example.orderdemo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.example.orderdemo.mapper")
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

第 8 步:运行并验证

在项目根目录(pom.xml 所在目录)执行:

mvn spring-boot:run

看到日志里有 Started DemoApplication 和 Tomcat started on port 8080 就成了。开另一个终端用 curl 验证:

# 1. 查商品列表,能看到红烧肉套餐(stock=10) 和清炒时蔬(stock=20)
curl http://localhost:8080/api/products

# 2. 下单 2 份红烧肉套餐
curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"productId":1,"quantity":2}'

# 3. 再查红烧肉套餐,stock 应变成 8、sales 变成 2
curl http://localhost:8080/api/products/1

想验证防超卖,把第 1 步示例数据的 stock 改成 1,然后用两个终端同时发下单请求买 1 份——只有一个人会成功,另一个会收到「库存不足,下单失败」。这就是那条带条件的 UPDATE 在起作用。

下一步:从后端到完整系统

这个后端骨架能跑通后,离真实的点餐小程序还差这几块(都是我那个真实项目里做了的):

  • 登录鉴权:用 JWT 区分「普通用户」和「管理员」角色,下单接口要登录、改库存的接口要管理员。微信小程序端拿 code 走 jscode2session 换 openid 当用户主键。
  • 小程序前端:用 uni-app + Vue 3 写点餐界面,编译成微信小程序跑在手机上。
  • 后台管理 Web:用 Vue 3 + Element Plus 做菜品增删改查、订单查看。
  • 部署:用 Docker Compose 把 MySQL、后端、后台前端一起编排,多阶段构建让服务器上不用装 JDK/Node,docker compose up -d --build 即可上线;公网入口可以用 Cloudflare 隧道。

但核心你已经握住了——Spring Boot 分层结构(Controller/Service/Mapper/Entity)+ 用一条条件 UPDATE 原子扣库存防超卖 + 事务保证一致性。绝大多数后端业务系统,都是在这套骨架上加东西。

分层结构:Controller、Service、Mapper 各自负责什么

一个可维护的 Spring Boot 项目不应该把 SQL、参数校验和业务逻辑全写在 Controller 中。本文示例采用经典分层:

HTTP Request
    ↓
Controller:路由、DTO、HTTP 状态码
    ↓
Service:事务、库存、价格和订单规则
    ↓
Mapper:MyBatis-Plus CRUD / 自定义 SQL
    ↓
MySQL:商品、订单、库存

分层不是为了增加文件数量,而是为了让变化隔离:接口字段变化只影响 DTO,库存规则变化只影响 Service,SQL 优化只影响 Mapper。

库存扣减为什么不会超卖

错误写法通常是两步:

SELECT stock FROM t_product WHERE id = ?;
-- Java 判断 stock >= quantity
UPDATE t_product SET stock = stock - ? WHERE id = ?;

并发下两个请求都可能先读到库存 1,然后都通过判断,最终库存变成负数或卖出两份。

更安全的写法是把判断和修改合成一条条件更新:

UPDATE t_product
SET stock = stock - #{quantity},
    sales = sales + #{quantity}
WHERE id = #{productId}
  AND stock >= #{quantity};

应用层只判断 affectedRows:

  • 1:扣减成功,可以继续写订单。
  • 0:商品不存在或库存不足,抛出业务异常。

数据库行锁会保证同一行的更新按顺序执行,条件在真正更新时重新判断。这是本项目最重要的并发安全设计。

事务边界与异常处理

@Transactional 应放在 Service 的用例方法上,而不是 Controller:

@Transactional(rollbackFor = Exception.class)
public Order placeOrder(OrderRequest request) {
    int affected = productMapper.deductStock(
        request.getProductId(), request.getQuantity());
    if (affected != 1) {
        throw new InsufficientStockException("库存不足");
    }
    Order order = orderMapper.insertAndReturn(...);
    return order;
}

生产代码还应该补充:

  • 自定义 BusinessException,区分库存不足、商品不存在和系统异常。
  • @RestControllerAdvice 统一转换错误响应。
  • 记录 traceId、用户标识和商品 ID,便于排查订单问题。
  • 对重复请求使用幂等键,避免用户重复点击造成重复订单。
  • 金额使用 BigDecimal 和 DECIMAL,禁止 double 参与金额计算。

数据库索引与锁的考虑

基础表至少需要:

CREATE INDEX idx_product_created_at ON t_product(created_at);
CREATE INDEX idx_order_product_id ON t_order(product_id);

如果订单列表按用户、时间查询,应建立联合索引,例如:

CREATE INDEX idx_order_user_created
ON t_order(user_id, created_at DESC);

索引不是越多越好:每次写入都要维护索引,应该根据真实查询条件通过 EXPLAIN 验证。库存扣减是单行条件更新,通常比“先查询后更新”减少一次网络往返,也减少竞态窗口。

接口设计建议

建议接口明确区分资源和动作:

方法路径用途
GET/api/v1/products商品列表,支持分页和上下架过滤
POST/api/v1/orders创建订单,要求幂等键
GET/api/v1/orders/{id}查询订单详情
POST/api/v1/orders/{id}/cancel取消订单并回补库存

统一返回格式时,HTTP 状态码和业务码要各司其职:400 表示参数问题,401 表示未登录,409 表示库存或状态冲突,500 才表示未预期的服务错误。

测试与并发验证

单元测试

  • 数量小于 1 时校验失败。
  • 商品不存在时不产生订单。
  • 库存不足时不产生订单。
  • 数据库插入失败时库存回滚。
  • 金额计算使用 BigDecimal,小数不丢失。

并发测试

准备库存为 1 的商品,同时发送 20 个下单请求,预期结果:

成功订单:1
失败请求:19
最终库存:0
最终订单数:1

可以使用 JMeter、wrk 或一段 Java/Python 并发脚本进行验证。测试不能只看 HTTP 200,还要查询数据库确认库存、订单和销售数量三者一致。

从单体 Demo 到生产服务

继续演进时可以按这个顺序:

  1. 加 Spring Boot Actuator 健康检查和指标。
  2. 加 Flyway/Liquibase 管理数据库版本。
  3. 加 Redis 做热点商品缓存,但库存扣减仍以数据库为最终事实源。
  4. 订单创建改为事件驱动时,引入消息队列和 Outbox,保证数据库与消息最终一致。
  5. 增加限流、熔断、审计日志和链路追踪。
  6. 通过 Docker Compose 或 Kubernetes 做灰度发布和回滚。
阳熙

Java / 保险业务系统开发 · 无锡

← 返回文章列表

评论

还没有评论,来留第一条吧。

首次评论会做人机校验