数据持久化与并发安全:让系统真正扛得住
2026/6/6 8:41:01 网站建设 项目流程

系列专栏:从Java到AI应用开发| 第5篇

写在前面

上篇搭完了电商系统,能下单、能支付、能退款,看起来像个样子了。但留了两个问题:

  1. 两个人同时买最后一件商品,会不会超卖?
  2. 下了单一直不付款怎么办?

这两个问题的本质,一个是并发安全,一个是数据可靠性。而它们的共同解法指向同一件事——从Excel存储升级到真正的数据库

Excel是好老师,让你理解了"存和取"的本质,但它撑不起真实业务。今天我们把数据层彻底换掉,同时解决并发安全问题。

一、为什么必须换掉Excel

上篇的Repository层,底层全是在读写Excel文件。这在学习阶段没问题,但真实场景有几个致命问题:

表格

问题Excel数据库
并发写入直接覆盖,数据丢失行级锁,排队执行
事务支持没有ACID保证
查询能力全表扫描索引加速
数据量几千行就卡百万级没问题
崩溃恢复文件损坏就没了有日志可以恢复

最致命的是第一个:并发写入。两个请求同时修改同一个商品的库存,Excel会互相覆盖,最后只保留一个的结果。

二、引入MySQL + Spring Data JPA

2.1 加依赖

xml

99

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

<!-- pom.xml -->

<dependencies>

<!-- Spring Data JPA -->

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-data-jpa</artifactId>

</dependency>

<!-- MySQL驱动 -->

<dependency>

<groupId>com.mysql</groupId>

<artifactId>mysql-connector-j</artifactId>

<scope>runtime</scope>

</dependency>

</dependencies>

2.2 配置数据库连接

yaml

99

1

2

3

4

5

6

7

8

9

10

11

12

13

14

# application.yml

spring:

datasource:

url: jdbc:mysql://localhost:3306/ecommerce?useSSL=false&serverTimezone=Asia/Shanghai

username: root

password: your_password

jpa:

hibernate:

ddl-auto: update # 开发阶段自动建表,生产环境用validate

show-sql: true # 打印SQL,方便调试

properties:

hibernate:

format_sql: true # 格式化SQL

2.3 实体类改造

之前我们的Model是纯POJO,现在要加上JPA注解,告诉数据库怎么存:

java

99

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

@Entity

@Table(name = "products")

public class Product {

@Id

@GeneratedValue(strategy = GenerationType.UUID)

private String id;

@Column(nullable = false, length = 100)

private String name;

@Column(length = 50)

private String category;

@Column(nullable = false, precision = 10, scale = 2)

private BigDecimal price;

@Column(precision = 10, scale = 2)

private BigDecimal costPrice;

@Column(nullable = false)

private int stock;

@Column(nullable = false)

private int sold;

@Column(length = 500)

private String description;

@Column(name = "create_time", updatable = false)

private LocalDateTime createTime;

@Column(name = "update_time")

private LocalDateTime updateTime;

// JPA要求有无参构造器

public Product() {}

@PrePersist // 插入前自动填充

protected void onCreate() {

createTime = LocalDateTime.now();

updateTime = LocalDateTime.now();

}

@PreUpdate // 更新前自动填充

protected void onUpdate() {

updateTime = LocalDateTime.now();

}

// getter/setter ...

}

几个关键注解:

  • @Entity→ 标记这是一个数据库实体
  • @Table(name = "products")→ 指定表名(不写就默认用类名小写)
  • @Column→ 定义列的约束(是否可空、长度、精度)
  • @PrePersist/@PreUpdate→ JPA生命周期回调,自动填充时间字段

Order实体稍微特殊一点,因为它和OrderItem是一对多关系:

java

99

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

@Entity

@Table(name = "orders")

public class Order {

@Id

private String id; // 我们自己生成的订单号

@Column(nullable = false)

private String userId;

@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)

@JoinColumn(name = "order_id")

private List<OrderItem> items;

@Column(name = "total_amount", precision = 10, scale = 2)

private BigDecimal totalAmount;

@Column(name = "discount_amount", precision = 10, scale = 2)

private BigDecimal discountAmount;

@Column(name = "pay_amount", precision = 10, scale = 2)

