news 2026/10/28 8:42:58

Spring Boot电商项目实战:缓存、事务与分布式锁

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot电商项目实战:缓存、事务与分布式锁

简介:一套基于Springboot+Vue的网上电商项目,采用前后端分离架构,面向JavaWeb学习者与初中级开发者,覆盖前台商品展示、购物车、订单查询与后台商品/订单/用户管理等典型业务场景。项目整合Springboot、Maven、Mybatis、Vue、Redis等技术,并附带支付宝沙箱环境与支付环节代码,可作为毕业设计或企业实训参考。压缩包共615个文件,主要以Java源码、class文件、Vue组件、JS脚本、XML/yml配置、SQL脚本等形式呈现,整体18.89MB,目录结构清晰,便于按功能模块查阅。目前已有297人学习下载。包内包含完整前后端工程、数据库初始化脚本、环境配置说明及常见问题处理信息,下载后按说明配置Jdk1.8、Tomcat8.5与MySQL即可运行,适合希望快速掌握电商项目全栈流程的读者。

1. 网上电商项目到底在做什么:一套前后端分离的Spring Boot全家桶

网上电商项目,前后端分离,Java + Vue + Spring Boot + SSM + MySQL + Redis,这套组合说句不好听的,几乎成了Java Web就业项目的标配模板。但真拿它去撑一个能上线的线上商城,差距不在功能,而在细节。我做过的电商项目少说三四个,商品表、购物车、订单、支付回调,业务逻辑都差不多,真正拉开差距的是热点数据怎么缓存、库存怎么扣、并发下单时怎么保证不超卖。这篇文章就顺着这套技术栈,把商品浏览、购物车、订单、缓存和事务这些核心链路拆开讲,从工程骨架到参数配置,再到我踩过的坑,让你照着能复现,而不是只会跑一个demo。

2. 从零拉起电商项目骨架:Maven多模块与Vue工程初始化

2.1 单模块还是多模块:电商项目该用哪种Maven结构

很多教程把Spring Boot项目做成一个单模块工程,controller、service、mapper全塞在一起,几百行代码还能撑住,一旦商品、订单、用户、支付、优惠券这些业务模块堆进来,单模块会让你改一个支付回调都得重新编译整个工程。我一般会拆成多模块,不是为好看,是为了让mysql、redis、maven这些依赖的边界清晰,也方便以后拆微服务。

一个典型的电商后端多模块结构是这样:

e-commerce-backend/ ├── pom.xml # 父POM,统一版本管理 ├── e-commerce-common/ # 通用类:统一返回、异常、工具类 ├── e-commerce-product/ # 商品模块 ├── e-commerce-order/ # 订单模块 ├── e-commerce-user/ # 用户模块 └── e-commerce-admin/ # 管理后台接口模块

父POM里做两件事:用dependencyManagement锁定依赖版本,用properties统一版本号。Spring Boot的版本选择非常关键,热词里一直在刷“springboot版本太高”的坑,3.x和2.x的差异不只是javax到jakarta,redis、mybatis整合方式都有变化。做这套电商项目,我建议直接用Spring Boot 2.7.x,稳定,网上资料多,mybatis-spring-boot-starter也兼容得最好。

<properties> <java.version>1.8</java.version> <spring-boot.version>2.7.18</spring-boot.version> <mysql.version>5.7.44</mysql.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

这里有个参数说明:Java版本锁在1.8,不是因为新版本不好,而是这套技术栈在1.8下踩坑最少。MySQL用5.7.44,是因为5.7的InnoDB在事务和锁机制上与MyBatis的配合非常成熟,网上遇到的案例最多,排查资料也好找。Spring Boot版本一旦高于3.0,javax.servlet要改成jakarta.servlet,部分老版本的MyBatis分页插件直接失效,这种连带问题会浪费你一整个下午。

2.2 Spring Boot整合SSM:数据源、MyBatis与事务配置

SSM在Spring Boot里不再需要那些繁琐的XML配置,但你要明白底层装配逻辑,不然后面遇到“mapper注入为null”这种问题会毫无头绪。SSM指的就是Spring + Spring MVC + MyBatis,Spring Boot通过自动配置把它们串起来。

