数据持久化与并发安全:让系统真正扛得住
系列专栏从Java到AI应用开发| 第5篇写在前面上篇搭完了电商系统能下单、能支付、能退款看起来像个样子了。但留了两个问题两个人同时买最后一件商品会不会超卖下了单一直不付款怎么办这两个问题的本质一个是并发安全一个是数据可靠性。而它们的共同解法指向同一件事——从Excel存储升级到真正的数据库。Excel是好老师让你理解了存和取的本质但它撑不起真实业务。今天我们把数据层彻底换掉同时解决并发安全问题。一、为什么必须换掉Excel上篇的Repository层底层全是在读写Excel文件。这在学习阶段没问题但真实场景有几个致命问题表格问题Excel数据库并发写入直接覆盖数据丢失行级锁排队执行事务支持没有ACID保证查询能力全表扫描索引加速数据量几千行就卡百万级没问题崩溃恢复文件损坏就没了有日志可以恢复最致命的是第一个并发写入。两个请求同时修改同一个商品的库存Excel会互相覆盖最后只保留一个的结果。二、引入MySQL Spring Data JPA2.1 加依赖xml99123456789101112131415!-- pom.xml --dependencies!-- Spring Data JPA --dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-data-jpa/artifactId/dependency!-- MySQL驱动 --dependencygroupIdcom.mysql/groupIdartifactIdmysql-connector-j/artifactIdscoperuntime/scope/dependency/dependencies2.2 配置数据库连接yaml991234567891011121314# application.ymlspring:datasource:url: jdbc:mysql://localhost:3306/ecommerce?useSSLfalseserverTimezoneAsia/Shanghaiusername: rootpassword: your_passwordjpa:hibernate:ddl-auto: update # 开发阶段自动建表生产环境用validateshow-sql: true # 打印SQL方便调试properties:hibernate:format_sql: true # 格式化SQL2.3 实体类改造之前我们的Model是纯POJO现在要加上JPA注解告诉数据库怎么存java9912345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152EntityTable(name products)public class Product {IdGeneratedValue(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是一对多关系java99123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051EntityTable(name orders)public class Order {Idprivate String id; // 我们自己生成的订单号Column(nullable false)private String userId;OneToMany(cascade CascadeType.ALL, fetch FetchType.EAGER)JoinColumn(name order_id)private ListOrderItem 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() {}PrePersistprotected void onCreate() {createTime LocalDateTime.now();}}OneToMany(cascade CascadeType.ALL)→ 保存Order时关联的OrderItem也会自动保存。这就是级联操作省得我们手动一个一个存。2.4 Repository改造从手写Excel到接口继承之前我们的Repository要手写Excel读写逻辑现在继承一个接口就完了java991234567891011public interface ProductRepository extends JpaRepositoryProduct, String {// 按分类查ListProduct findByCategory(String category);// 按名称模糊搜索ListProduct findByNameContaining(String keyword);// 按分类名称组合查ListProduct findByCategoryAndNameContaining(String category, String keyword);// 热销排行ListProduct findTop10ByOrderBySoldDesc();}这就是Spring Data JPA的魔法——你只写方法名它自动生成SQL。findByNameContaining会变成WHERE name LIKE %xxx%findTop10ByOrderBySoldDesc会变成ORDER BY sold DESC LIMIT 10。CartRepository也一样java9123456public interface CartRepository extends JpaRepositoryCartItem, String {ListCartItem findByUserId(String userId);OptionalCartItem findByUserIdAndProductId(String userId, String productId);void deleteByUserId(String userId);}对比一下改造前后的代码量之前每个Repository动辄100多行Excel读写代码现在3-5行接口定义。这省下来的时间拿去写业务逻辑。2.5 Service层几乎不用改这是分层架构最大的好处——Repository换了实现Service层代码基本不用动。java9912345678910111213141516171819202122Servicepublic class ProductServiceImpl implements ProductService {Autowiredprivate ProductRepository productRepository; // 接口没变只是实现从Excel变成了JPAOverrideTransactionalpublic 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件plaintext9123456时刻1用户A读到 stock 1时刻2用户B读到 stock 1 ← 两人都认为还有库存时刻3用户A写入 stock 0时刻4用户B写入 stock 0 ← 覆盖了A的写入库存从0又变成0结果两人都下单成功但实际只有1件商品 → 超卖这个问题用Excel无解Excel根本没有并发控制但数据库有几种方案。3.2 方案一乐观锁推荐思路给表加一个版本号字段每次更新时检查版本号是否变化。如果变了说明别人改过本次更新失败。java9123456789EntityTable(name products)public class Product {// ... 其他字段Versionprivate Integer version; // 乐观锁版本号}就加一个Version注解JPA自动处理。原理看SQL就明白了sql9123456-- 更新时JPA自动生成的SQLUPDATE productsSET stock 0, version 2WHERE id xxx AND version 1;-- ^^^^^^^^ 只在版本号匹配时才更新如果两个人同时读到 version1第一个人的更新成功version变成2第二个人更新时WHERE version 1已经不匹配了影响行数为0 → JPA抛出OptimisticLockException。Service层处理java991234567891011121314151617181920OverrideTransactionalpublic 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 方案二悲观锁思路读取数据时就直接锁住这一行别人连读都读不到只能等直到我改完释放锁。java991234567891011public interface ProductRepository extends JpaRepositoryProduct, String {/*** 悲观锁查询SELECT ... FOR UPDATE* 拿到这行数据后其他事务不能修改直到当前事务提交*/Lock(LockModeType.PESSIMISTIC_WRITE)Query(SELECT p FROM Product p WHERE p.id :id)OptionalProduct findByIdForUpdate(Param(id) String id);}java9912345678910111213141516OverrideTransactionalpublic 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内置了定时任务支持不需要额外依赖。第一步开启定时任务java912345678SpringBootApplicationEnableScheduling // 加这个注解public class EcommerceApplication {public static void main(String[] args) {SpringApplication.run(EcommerceApplication.class, args);}}第二步写定时任务java9912345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152Componentpublic class OrderTimeoutTask {Autowiredprivate OrderRepository orderRepository;Autowiredprivate ProductRepository productRepository;Autowiredprivate CouponRepository couponRepository;/*** 每5分钟执行一次检查超时未支付的订单*/Scheduled(fixedRate 5 * 60 * 1000) // 5分钟Transactionalpublic void cancelTimeoutOrders() {LocalDateTime timeout LocalDateTime.now().minusMinutes(30);// 查找所有待支付且创建时间超过30分钟的订单ListOrder 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加一个查询方法java912345public interface OrderRepository extends JpaRepositoryOrder, String {ListOrder findByStatusAndCreateTimeBefore(OrderStatus status, LocalDateTime createTime);ListOrder findByUserIdOrderByCreateTimeDesc(String userId);}4.3 Scheduled的几种写法java99123456789101112// 固定间隔每隔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 还可以加一个定时任务过期优惠券清理java9912345678910111213141516171819202122Componentpublic class CouponExpireTask {Autowiredprivate CouponRepository couponRepository;/*** 每天凌晨1点标记过期的优惠券*/Scheduled(cron 0 0 1 * * ?)Transactionalpublic void markExpiredCoupons() {LocalDateTime now LocalDateTime.now();ListCoupon expiredCoupons couponRepository.findByUsedFalseAndExpireTimeBefore(now);// 这里可以加一个已过期状态或者直接删除// 简单处理打印日志实际项目可以发通知提醒用户System.out.println(发现 expiredCoupons.size() 张过期优惠券);}}五、事务深入不只是加个注解前面一直在用Transactional但没有深入讲。现在有了数据库可以真正理解事务了。5.1 事务的ACID特性表格特性含义电商例子Atomicity 原子性要么全成功要么全失败下单时扣库存建订单清购物车不能只做一半Consistency 一致性事务前后数据都是对的库存不能出现负数Isolation 隔离性并发事务互不干扰A扣库存时B看不到中间状态Durability 持久性提交后数据永久保存断电不能丢数据5.2 事务传播行为最常用的两种java9123456789// REQUIRED默认有事务就加入没有就新建Transactional(propagation Propagation.REQUIRED)public void createOrder() { ... }// REQUIRES_NEW不管有没有都新建一个独立事务// 用于日志记录等不能被外层事务回滚影响的场景Transactional(propagation Propagation.REQUIRES_NEW)public void saveOrderLog() { ... }场景下单时记录操作日志。如果下单失败回滚日志也应该保留方便排查所以日志方法用REQUIRES_NEW独立事务。5.3 只读事务java912345Transactional(readOnly true)public Product getProduct(String id) {return productRepository.findById(id).orElseThrow();}readOnly true→ 告诉数据库我只读不写数据库可以做优化不加锁、用快照读。查询方法都应该加别偷懒。5.4 事务回滚规则java912345// 默认只回滚RuntimeException和Error// 如果要回滚检查异常checked exception需要显式指定Transactional(rollbackFor Exception.class)public void someMethod() throws IOException { ... }经验法则统一加rollbackFor Exception.class所有异常都回滚最安全。六、完整改造后的项目结构plaintext9912345678910111213141516171819202122ecommerce/├── 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包定时任务七、验证一下并发安全写个简单的并发测试看看乐观锁是不是真的管用java99123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354SpringBootTestpublic class ConcurrencyTest {Autowiredprivate ProductService productService;Autowiredprivate ProductRepository productRepository;Testpublic 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());// 验证库存确实是0Product 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预扣库存 异步落库