private BigDecimal payAmount;

@Enumerated(EnumType.STRING) // 枚举存字符串,可读性好

private OrderStatus status;

@Column(name = "coupon_id")

private String couponId;

private String address;

@Column(name = "create_time", updatable = false)

private LocalDateTime createTime;

@Column(name = "pay_time")

private LocalDateTime payTime;

@Column(name = "ship_time")

private LocalDateTime shipTime;

@Column(name = "deliver_time")

private LocalDateTime deliverTime;

public Order() {}

@PrePersist

protected void onCreate() {

createTime = LocalDateTime.now();

}

}

@OneToMany(cascade = CascadeType.ALL)→ 保存Order时,关联的OrderItem也会自动保存。这就是级联操作,省得我们手动一个一个存。

2.4 Repository改造:从手写Excel到接口继承

之前我们的Repository要手写Excel读写逻辑,现在?继承一个接口就完了:

java

99

1

2

3

4

5

6

7

8

9

10

11

public interface ProductRepository extends JpaRepository<Product, String> {

// 按分类查

List<Product> findByCategory(String category);

// 按名称模糊搜索

List<Product> findByNameContaining(String keyword);

// 按分类+名称组合查

List<Product> findByCategoryAndNameContaining(String category, String keyword);

// 热销排行

List<Product> findTop10ByOrderBySoldDesc();

}

这就是Spring Data JPA的魔法——你只写方法名,它自动生成SQL。findByNameContaining会变成WHERE name LIKE '%xxx%'findTop10ByOrderBySoldDesc会变成ORDER BY sold DESC LIMIT 10

CartRepository也一样:

java

9

1

2

3

4

5

6

public interface CartRepository extends JpaRepository<CartItem, String> {

List<CartItem> findByUserId(String userId);

Optional<CartItem> findByUserIdAndProductId(String userId, String productId);

void deleteByUserId(String userId);

}

对比一下改造前后的代码量:之前每个Repository动辄100多行Excel读写代码,现在3-5行接口定义。这省下来的时间,拿去写业务逻辑。

2.5 Service层几乎不用改

这是分层架构最大的好处——Repository换了实现,Service层代码基本不用动。

java

99

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

@Service

public class ProductServiceImpl implements ProductService {

@Autowired

private ProductRepository productRepository; // 接口没变,只是实现从Excel变成了JPA

@Override

@Transactional

public void updateStock(String productId, int quantity) {

// 代码和之前一模一样!

Product product = productRepository.findById(productId)

.orElseThrow(() -> new BusinessException("商品不存在"));

int newStock = product.getStock() + quantity;

if (newStock < 0) {

throw new BusinessException("库存不足");

}

product.setStock(newStock);

product.setUpdateTime(LocalDateTime.now());

productRepository.save(product);

}

}

面向接口编程的价值就在这里:底层存储换了,业务代码零修改。

三、超卖问题:并发场景下的一道坎

3.1 问题描述

库存只剩1件,用户A和用户B同时下单,各买1件:

plaintext

9

1

2

3

4

5

6

时刻1:用户A读到 stock = 1

时刻2:用户B读到 stock = 1 ← 两人都认为还有库存

时刻3:用户A写入 stock = 0

时刻4:用户B写入 stock = 0 ← 覆盖了A的写入,库存从0又变成0

结果:两人都下单成功,但实际只有1件商品 → 超卖!

这个问题用Excel无解(Excel根本没有并发控制),但数据库有几种方案。

3.2 方案一:乐观锁(推荐)

思路:给表加一个版本号字段,每次更新时检查版本号是否变化。如果变了,说明别人改过,本次更新失败。

java

9

1

2

3

4

5

6

7

8

9

@Entity

@Table(name = "products")

public class Product {

// ... 其他字段

@Version

private Integer version; // 乐观锁版本号

}

就加一个@Version注解,JPA自动处理。

原理看SQL就明白了:

sql

9

1

2

3

4

5

6

-- 更新时JPA自动生成的SQL

UPDATE products

SET stock = 0, version = 2

WHERE id = 'xxx' AND version = 1;

-- ^^^^^^^^ 只在版本号匹配时才更新