先创建数据库和表,这是电商项目的地基。商品表、订单表、购物车表、用户表,下面这份SQL是精简版,但字段和索引思路是完整的:

CREATE DATABASE IF NOT EXISTS ecommerce DEFAULT CHARACTER SET utf8mb4; CREATE TABLE t_product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(128) NOT NULL COMMENT '商品名称', product_price DECIMAL(10,2) NOT NULL COMMENT '售价', stock INT NOT NULL DEFAULT 0 COMMENT '库存', category_id BIGINT NOT NULL, status TINYINT DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id) ) ENGINE=InnoDB COMMENT='商品表'; CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '订单号', user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id) ) ENGINE=InnoDB COMMENT='订单表';

pom.xml里加上SSM三件套的Spring Boot整合依赖,注意不是单独引入spring、springmvc,而是引入启动器:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql.version}</version> </dependency>

mybatis-spring-boot-starter的版本同样要锁住,2.3.x配合Spring Boot 2.7.x是经过大量项目验证的。不要用3.0的mybatis starter,它的包名和自动化配置有破坏性调整。

application.yml是这套项目的总控文件,数据源、Redis连接、MyBatis映射、事务管理都在这里:

spring: datasource: url: jdbc:mysql://localhost:3306/ecommerce?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.ecommerce.entity configuration: map-underscore-to-camel-case: true

这里有几个关键参数值得说明:serverTimezone一定要显式设置,否则MySQL 5.7以上版本会报时区错误,这种错误在启动时看不出来,但查询时间字段时会差8小时。Redis的timeout设成3000ms,是个折中值,太小容易在生产环境误报超时,太大又会让接口响应变慢。max-active=8是lettuce连接池的默认值,电商场景下单机并发不高时够用,但如果要做秒杀,这个值要调大到20以上。

2.3 Vue前端工程与后端联调:代理和打包

前端用Vue CLI初始化工程,这部分对应的就是热词里的“vue安装及环境配置”和“vue路由”。电商项目前端不是重点,但联调时有一个配置是必经之路,就是开发环境代理。前后端分离后,前端跑在8080端口,后端跑在8081端口,跨域问题必须通过代理解决,而不是在后端代码里写@CrossOrigin。

vue.config.js里这样配:

module.exports = { devServer: { port: 8080, host: 'localhost', proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true, pathRewrite: { '^/api': '' } } } } };

关键在pathRewrite这一行,它的作用是把前端请求的/api前缀剥掉再转发给后端。我见过很多新手前后端联调失败,不是因为Java代码有问题,而是代理没配好,请求打到了后端找不到的路径上。changeOrigin必须设为true,否则后端拿到的Host头还是前端地址,一些基于域名做权限校验的后端接口会直接拒绝。

前端路由用Vue Router,电商项目至少要有首页、商品列表、商品详情、购物车、订单确认、订单列表这六个页面。这个阶段不着急写业务,把路由和页面骨架搭好,能通过代理调到后端接口就说明工程骨架已经通了。

3. 商品中心与Redis缓存:把热点数据从MySQL里解放出来

3.1 商品表设计的SPU与SKU:电商项目的经典模型

商品模块是电商项目的核心数据源。早期做电商项目的人容易把商品设计成一张大表,所有属性全塞进去,结果就是字段爆炸、查询缓慢、扩展困难。稍微正规一点的电商项目都会区分SPU和SKU:SPU是商品聚合,比如“iPhone 15 Pro”;SKU是具体可购买的规格,比如“iPhone 15 Pro 黑色 256GB”。

这个模型直接决定了Redis缓存怎么设计。SPU信息变化频率低、访问频率高,适合做缓存;SKU的库存变化频率极高,不适合长时间缓存。我一般把SPU整体缓存管叫“商品缓存”,SKU库存单独走Redis的String类型存数字,配合后面的分布式锁和事务使用。

商品列表页的SQL查询在电商项目里是一个极易翻车的点。很多人一上来就SELECT *,商品一多,连表查分类和品牌,数据库CPU直接被打满。我的习惯是列表页只查商品ID和必要展示字段,详情再查全量。配合MySQL的索引,商品列表的查询语句要做到explain里type至少是ref,不能是ALL全表扫描。

