Spring Boot+Redis+小程序:从零搭建高并发商城系统实战
2026/9/14 3:06:13 网站建设 项目流程

简介:这是一套面向中小企业的Java微信小程序商城系统完整源码,涵盖后台管理、Vue中后台及微信小程序端三部分,采用Spring Boot、MyBatis、Redis与Vue Element UI等主流技术实现,适合Java开发者学习电商项目架构或直接二次开发。资源共1529个文件,压缩包16.33MB,其中包含404个Java后端逻辑文件、167个Vue前端页面、68个WXSS样式及50个WXML小程序模板,另有SQL脚本、YAML配置与Dockerfile等部署相关文件,代码结构清晰,便于按模块阅读。后台管理功能覆盖商品、订单、运费模板、规格、会员、运营、内容、统计报表及权限管理等,基本满足实际电商业务需求。目前已有615人学习下载,对于想快速搭建小程序商城或研究主流前后端分离方案的开发者来说,是性价比较高的参考资料。

1. 从一个商城的加载页说起

你打开一个小程序商城,商品列表秒开、购物车不丢、下单不超卖,背后的技术栈很可能就是标题里这一套:后端 Spring Boot + MyBatis + Redis,前端 Vue + Element UI 做管理后台,小程序本体用原生微信框架。这套组合在二线电商、社区团购、企业内购场景里出现频率极高,因为它足够务实——每个环节都有海量案例可查,招人容易,踩坑也有前人垫着。这不是一个追求极致性能的架构,而是一个中小团队能最快跑通、最稳交付的架构。

这篇文章会从项目落地的角度,把环境搭建、后端接口设计、Redis 的缓存与库存方案、Vue 管理后台的骨架、小程序端的登录与支付串起来讲。适合正在做毕设、接外包、或者准备 Java 面试时想拿真实业务说话的工程师。你能从中带走一套能直接抄的目录结构、一份可运行的配置,以及几个真正影响线上行为的坑。

2. 技术选型背后的业务逻辑与本地环境搭建

2.1 为什么是 Spring Boot + MyBatis + Redis 而不是其他组合

先回答一个面试常被问、也常被答错的问题:为什么用 MyBatis 而不是 JPA,为什么一定要引入 Redis?

MyBatis 的核心价值在于 SQL 可控。商城系统的查询维度极其碎片化——按类目、按价格区间、按关键字、按销量排序,每种组合都对应不同的 SQL。MyBatis 让你把 SQL 写在 XML 里,DBA 或资深开发可以直接 review 语句本身,而不是去看一层 JPQL 到 SQL 的转换。对于订单表这种行宽大、索引敏感的表,手写 SQL 能精确控制 force index、union 写法、批量 insert 的 batch 参数。

Redis 在这里的角色是"承担读多写少的热点数据"。商品详情、首页 Banner、类目树、微信 Access Token,这些数据在 Redis 里放一份,QPS 能轻松上千而数据库零压力。更关键的是购物车和分布式登录态,这两块天然适合 Redis 的 Hash 结构和过期策略,后面会展开。

2.2 本地环境的分版本选型建议

本地开发环境的版本搭配是第一个容易踩坑的地方,尤其当你的 Spring Boot 版本"太高"时。

组件推荐版本说明
JDK1.8 或 11如果用的是 Spring Boot 2.x,JDK 8 足够;Spring Boot 3.x 强制 JDK 17
MySQL5.7 或 8.05.7 配 mysql-connector-java 5.1.49;8.0 配 8.0.x 驱动
Redis5.x 以上Windows 用 tporadowski/redis 的构建版本即可
Node.js14.18 或 16.xVue 2 + Element UI 对应 Node 14;Vue 3 + Element Plus 建议 Node 16+
Maven3.6.3 或 3.8.x3.9 也能用,但镜像源要配置好

提示:Spring Boot 3.x 把 javax 包迁移到了 jakarta,如果你的项目里老代码大量使用import javax.servlet.*,升级后编译直接报错。本地新建项目时建议直接初始化 Spring Boot 2.7.x,等业务稳定后再评估是否升级。