如果两个人同时读到 version=1,第一个人的更新成功(version变成2),第二个人更新时WHERE version = 1已经不匹配了,影响行数为0 → JPA抛出OptimisticLockException

Service层处理:

java

99

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

@Override

@Transactional

public void updateStock(String productId, int quantity) {

Product product = productRepository.findById(productId)

.orElseThrow(() -> new BusinessException("商品不存在"));

int newStock = product.getStock() + quantity;

if (newStock < 0) {

throw new BusinessException("库存不足");

}

product.setStock(newStock);

product.setUpdateTime(LocalDateTime.now());

try {

productRepository.save(product);

} catch (ObjectOptimisticLockingFailureException e) {

throw new BusinessException("操作太频繁,请重试");

}

}

乐观锁适合"读多写少"的场景——大多数时候不会冲突,偶尔冲突了让用户重试就行。商品库存刚好符合这个特征。

3.3 方案二:悲观锁

思路:读取数据时就直接锁住这一行,别人连读都读不到(只能等),直到我改完释放锁。

java

99

1

2

3

4

5

6

7

8

9

10

11

public interface ProductRepository extends JpaRepository<Product, String> {

/**

* 悲观锁查询:SELECT ... FOR UPDATE

* 拿到这行数据后,其他事务不能修改,直到当前事务提交

*/

@Lock(LockModeType.PESSIMISTIC_WRITE)

@Query("SELECT p FROM Product p WHERE p.id = :id")

Optional<Product> findByIdForUpdate(@Param("id") String id);

}

java

99

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

@Override

@Transactional

public void updateStock(String productId, int quantity) {

// 用悲观锁查询,这行数据被锁定

Product product = productRepository.findByIdForUpdate(productId)

.orElseThrow(() -> new BusinessException("商品不存在"));

int newStock = product.getStock() + quantity;

if (newStock < 0) {

throw new BusinessException("库存不足");

}

product.setStock(newStock);

productRepository.save(product);

// 方法结束,事务提交,锁释放

}

**悲观锁适合"写多"或者冲突频繁的场景 **——抢购、秒杀这种,几乎每次都会冲突,乐观锁重试代价太大。

3.4 两种锁怎么选

表格

维度乐观锁悲观锁
原理版本号检测,冲突时失败读取时锁行,冲突时排队等
性能无冲突时很快每次都要加锁,有开销
冲突处理抛异常,让上层重试自动排队等,对调用方透明
适用场景读多写少,偶尔冲突写多冲突频繁,如秒杀
死锁风险有(多个锁交叉时可能死锁)

**一般电商推荐乐观锁 **,简单安全。秒杀场景用悲观锁或更专门的方案(Redis原子操作)。

四、超时未支付:定时任务自动取消

上篇留的第二个问题:用户下单了不付款,库存一直被占着怎么办?

4.1 思路

给订单加一个**超时时间 **(比如30分钟),到了时间还没付款就自动取消,归还库存。

4.2 Spring定时任务

Spring Boot内置了定时任务支持,不需要额外依赖。

第一步:开启定时任务

java

9

1

2

3

4

5

6

7

8

@SpringBootApplication

@EnableScheduling // 加这个注解

public class EcommerceApplication {

public static void main(String[] args) {

SpringApplication.run(EcommerceApplication.class, args);

}

}

第二步:写定时任务

java

99

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

@Component

public class OrderTimeoutTask {

@Autowired

private OrderRepository orderRepository;

@Autowired

private ProductRepository productRepository;

@Autowired

private CouponRepository couponRepository;

/**

* 每5分钟执行一次,检查超时未支付的订单

*/

@Scheduled(fixedRate = 5 * 60 * 1000) // 5分钟

@Transactional

public void cancelTimeoutOrders() {

LocalDateTime timeout = LocalDateTime.now().minusMinutes(30);

// 查找所有"待支付"且创建时间超过30分钟的订单

List<Order> timeoutOrders = orderRepository

.findByStatusAndCreateTimeBefore(OrderStatus.PENDING, timeout);

if (timeoutOrders.isEmpty()) {

return; // 没有超时订单,直接返回

}

for (Order order : timeoutOrders) {

// 归还库存

for (OrderItem item : order.getItems()) {

Product product = productRepository.findById(item.getProductId()).orElseThrow();

product.setStock(product.getStock() + item.getQuantity());

product.setSold(product.getSold() - item.getQuantity());

productRepository.save(product);

}

// 退回优惠券

if (order.getCouponId() != null) {

Coupon coupon = couponRepository.findById(order.getCouponId()).orElseThrow();

coupon.setUsed(false);

coupon.setUsedOrderId(null);

couponRepository.save(coupon);

}

// 更新订单状态

order.setStatus(OrderStatus.CANCELLED);

orderRepository.save(order);

}

System.out.println("超时订单取消完成,共处理" + timeoutOrders.size() + "个订单");

}

}