SELECT id, product_name, product_price, main_image FROM t_product WHERE category_id = #{categoryId} AND status = 1 ORDER BY id DESC LIMIT #{offset}, #{pageSize};

这条SQL的写法有几个细节:只查列表需要的字段,减少传输和内存消耗;ORDER BY用主键id而不是创建时间,因为id的索引天然有序;limit分页传offset和pageSize,后端做统一分页参数对象。这样的查询在商品数量百万级以内都能保持稳定响应。

3.2 用RedisTemplate还是Spring Cache:缓存方案选型

商品详情页是最典型的缓存应用场景。一个商品被成千上万人同时浏览,如果每次请求都打MySQL,数据库迟早被压垮。Redis在这里做的是“缓存治理”,把热点数据的读压力从MySQL扛走。但实现方式有分歧:Spring Cache的注解方式简单,RedisTemplate手动方式灵活。

我的建议是商品缓存用RedisTemplate手动控制。原因很简单:Spring Cache的注解适合不怎么变化的缓存,但商品缓存需要处理缓存空值防止穿透、需要设置不同的过期时间、需要配合布隆过滤器,这些逻辑用注解表达起来非常别扭。

@Service public class ProductService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private ProductMapper productMapper; private static final String PRODUCT_CACHE_KEY = "product:detail:"; public ProductVO getProductDetail(Long productId) { // 先查缓存,命中直接返回 String cached = redisTemplate.opsForValue().get(PRODUCT_CACHE_KEY + productId); if (cached != null) { if ("EMPTY".equals(cached)) { // 缓存空值,防止缓存穿透 return null; } return JSON.parseObject(cached, ProductVO.class); } // 缓存未命中,查MySQL Product product = productMapper.selectById(productId); if (product == null) { // 设置空值缓存,过期时间设短 redisTemplate.opsForValue().set(PRODUCT_CACHE_KEY + productId, "EMPTY", 60, TimeUnit.SECONDS); return null; } ProductVO vo = convertToVO(product); // 商品信息缓存10分钟,过期时间略微随机化防雪崩 long expireTime = 600 + new Random().nextInt(60); redisTemplate.opsForValue().set( PRODUCT_CACHE_KEY + productId, JSON.toJSONString(vo), expireTime, TimeUnit.SECONDS ); return vo; } }

这段代码的逻辑说明:先读缓存,缓存有就直接返回JSON反序列化后的对象;缓存没有就读数据库;数据库也没有就把“EMPTY”这个标记写进缓存,这就是缓存穿透的标准解法。过期时间600秒加随机数,是为了防止大量商品同时失效导致缓存雪崩。这套逻辑用Spring Cache的@Cacheable注解是实现不了这么精细的控制的。

代码里的参数值得你记录:空值缓存过期时间只设60秒,因为空值不需要长期占用Redis内存;正常商品缓存过期时间在10分钟上下浮动,具体数值取决于你的商品更新频率,我做过一个比价类项目,商品价格变动频繁,缓存时间压到了3分钟。

3.3 布隆过滤器:缓存穿透的最终防线

商品列表和详情页的缓存穿透问题,在正常流量下不会暴露,一旦遇到恶意脚本用不存在的商品ID批量刷接口,请求会绕过缓存直接打到MySQL。空值缓存能解决一部分问题,但每次穿透都查一次数据库的成本还是高。更彻底的方案是布隆过滤器。

布隆过滤器的作用就是快速判断一个ID是否可能存在。它的特点是“一定不存在的肯定不存在,存在的可能误判存在”,这种特性恰好适合做缓存穿透过滤。

@Configuration public class BloomFilterConfig { private static final int EXPECTED_SIZE = 100000; private static final double FPP = 0.01; @Bean public BloomFilter<Long> productBloomFilter() { BloomFilter<Long> filter = BloomFilter.create(Funnel.longFunnel(), EXPECTED_SIZE, FPP); // 系统启动时,把商品表中的所有商品ID预加载进布隆过滤器 productMapper.selectAllIds().forEach(filter::put); return filter; } }

参数说明:EXPECTED_SIZE设置为10万,是预估的商品总量,设置小了会导致误判率急剧上升,设置大了会浪费内存。FPP误判率设为1%,意味着100个不存在的ID里可能有1个会穿透到数据库,这个概率对于电商商品ID来说完全可接受。启动时全量把商品ID加载进布隆过滤器是一个简单但有效的初始化方式,商品量到百万级时,这个初始化过程要花十几秒,但换来的是缓存层几乎100%的抗穿透能力。

用的时候在getProductDetail方法最前面加一行判断:

if (!productBloomFilter().mightContain(productId)) { return null; }

这一行的意思是,布隆过滤器说这个ID不存在,就直接返回空,连Redis都不查。这就是“缓存治理”里最常见也最实用的三道防线:布隆过滤器挡第一层,空值缓存挡第二层,MySQL兜底第三层。

4. 购物车与订单:MySQL事务、库存扣减与Redis分布式锁

4.1 购物车用Redis存储还是MySQL存储

购物车是电商项目里一个很有意思的设计点。用MySQL存储,好处是持久化、跨设备同步,坏处是用户每次加购都要写库,频繁的读写会让数据库压力山大。用Redis存储,好处是读写极快、天然过期,坏处是Redis重启会丢数据,用户的购物车可能莫名其妙消失。

折中方案是这样:购物车以Redis存储为主,用户登录后把购物车数据异步同步一份到MySQL。Redis里购物车的key用hash类型,field是商品ID,value是加购数量。这样设计的好处是,用户改数量时不需要读整个购物车,直接操作Redis的hash字段。

@Service public class CartService { @Autowired private StringRedisTemplate redisTemplate; private static final String CART_KEY_PREFIX = "cart:user:"; // 加购操作 public void addToCart(Long userId, Long productId, Integer count) { String cartKey = CART_KEY_PREFIX + userId; // hash操作,field是商品ID,value是数量 redisTemplate.opsForHash().increment(cartKey, productId.toString(), count); // 设置过期时间,30天不登录清空购物车,避免Redis内存泄漏 redisTemplate.expire(cartKey, 30, TimeUnit.DAYS); } // 查询购物车 public Map<Object, Object> getCart(Long userId) { String cartKey = CART_KEY_PREFIX + userId; Map<Object, Object> entries = redisTemplate.opsForHash().entries(cartKey); // 这部分可以异步加载商品详情,补充购物车展示信息 return entries; } }

Redis的hash结构做购物车有一个天然的优势:increment命令是原子的,用户连续点击加购不会出现数量覆盖丢失的问题。过期时间设30天,是平衡用户体验和Redis内存的常用做法,太短用户久了回来购物车空了体验差,太长Redis里全是僵尸数据。

这里你要注意一个坑:购物车数据在Redis里只能存商品ID和数量,不能存商品价格。原因很简单,商品价格会变,购物车显示的价格应该在下单时实时从商品服务取,而不是用用户在购物车页面看到的历史价格。

4.2 订单事务与库存扣减:MySQL事务和锁的配合

下单流程是整个电商项目技术含量最高的部分,涉及的事务有三种:订单创建、库存扣减、购物车清理。这三个操作必须在一个事务里完成,任何一步失败都要回滚。Spring Boot里用@Transactional注解是最直接的方案。

@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private ProductMapper productMapper; @Transactional(rollbackFor = Exception.class) public Order createOrder(OrderRequest request) { // 1. 生成订单号,用雪花算法或者时间戳+随机数 String orderNo = generateOrderNo(); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(request.getUserId()); order.setTotalAmount(calculateAmount(request)); // 2. 扣减库存,UPDATE加条件判断,防止超卖 int updated = productMapper.deductStock(request.getProductId(), request.getCount()); if (updated == 0) { throw new BusinessException("库存不足"); } // 3. 创建订单 orderMapper.insert(order); // 4. 清理购物车对应商品 cartService.removeItem(request.getUserId(), request.getProductId()); return order; } }

这里最关键的是第2步库存扣减。如果你的SQL写的是先查库存再判断够不够,然后UPDATE库存减一,那这个接口在并发100时一定会超卖,因为读和写之间有时间差。正确做法是直接把判断条件写进UPDATE语句中:

UPDATE t_product SET stock = stock - #{count} WHERE id = #{productId} AND stock >= #{count}

这条UPDATE的含义是:只有当库存大于等于购买数量时才执行扣减。MySQL的UPDATE是行锁级别的原子操作,两个并发请求同时走这条SQL时,只有一个能更新成功,另一个受影响行数为0。通过受影响行数判断库存是否扣减成功,是MySQL层面的最优解,比Java层加锁效率高得多。

事务的rollbackFor=Exception.class这个参数值得特意说明:Spring默认只在遇到RuntimeException时才回滚,checked exception不会触发回滚。你在事务方法里抛一个IOException,默认情况下之前的数据库操作照样提交。所以事务方法必须显式声明rollbackFor为Exception.class,这是血泪经验。

4.3 Redis分布式锁:避免库存超卖的另一种解法

MySQL的乐观锁UPDATE能解决单库库存扣减,但电商项目里的库存往往还有Redis里的预扣库存,尤其是接入秒杀场景后。热词里一直在刷“redis分布式锁”,到这一步真实需要一个能把Redis里的库存安全扣减的方案。它的核心是:多个线程同时修改Redis里的库存值,必须保证只有一个线程能执行扣减操作。

Redis分布式锁的经典实现是SETNX加过期时间,但这里面坑非常多。下面是落地版本的代码:

public boolean tryLock(String lockKey, String requestId, long expireSeconds) { // SET key value NX EX 是原子操作,分布式锁的根 Boolean result = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, expireSeconds, TimeUnit.SECONDS); return Boolean.TRUE.equals(result); } public void unlock(String lockKey, String requestId) { // 用Lua脚本保证“判断是自己持有锁+删除锁”是原子的 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) " + "else " + "return 0 " + "end"; DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class); redisTemplate.execute(redisScript, Collections.singletonList(lockKey), requestId); }

这段代码里有两个参数是绝对不能省的:一个是requestId,它必须是当前线程的唯一标识,比如UUID,作用是在释放锁时确认这把锁确实是自己的,防止误删了其他线程的锁;一个是expireSeconds,必须给锁设置过期时间,否则持有锁的线程挂了,锁永远不会释放,造成死锁。

为什么释放锁要单独用Lua脚本?因为“检查锁是否是自己持有”和“删除锁”是两个操作,如果分开执行,中间可能有别的线程抢占了锁。用Lua脚本保证这两条命令的原子性,是我在这个项目里踩坑后学到的。网上有很多热词帖子讲Redis分布式锁的各种翻车案例,大多数翻车原因就是忽略了requestId的校验或过期时间。

库存扣减时的使用方式是把分布式锁和MySQL扣减结合起来,先锁住Redis里的库存,再走事务扣减MySQL库存:

public void deductStockWithLock(Long productId, Integer count) { String lockKey = "lock:product:" + productId; String requestId = UUID.randomUUID().toString(); boolean locked = tryLock(lockKey, requestId, 10); if (!locked) { throw new BusinessException("当前操作人数过多,请稍后重试"); } try { // 获锁后,做库存等数据的一致性校验和扣减 createOrder(request); } finally { unlock(lockKey, requestId); } }

5. 电商项目常见问题排查:从启动失败到缓存穿透的5条踩坑记录

5.1 Spring Boot版本太高导致Redis连接失败

现象:项目用Spring Boot 3.x启动,Redis连接一直报错,控制台提示“Unable to connect to Redis”,但Redis进程明明是正常运行的。想回退版本,发现代码里javax.servlet相关的import全部报错。

原因:Spring Boot 3.x把javax命名空间迁移到了jakarta,lettuce客户端的一些底层配置也变了。很多老版本的redis客户端依赖不兼容3.x的自动配置机制。更隐蔽的是,3.x对Redis连接池的默认配置做了调整,很多2.x时代的配置项失效或被忽略。

解决:电商项目不追求新版本,直接用Spring Boot 2.7.18 + mybatis-spring-boot-starter 2.3.2 + mysql-connector-java 5.7.44这一套组合。这不是技术保守,是这套版本组合经过了海量项目的验证,遇到过的问题都能在网上找到现成的排查方案。

5.2 前端Vue打包放进Spring Boot后页面空白

现象:开发环境跑得好好的,npm run build之后把dist目录扔进Spring Boot的static文件夹,启动后访问首页一片空白,F12控制台显示404或者加载不到JS文件。

原因:Vue默认的publicPath是/,打包后的JS和CSS用的是绝对路径,部署到Spring Boot后如果项目不是在根路径下启动,路径就对不上。另一个常见原因是Vue Router开启了history模式,nginx或Spring Boot没有配置路径重写规则,刷新页面时直接404。

解决:vue.config.js里把publicPath改成./,这样打包资源就变成了相对路径;Vue Router用hash模式,别开history,省去服务端配置的麻烦。这是我做前后端分离项目最省心的部署组合,代价是URL里会带一个#号,但电商后台管理系统完全不介意这个。

5.3 Docker安装MySQL失败,但宿主机MySQL正常

现象:本地用Docker启动MySQL容器,端口也映射了,但Spring Boot连接时总报Access denied或者连接超时。查了容器日志,MySQL初始化没有正常完成。

原因:MySQL 5.7的Docker容器首次启动时要执行初始化脚本,如果你没有设置MYSQL_ROOT_PASSWORD环境变量,或者挂载了本地数据目录却用了不同的字符集,容器会启动失败。还有一种翻车情况是端口映射用了3306:3306,但宿主机自带的MySQL已经占用了3306端口。

解决:给MySQL容器单独映射一个端口,比如3336:3306,避免和宿主机端口冲突。启动命令里必须带上MYSQL_ROOT_PASSWORD。修改Spring Boot的数据源URL端口为3336。这条经验能帮你省掉至少半小时的排查时间。

5.4 Redis缓存穿透:商品ID被恶意循环请求打垮

现象:某天MySQL的CPU突然飙高,慢查询日志里全是同一个SQL在查不存在的商品ID。Redis命中率从95%降到了40%,说明大量请求穿过了缓存打到数据库。

原因:有人用脚本循环调用商品详情接口,传的商品ID都是不存在的。因为没有布隆过滤器,空值也没有缓存,请求全部穿透到MySQL。600秒的缓存对不存在的ID不起作用,每次请求都执行了一次数据库SELECT。

解决:加布隆过滤器前置过滤,加空值缓存兜底,同时给商品详情接口加了简单的接口限流,同一IP每秒超过20次直接返回错误码。布隆过滤器挡住99%的无效请求,空值缓存挡住剩余的1%,MySQL的压力瞬间就下来了。

5.5 分布式锁释放时误删了别人的锁

现象:高并发下单时段,用户A的订单莫名其妙被取消,日志里显示“未持有锁却执行了释放锁操作”。查代码发现释放锁时只用了删除key,没有判断key的value。

原因:线程A获取锁后执行时间过长,锁自动过期了;线程B获取锁成功开始执行;线程A执行完毕后直接DELETE了锁key,把线程B的锁删掉了。这导致线程B和线程C同时进入临界区,库存扣减失去保护。

解决:释放锁时用Lua脚本检查value是否为当前线程的requestId,是才删除。修改后,误删锁的问题再没出现过。既然锁设了过期时间,就必须在释放时校验持有者身份,这个流程里没有一个参数是多余的。

6. 支付回调的幂等处理:电商项目上线前最该补的一课

订单创建完,项目还没有真正闭环,支付回调才是检验一个电商项目是否成熟的分水岭。微信或支付宝的支付回调会以异步通知方式发送到你配置的回调地址,一个关键特点是:同样的通知可能发送多次,必须做到幂等处理,即重复通知不会产生重复订单或重复加余额。

幂等的标准做法是在订单表加一个唯一约束的字段,也就是前面设计表中的order_no做了UNIQUE。支付回调里先按order_no查询订单,如果订单状态已经是“已支付”,直接返回成功,不再重复处理。这是最基础的一层防线。更稳的是加一张支付回调记录表,每次回调先尝试insert这条通知记录,如果主键冲突说明已经处理过,直接返回成功。

上线前的验证方法我习惯分三步:一是用Postman或curl手动模拟支付回调请求,重复发送三次同样的通知,看订单状态是否被正确置为“已支付”,是否产生任何重复数据;二是把MySQL的慢查询日志开着,在并发下单测试时观察有没有全表扫描的SQL偷摸跑出来;三是Redis里那几个核心缓存的命中率,用info命令盯着看,命中率低于90%就要回头检查缓存策略。

我最早做电商项目时根本没想过幂等这回事,直到一次测试环境里支付回调重放了十几遍,订单表出现了十几条重复数据,才明白微信文档里为什么反复强调幂等。后来我养成了一个习惯,任何涉及到钱的接口,第一版代码就要把幂等校验写进去,后来在面试里讲这个点,比背一堆框架面试题管用得多。这套电商项目做到支付回调这步,能顺利跑通,基本就具备上线能力了,剩下的就是按真实流量慢慢调Redis缓存参数和MySQL索引。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 8:42:58

SpringBoot+SSM+Redis+Vue电商项目实战:从搭建到压测避坑

简介&#xff1a;一款基于SpringbootVue的网上电商项目&#xff0c;采用前后端分离架构&#xff0c;前台覆盖商品展示、分类筛选、购物车与订单查询&#xff0c;后台包含商品管理、订单管理、用户管理与信息维护&#xff0c;并集成支付宝沙箱支付环境&#xff0c;适合Java学习者…

作者头像 李华
网站建设 2026/10/28 8:42:09

学术写作AI检测与降AI率工具全解析

1. 项目概述&#xff1a;学术写作与AI检测的博弈现状最近两年&#xff0c;学术圈出现了一个有趣的现象&#xff1a;各大高校的查重系统纷纷升级了AI检测功能。我指导的几位研究生就曾遇到这样的困境——明明是自己熬夜写的论文&#xff0c;系统却判定"AI生成率过高"。…

作者头像 李华
网站建设 2026/10/28 8:41:57

AI进化三信号:AR翻译眼镜、iOS27第三方AI与Claude技能升级

最近我密集参加了好几场跨语言的技术会议&#xff0c;耳机式翻译设备用了几天就有点顶不住——耳朵被塞得发胀不说&#xff0c;周围人一多&#xff0c;收音也跟着乱。直到看到亮亮视野推出ARAI会议翻译系统的消息&#xff0c;我才意识到真正适合会议场景的翻译设备&#xff0c;…

作者头像 李华
网站建设 2026/10/28 8:41:36

PyTorch物理约束模型在浅基础承载力预测中的应用

1. 项目概述在岩土工程领域&#xff0c;浅基础极限承载力的准确预测一直是个具有挑战性的课题。传统方法主要依赖经验公式和有限元分析&#xff0c;但这些方法要么精度有限&#xff0c;要么计算成本高昂。我们尝试将物理约束与多源知识融合的思想引入这个问题&#xff0c;开发了…

作者头像 李华
网站建设 2026/10/28 8:40:42

线性代数在AI中的核心应用与实践指南

1. 线性代数在人工智能中的核心地位线性代数作为人工智能的数学基石&#xff0c;其重要性怎么强调都不为过。我在过去五年的人工智能项目实践中发现&#xff0c;超过80%的模型底层运算最终都归结为矩阵操作。从最简单的线性回归到复杂的Transformer架构&#xff0c;线性代数的身…

作者头像 李华
网站建设 2026/10/28 8:40:42

ControlNet v1.1 Scribble版:草图控制AI图像生成技术解析

1. ControlNet v1.1 Scribble版技术解析ControlNet v1.1 Scribble版是2023年推出的AI图像生成领域突破性工具&#xff0c;它通过将手绘草图作为控制条件&#xff0c;实现了草图到精细图像的智能转换。这项技术本质上是在Stable Diffusion模型基础上添加了额外的控制网络&#x…

作者头像 李华