2.3 数据库表结构的最小集

商城系统表很多,但第一版跑通只需要六张核心表:

-- 用户表(小程序用户) CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL, `nickname` VARCHAR(64) DEFAULT '', `avatar` VARCHAR(255) DEFAULT '', `phone` VARCHAR(20) DEFAULT '', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 商品表 CREATE TABLE `product` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `title` VARCHAR(128) NOT NULL, `subtitle` VARCHAR(255) DEFAULT '', `main_image` VARCHAR(255) NOT NULL, `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-上架 0-下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 CREATE TABLE `order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_sn` VARCHAR(32) NOT NULL, `user_id` BIGINT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待支付 1-已支付 2-已发货 3-已完成 4-已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_sn` (`order_sn`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

关键在于order_sn的唯一索引和order表名要加反引号,因为order是 MySQL 的保留字。

2.4 Spring Boot 的 YAML 配置与 MyBatis 映射

application.yml里的配置直接决定你调试时的体验:

server: port: 8081 spring: datasource: url: jdbc:mysql://localhost:3306/mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 timeout: 3000ms jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.mall.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

map-underscore-to-camel-case这个配置必须开,否则数据库的create_time映射不到 Java 属性的createTime上。log-impl设成 StdOutImpl,开发时能在控制台看到完整 SQL 和参数,分析问题时比任何调试器都直观。

3. 后端核心:Spring Boot 接口设计、MyBatis 查询与 Redis 缓存实践

3.1 统一返回结构与自定义异常

一个商城的后端接口风格要稳定,否则前端 Vue 和小程序端要写两套解析逻辑。我的习惯是定义一个Result<T>泛型类:

public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } }

配合@RestControllerAdvice做全局异常捕获,业务代码里只抛自定义的BizException,避免每个接口都写 try-catch。这里有一个细节,code 不要用 HTTP 状态码,统一用业务码:200 成功、400 参数错误、401 未登录、500 系统错误。前端拦截器只需要判断 code 是否为 200,与 HTTP 层解耦。

3.2 MyBatis XML 中的动态 SQL 与参数陷阱

商品列表的分页查询是商城最基础的接口,但 MyBatis 里写动态 SQL 时有一个高频坑——单个数字字符比较