Repository加一个查询方法:

java

9

1

2

3

4

5

public interface OrderRepository extends JpaRepository<Order, String> {

List<Order> findByStatusAndCreateTimeBefore(OrderStatus status, LocalDateTime createTime);

List<Order> findByUserIdOrderByCreateTimeDesc(String userId);

}

4.3 @Scheduled的几种写法

java

99

1

2

3

4

5

6

7

8

9

10

11

12

// 固定间隔:每隔5分钟执行(从上次开始时间算起)

@Scheduled(fixedRate = 5 * 60 * 1000)

// 固定延迟:上次执行完后等5分钟再执行

@Scheduled(fixedDelay = 5 * 60 * 1000)

// Cron表达式:每天凌晨2点执行

@Scheduled(cron = "0 0 2 * * ?")

// Cron表达式:每10分钟执行

@Scheduled(cron = "0 */10 * * * ?")

Cron表达式格式:秒 分 时 日 月 周

表格

表达式含义
0 0 2 * * ?每天凌晨2点
0 */10 * * * ?每10分钟
0 0 9-18 * * MON-FRI工作日9点到18点整点
0 0 0 1 * ?每月1号零点

4.4 还可以加一个定时任务:过期优惠券清理

java

99

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

@Component

public class CouponExpireTask {

@Autowired

private CouponRepository couponRepository;

/**

* 每天凌晨1点,标记过期的优惠券

*/

@Scheduled(cron = "0 0 1 * * ?")

@Transactional

public void markExpiredCoupons() {

LocalDateTime now = LocalDateTime.now();

List<Coupon> expiredCoupons = couponRepository

.findByUsedFalseAndExpireTimeBefore(now);

// 这里可以加一个"已过期"状态,或者直接删除

// 简单处理:打印日志,实际项目可以发通知提醒用户

System.out.println("发现" + expiredCoupons.size() + "张过期优惠券");

}

}

五、事务深入:不只是加个注解

前面一直在用@Transactional,但没有深入讲。现在有了数据库,可以真正理解事务了。

5.1 事务的ACID特性

表格

特性含义电商例子
Atomicity 原子性要么全成功,要么全失败下单时扣库存+建订单+清购物车,不能只做一半
Consistency 一致性事务前后数据都是对的库存不能出现负数
Isolation 隔离性并发事务互不干扰A扣库存时,B看不到中间状态
Durability 持久性提交后数据永久保存断电不能丢数据

5.2 事务传播行为

最常用的两种:

java

9

1

2

3

4

5

6

7

8

9

// REQUIRED(默认):有事务就加入,没有就新建

@Transactional(propagation = Propagation.REQUIRED)

public void createOrder() { ... }

// REQUIRES_NEW:不管有没有,都新建一个独立事务

// 用于日志记录等"不能被外层事务回滚影响"的场景

@Transactional(propagation = Propagation.REQUIRES_NEW)

public void saveOrderLog() { ... }

场景:下单时记录操作日志。如果下单失败回滚,日志也应该保留(方便排查),所以日志方法用REQUIRES_NEW,独立事务。

5.3 只读事务

java

9

1

2

3

4

5

@Transactional(readOnly = true)

public Product getProduct(String id) {

return productRepository.findById(id).orElseThrow();

}

readOnly = true→ 告诉数据库"我只读不写",数据库可以做优化(不加锁、用快照读)。查询方法都应该加,别偷懒。

5.4 事务回滚规则

java

9

1

2

3

4

5

// 默认只回滚RuntimeException和Error

// 如果要回滚检查异常(checked exception),需要显式指定

