数据持久化与并发安全:让系统真正扛得住
系列专栏:从Java到AI应用开发| 第5篇
写在前面
上篇搭完了电商系统,能下单、能支付、能退款,看起来像个样子了。但留了两个问题:
- 两个人同时买最后一件商品,会不会超卖?
- 下了单一直不付款怎么办?
这两个问题的本质,一个是并发安全,一个是数据可靠性。而它们的共同解法指向同一件事——从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到数据库,不只是换个存储
这次改造表面上是"换了个存数据的方式",实际上解决了三个层面的问题:
- 可靠性→ 事务保证操作不会做一半,数据不会丢
- 并发安全→ 乐观锁防超卖,悲观锁防排队混乱
- 自动化→ 定时任务自动处理超时、过期等边界情况
这三个问题,Excel一个都解决不了。** 存储不只是存数据,还要保护数据。**
下篇预告
下一篇我们聊聊缓存和性能优化——数据库扛不住的时候怎么办?Redis登场。
思考题:如果同一件商品有1000个人同时抢购(秒杀场景),乐观锁会导致大量重试失败,怎么优化?(提示:Redis预扣库存 + 异步落库)