<select id="searchProducts" resultType="com.example.mall.entity.Product"> SELECT * FROM product <where> <if test="categoryId != null and categoryId != ''"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND title LIKE CONCAT('%', #{keyword}, '%') </if> <if test="minPrice != null"> AND price &gt;= #{minPrice} </if> </where> ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select>

categoryId如果声明为 Integer,categoryId != ''这个判断在 MyBatis 的 OGNL 表达式里永真——Integer 与空字符串比较,结果是类型不匹配但不会报错,而是永远走不进这个条件。结果就是分类筛选失效,查出来全量数据。正确写法是只需要categoryId != null就够了。

另一个问题是LIMIT #{offset}, #{pageSize}在 MySQL 里没问题,但如果后期迁移到 PostgreSQL 或 SQL Server,语法不兼容。更稳妥的做法是用 PageHelper 插件,在 Service 层调用PageHelper.startPage(pageNum, pageSize),SQL 里不写LIMIT,由插件自动拼接。

3.3 Redis 缓存商品详情与登录态

商品详情是典型的读多写少场景。我的缓存策略是"Cache Aside"模式:

@Service public class ProductService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private ProductMapper productMapper; // 缓存 key 设计:mall:product:detail:{id} private static final String PRODUCT_DETAIL_KEY = "mall:product:detail:"; public Product getProductDetail(Long productId) { // 1. 先查缓存 String cacheKey = PRODUCT_DETAIL_KEY + productId; String cacheValue = redisTemplate.opsForValue().get(cacheKey); if (cacheValue != null) { return JSON.parseObject(cacheValue, Product.class); } // 2. 缓存未命中,查数据库 Product product = productMapper.selectById(productId); if (product != null) { // 3. 写回缓存,设置随机过期时间,防止缓存雪崩 int expireSeconds = 3600 + new Random().nextInt(600); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), expireSeconds, TimeUnit.SECONDS); } return product; } }

这里有几个要点:一是StringRedisTemplate的 key 和 value 都是 String,配合 Fastjson 或 Jackson 做序列化,不要用默认的 JdkSerializationRedisSerializer,否则 Redis 里存的是二进制乱码,排查问题时根本看不下去。二是过期时间加一个 0 到 600 秒的随机值,防止大量商品同时过期导致数据库被打穿。三是缓存空值,如果商品不存在,也要缓存一个空对象并设置 60 秒左右的短过期,否则恶意请求一个不存在的商品 ID,每次都打到数据库,这就是缓存穿透。

3.4 Spring Boot 与 Redis 的版本适配坑

热词里"springboot版本太高"就是这个原因。Spring Boot 2.x 默认使用 Lettuce 作为 Redis 客户端,而 Lettuce 在 6.x 版本里对 Redis 6 的 ACL 特性支持有问题,连接池配置和 Jedis 完全不同。

spring: redis: lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 max-wait: -1ms

如果你在 application.yml 里配置的是jedis.pool,而实际依赖是 Lettuce,启动不会报错,但连接池参数全部失效,高并发下会不断创建新连接直到 Redis 连接数打满。排查方法是:启动时看日志里的Using Lettuce RedisConnectionFactory,然后确认配置文件里的前缀是spring.redis.lettuce.pool而不是spring.redis.jedis.pool

4. Vue + Element UI 管理后台:从脚手架到可交付的骨架

4.1 Vue 项目的初始化与目录规划

管理后台用 Vue 有一件重要的事:先确认团队技术基线。如果历史项目是 Vue 2 + Element UI,就继续用vue create生成 Vue 2 项目;如果新项目,则推荐 Vue 3 + Vite + Element Plus。标题里的 Vue + Element UI 指的是组件库本身,不影响架构逻辑。

我的目录结构是这样的:

src/ ├── api/ # 按模块拆分的接口请求 │ ├── product.js │ ├── order.js │ └── user.js ├── assets/ ├── components/ # 通用组件 ├── router/ # 路由配置 ├── store/ # Vuex 或 Pinia ├── utils/ │ ├── request.js # axios 封装 │ └── auth.js # token 存取 ├── views/ # 页面组件 │ ├── login/index.vue │ ├── product/list.vue │ ├── product/edit.vue │ ├── order/list.vue │ └── dashboard/index.vue └── main.js

api目录独立出来的价值在于,后端接口改 URL 或参数名时,前端只需要改一个文件,而不用在页面组件里到处找axios.get的调用点。

4.2 axios 封装:拦截器里做登录态与错误处理

管理后台的所有请求都应该走同一个 axios 实例:

// utils/request.js import axios from 'axios' import { ElMessage } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) // 请求拦截器:注入 token service.interceptors.request.use(config => { const token = localStorage.getItem('admin_token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }, error => { return Promise.reject(error) }) // 响应拦截器:统一处理业务码 service.interceptors.response.use(response => { const res = response.data if (res.code !== 200) { if (res.code === 401) { localStorage.removeItem('admin_token') router.push('/login') } ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error(error.message) return Promise.reject(error) }) export default service

这里面的逻辑说明:baseURL从环境变量读取,开发环境指向http://localhost:8081,生产构建时用.env.production文件覆盖。响应拦截器里判断的是后端返回的业务 code,不是 HTTP 状态码,所以后端必须保证业务异常时也用 HTTP 200 返回。401 时清除本地 token 并跳转登录页,这是管理后台的标准行为。

4.3 菜单与路由:动态权限的基础写法

商城管理后台一般有商品管理、订单管理、用户管理、数据统计这几个一级菜单。权限控制不复杂的话,用 Vue Router 的全局前置守卫判断登录态即可:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('admin_token') if (to.path === '/login') { next() } else { if (!token) { next('/login') } else { next() } } })

如果需要按角色控制菜单,可以在登录接口返回rolemenuList,存入 Vuex,然后用router.addRoutes动态注册路由。