@Transactional(rollbackFor = Exception.class)

public void someMethod() throws IOException { ... }

经验法则:统一加rollbackFor = Exception.class,所有异常都回滚,最安全。

六、完整改造后的项目结构

plaintext

99

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

ecommerce/

├── controller/

├── service/

│ └── impl/

├── model/

│ ├── Product.java ← 加了@Entity、@Version

│ ├── CartItem.java ← 加了@Entity

│ ├── Order.java ← 加了@Entity、@OneToMany

│ ├── OrderItem.java ← 加了@Entity

│ └── Coupon.java ← 加了@Entity

├── repository/ ← 全部从Excel改成JPA接口

│ ├── ProductRepository.java

│ ├── CartRepository.java

│ ├── OrderRepository.java

│ └── CouponRepository.java

├── enums/

├── exception/

├── task/ ← 新增:定时任务

│ ├── OrderTimeoutTask.java

│ └── CouponExpireTask.java

└── EcommerceApplication.java ← 加了@EnableScheduling

改动量统计:

  • Model层:每个类加注解,改动约30%
  • Repository层:从手写Excel变成接口定义,改动90%(代码量大幅减少)
  • Service层:几乎不动
  • 新增:task包(定时任务)

七、验证一下并发安全

写个简单的并发测试,看看乐观锁是不是真的管用:

java

99

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

@SpringBootTest

public class ConcurrencyTest {

@Autowired

private ProductService productService;

@Autowired

private ProductRepository productRepository;

@Test

public void testConcurrentDeduct() throws InterruptedException {

// 准备:库存为1的商品

Product product = new Product();

product.setName("测试商品");

product.setPrice(new BigDecimal("99.00"));

product.setStock(1);

product.setSold(0);

product = productRepository.save(product);

String productId = product.getId();

// 两个线程同时扣库存

CountDownLatch latch = new CountDownLatch(2);

AtomicInteger successCount = new AtomicInteger(0);

AtomicInteger failCount = new AtomicInteger(0);

Runnable task = () -> {

try {

latch.countDown();

latch.await(); // 确保两个线程同时开始

productService.updateStock(productId, -1);

successCount.incrementAndGet();

} catch (BusinessException e) {

failCount.incrementAndGet();

} catch (Exception e) {

// 乐观锁冲突

failCount.incrementAndGet();

}

};

new Thread(task).start();

new Thread(task).start();

Thread.sleep(2000); // 等待执行完成

// 断言:只有一个成功,一个失败

assertEquals(1, successCount.get());

assertEquals(1, failCount.get());

// 验证库存确实是0

Product updated = productRepository.findById(productId).orElseThrow();

assertEquals(0, updated.getStock());

}

}

没有乐观锁时:两个都"成功",库存变成0但卖了2件 → 超卖。

有乐观锁时:只有一个成功,另一个抛异常 → 安全。

和AI应用的关系

并发安全不只是电商的问题,AI应用一样会遇到:

表格

电商场景AI应用对应
超卖(库存扣多)API额度扣多(一个Token被用两次)
超时取消订单AI任务超时自动释放资源
乐观锁版本号Prompt版本控制(防止覆盖别人的修改)
事务回滚AI调用链失败时资源回收
定时任务清理过期会话清理、缓存过期

做AI应用时,并发问题更隐蔽——大模型调用是耗时的,一个请求可能跑几秒甚至几十秒,这期间状态怎么管、资源怎么分配,和电商扣库存是同一个问题。

总结:从Excel到数据库,不只是换个存储

这次改造表面上是"换了个存数据的方式",实际上解决了三个层面的问题:

  1. 可靠性→ 事务保证操作不会做一半,数据不会丢
  2. 并发安全→ 乐观锁防超卖,悲观锁防排队混乱
  3. 自动化→ 定时任务自动处理超时、过期等边界情况

这三个问题,Excel一个都解决不了。** 存储不只是存数据,还要保护数据。**

下篇预告

下一篇我们聊聊缓存和性能优化——数据库扛不住的时候怎么办?Redis登场。

思考题:如果同一件商品有1000个人同时抢购(秒杀场景),乐观锁会导致大量重试失败,怎么优化?(提示:Redis预扣库存 + 异步落库)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询