图示展示本文的核心技术问题:如何通过条件更新和事务保证库存与订单的一致性。
我做过一个家庭点餐小程序,后端是 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 到生产服务
继续演进时可以按这个顺序:
- 加 Spring Boot Actuator 健康检查和指标。
- 加 Flyway/Liquibase 管理数据库版本。
- 加 Redis 做热点商品缓存,但库存扣减仍以数据库为最终事实源。
- 订单创建改为事件驱动时,引入消息队列和 Outbox,保证数据库与消息最终一致。
- 增加限流、熔断、审计日志和链路追踪。
- 通过 Docker Compose 或 Kubernetes 做灰度发布和回滚。
评论
还没有评论,来留第一条吧。