4.4 Element UI 表格与表单的落地写法

商品列表页是管理后台最典型的 CRUD 场景,用 Element UI 的el-tableel-form组合:

<template> <div class="product-list"> <el-form :inline="true" :model="queryParams" class="search-form"> <el-form-item label="商品名称"> <el-input v-model="queryParams.keyword" placeholder="输入商品名称" clearable /> </el-form-item> <el-form-item label="分类"> <el-select v-model="queryParams.categoryId" placeholder="选择分类" clearable> <el-option v-for="item in categoryOptions" :key="item.id" :label="item.name" :value="item.id" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="fetchList">查询</el-button> <el-button @click="resetQuery">重置</el-button> </el-form-item> </el-form> <el-table :data="productList" v-loading="loading" border stripe> <el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="title" label="商品名称" min-width="200" show-overflow-tooltip /> <el-table-column prop="price" label="价格" width="120"> <template slot-scope="scope"> ¥{{ scope.row.price.toFixed(2) }} </template> </el-table-column> <el-table-column prop="stock" label="库存" width="100" /> <el-table-column label="状态" width="100"> <template slot-scope="scope"> <el-tag :type="scope.row.status === 1 ? 'success' : 'info'"> {{ scope.row.status === 1 ? '上架' : '下架' }} </el-tag> </template> </el-table-column> <el-table-column label="操作" width="150" fixed="right"> <template slot-scope="scope"> <el-button type="text" @click="handleEdit(scope.row)">编辑</el-button> <el-button type="text" style="color: #f56c6c" @click="handleDelete(scope.row)">删除</el-button> </template> </el-table-column> </el-table> <el-pagination background layout="total, prev, pager, next, sizes" :total="total" :page-sizes="[10, 20, 50]" :page-size="queryParams.pageSize" @current-change="handlePageChange" @size-change="handleSizeChange" /> </div> </template>

show-overflow-tooltip这个属性值得说一句,商品名称过长时表格不会撑破布局而是省略号加 tooltip,这是管理后台表格必备的设置。

4.5 Vue 打包后布局异常的排查思路

热词里"vue 打包后 布局异常"是出现频率极高的问题。常见原因有两个:一是publicPath没配置,打包后静态资源路径变成了绝对路径/js/app.js,部署到子目录下全部 404;二是history路由模式在 Nginx 没做 try_files 回退,刷新子页面直接 No matching location。

// vue.config.js module.exports = { publicPath: process.env.NODE_ENV === 'production' ? '/admin/' : '/', outputDir: 'dist', productionSourceMap: false }

对应的 Nginx 配置:

location /admin/ { alias /var/www/mall-admin/; try_files $uri $uri/ /admin/index.html; }

try_files的作用是:请求/admin/order/list时,服务器找不到这个真实路径,就回退到/admin/index.html,由前端路由接管。如果缺少这一行,刷新订单管理页面就会出 404 或白屏。

5. 微信小程序端:登录、request 封装与支付流程的避坑清单

5.1 小程序目录结构与 app.json 配置

小程序端与后台共用一套后端接口,但目录结构相对独立:

miniprogram/ ├── app.js # 全局逻辑,检查登录态 ├── app.json # 全局配置 ├── app.wxss # 全局样式 ├── utils/ │ ├── request.js # 封装 wx.request │ └── util.js ├── pages/ │ ├── index/ # 首页 │ ├── category/ # 分类 │ ├── cart/ # 购物车 │ ├── user/ # 个人中心 │ └── order/ │ ├── confirm/ # 确认订单 │ └── list/ # 订单列表 └── components/

app.json 里有一个配置很容易被忽略——lazyCodeLoading: "requiredComponents",它能显著减少小程序启动时的代码注入量。对于商城这种页面多、组件多的小程序,这个配置能让冷启动速度快 30% 左右。

5.2 request 封装与微信特有场景的兼容

小程序的wx.request和 axios 不一样,它有诸多限制:

// utils/request.js const BASE_URL = 'https://api.example.com' function request(url, method, data) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method: method, data: data, header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + wx.getStorageSync('token') }, success(res) { if (res.statusCode === 200 && res.data.code === 200) { resolve(res.data.data) } else if (res.data.code === 401) { // token 过期,重新登录 wx.removeStorageSync('token') wx.navigateTo({ url: '/pages/login/index' }) reject(res.data) } else { wx.showToast({ title: res.data.message, icon: 'none' }) reject(res.data) } }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }) reject(err) } }) }) } module.exports = { request }

BASE_URL必须改成备案过的 HTTPS 域名,这是微信的硬性要求。开发阶段可以在开发者工具右上角勾选"不校验合法域名",但真机预览时域名必须是 HTTPS 且在公众平台配置过 request 合法域名。另外注意wx.request的 timeout 默认 60 秒,商城接口建议显式设置timeout: 10000,慢查询尽早失败比一直转圈好。

5.3 微信登录流程:code 换 token 的实际链路

微信小程序的登录和传统账号密码完全不同,它依赖微信的授权体系:

// app.js - 登录逻辑 App({ onLaunch() { this.checkLogin() }, checkLogin() { const token = wx.getStorageSync('token') if (token) { // 有 token,直接进首页 return } // 无 token,走静默登录 wx.login({ success: (res) => { const code = res.code wx.request({ url: BASE_URL + '/api/auth/login', method: 'POST', data: { code: code }, success: (loginRes) => { const { token, userInfo } = loginRes.data.data wx.setStorageSync('token', token) wx.setStorageSync('userInfo', userInfo) } }) } }) } })

后端拿到code后,需要调用微信的jscode2session接口换取 openid:

@PostMapping("/api/auth/login") public Result<LoginVO> login(@RequestBody LoginRequest request) { // 1. 调用微信接口获取 openid String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId + "&secret=" + appSecret + "&js_code=" + request.getCode() + "&grant_type=authorization_code"; String result = restTemplate.getForObject(url, String.class); JSONObject json = JSON.parseObject(result); String openid = json.getString("openid"); // 2. 查询或创建用户 User user = userService.findByOpenid(openid); if (user == null) { user = userService.register(openid); } // 3. 生成 token 存入 Redis String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set("mall:token:" + token, openid, 7, TimeUnit.DAYS); // 4. 返回 token return Result.success(new LoginVO(token, user)); }

这段逻辑里jscode2session返回的 openid 是关键。注意不要把appSecret写在小程序端代码里,它只能在后端使用,这是安全红线。

5.4 小程序跳转链接weixin://dl/business的生成与触发

热词里有"微信小程序跳转链接weixin://dl/business从生成到触发的全流程",这个 URL Scheme 的准确用法值得细说。weixin://dl/business是微信内部用于拉起小程序的协议,但它不能被前端直接拼接。微信要求:在小程序后台"工具-生成 URL Scheme"或者通过云开发调用getUnlimitedURLScheme接口生成带签名的链接,链接形式是完整的weixin://dl/business/?t=xxxxx,其中t参数是签名后的 token。

// 小程序内跳转另一个小程序,使用 wx.navigateToMiniProgram 而不是拼 URL wx.navigateToMiniProgram({ appId: '目标小程序的appid', path: 'pages/index/index?from=mall', success(res) { console.log('跳转成功', res) }, fail(err) { console.log('跳转失败', err) } })

wx.navigateToMiniProgram只能跳转到已在公众平台"关联小程序"里添加过的小程序,否则会在fail回调里收到错误。而 URL Scheme 通常用于外部场景,比如短信、邮件、H5 网页里拉起小程序。触发时机也要注意:在 iOS 上weixin://dl/business只能在微信内置浏览器里有效,在 Safari 或浏览器 App 里直接打开会被系统拦截并提示"无法打开链接"。

6. 订单并发下的库存扣减、缓存一致性与 MyBatis 缓存配置细节

第六章收在三个最影响线上稳定性的技术上:库存防超卖、缓存一致性、MyBatis 二级缓存的开与关。

6.1 Redis 预扣库存:Lua 脚本保证原子性

商城秒杀或高并发下单时,直接用数据库UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0虽然能防超卖,但每次都命中数据库行锁,性能不够。常见做法是 Redis 预扣库存:

-- 库存扣减 Lua 脚本 if redis.call('get', KEYS[1]) == false then return -1 end local stock = tonumber(redis.call('get', KEYS[1])) if stock < tonumber(ARGV[1]) then return -1 end redis.call('decrby', KEYS[1], ARGV[1]) return 0

Spring Boot 中调用:

DefaultRedisScript<Long> script = new DefaultRedisScript<>(LUA_SCRIPT, Long.class); Long result = redisTemplate.execute(script, Arrays.asList("mall:stock:" + productId), String.valueOf(count)); if (result == -1) { throw new BizException("库存不足"); }

Lua 脚本在 Redis 中原子执行,多个请求并发执行时不会出现超卖。DB 的库存可以异步扣减,或者在支付回调时再真正落库,这就是常见的"预扣 + 异步对账"方案。

注意:Redis 宕机会导致库存数据丢失,所以 Redis 里的库存只能作为前置校验,最终一致性要靠数据库兜底。稳妥做法是下单时 Redis 预扣成功才允许创建订单,订单支付时数据库执行UPDATE product SET stock = stock - #{count} WHERE id = #{id} AND stock >= #{count},失败则回滚 Redis 库存。

6.2 缓存一致性:先删缓存还是先更新数据库

商品管理员在后台改了商品价格,如果 Redis 缓存没清,用户端看到的还是旧价格。缓存一致性是分布式系统的经典问题,但商城里用最简单可靠的方式就能解决:

Cache Aside模式下的标准操作是"先更新数据库,再删除缓存"。注意是删除而不是更新——更新缓存会有并发风险:两个请求同时更新数据库,先更新的请求后写缓存,导致缓存里是旧数据。删除缓存则没有这个问题,读请求在缓存缺失时会把最新数据写回去。

@Transactional public void updateProduct(Product product) { productMapper.updateById(product); // 事务提交后删除缓存 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { redisTemplate.delete("mall:product:detail:" + product.getId()); } }); }

删缓存放在afterCommit里是一个优化的做法,如果放在事务方法内,事务还没提交就删了缓存,其他线程查库拿到的是旧数据然后写回缓存,缓存又被污染了。

6.3 MyBatis 二级缓存:默认关闭才是对的

MyBatis 的一级缓存是 SqlSession 级别,默认开启,同一个 SqlSession 内相同查询会走缓存。二级缓存是 namespace 级别,跨 SqlSession 共享,但默认关闭。

很多项目图方便直接打开二级缓存,这在高并发商城是一个隐患:

<mapper namespace="com.example.mall.mapper.ProductMapper"> <cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/> </mapper>

<cache>标签开启了 ProductMapper 的二级缓存,商品数据在 60 秒内不会再次查询数据库。但问题在于:如果后台修改了商品,调用的是同一个 namespace 下的updateById,MyBatis 会清除该 namespace 的缓存,这没问题。可如果订单表有外键关联到商品表,或者用了多表 join 查询,其他 namespace 里的查询结果不会同步失效,就会读到脏数据。

我的建议是商城项目里 MyBatis 二级缓存保持默认关闭,缓存统一交给 Redis 管理。原因有二:一是 MyBatis 二级缓存是本地缓存,多实例部署时每个服务各存一份,数据一致性难保证;二是 Redis 的过期时间、淘汰策略、锁机制都比 MyBatis 的 cache 标签灵活得多。让 MyBatis 只负责 SQL,缓存专注交给 Redis,职责边界清晰,排查问题也更快。

三个技巧里,Lua 脚本扣库存解决的是"写"的原子性,删除缓存解决的是"读"的一致性,Redis 替代 MyBatis 二级缓存解决的是"多实例"的扩展性。把这三点落地到你的商城项目里,再配合压测工具模拟 100 并发下单,观察 Redis 的decrby调用次数和数据库最终库存,验证数据一致后,这套架构在一两千 QPS 的场景下足够稳定。

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

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

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

立即咨询