ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

购物商城项目实战:架构设计、库存扣减与支付回调避坑指南

购物商城项目实战:架构设计、库存扣减与支付回调避坑指南 简介购物商城系统压缩包由用户liezu7整理上传适合学习电子商务开发、希望了解商品交易全流程的技术人员也可作为课程设计或项目练手的参照。项目核心功能覆盖用户注册与登录、商品浏览搜索、购物车管理、订单提交与支付等环节基本体现了一个轻量级电商平台应有的业务模块。压缩包整体大小为3.29MB上游未提供具体文件数量与类型明细从描述推测可能包含前端页面源码、后端处理代码、数据库建表脚本或必要的配置文件下载后可通过目录结构了解系统组织方式。目前已有219人浏览学习对打算搭建小型购物网站或完善已有项目的读者有一定借鉴意义。深入研究这份资源可以梳理用户身份认证、购物车数据存储、支付接口调用等关键技术的实现细节从而掌握从商品选择、加入购物车到完成结算的完整链路并为后续扩展功能打下基础。1. 购物商城项目为什么说核心不在“能买”而在“买不错”一个购物商城系统跑通“加购-下单-支付”的流程新手往往只需要两周但要让它在高并发下不出超卖、不丢订单、不重复扣款却需要踩完一整轮生产事故才能稳住。这个方向的技术含量集中在交易链路的正确性上——库存怎么扣才不超卖订单状态机怎么设计才不混乱支付回调怎么处理才不重复入账。本文直接面向动手做购物商城系统的同学给出可落地的架构选型、核心表结构、关键代码和真实踩坑记录目标是让你从“能跑通演示”走到“敢上线扛流量”。2. 先把架构层定住单体优先的选型逻辑与数据模型2.1 单体架构为什么是购物商城项目的默认起点很多同学拿到“购物商城”这个命题第一反应是上微服务商品服务、订单服务、支付服务、用户服务各拆一个工程再用 Nacos 注册、OpenFeign 调用。这个思路不能说错但要看你面对的是什么量级。常见做法是日订单量在十万级以内的购物商城系统单体架构的维护成本远低于微服务而单机 MySQL Redis 的吞吐足以支撑这个量级。微服务引入的分布式事务、链路追踪、服务治理问题会让你在业务逻辑还没写熟的时候先陷入基础设施的泥潭。我一般会建议先把单体做扎实再按业务压力点逐步拆分。单体不是“技术落后”的代名词。Spring Boot 单体应用配合 MySQL 和 Redis可以覆盖购物商城 90% 以上的核心链路真正需要拆分的信号是你发现某个模块的发布频率、扩容维度、故障隔离需求和别人不一样了——比如秒杀流量把整个应用拖垮这时才值得把商品浏览服务拆出去。架构选型的本质是“延迟决策”不是“一步到位”。下面这张表可以帮你判断自己该走哪条路决策维度单体架构微服务架构日订单量十万级以下百万级以上或快速扩张中团队规模10 人以内多个独立团队并行故障隔离一个进程挂全部挂单服务故障不影响全局开发调试成本单工程启动快、断点跟得清跨服务调试需要链路追踪部署运维单包部署简单直接需要 CI/CD、容器编排、注册中心适合场景标准购物流程 秒杀缓存层扛流量多端多业务线并行、独立弹性伸缩选型定了就不要再左右摇摆。购物商城最怕的是架构反复重写业务代码跟着推倒重来这个行业里见过太多“为了微服务而微服务”的项目最后连上线都没撑到。2.2 技术栈骨架与工程目录划分单体购物商城的常见技术栈组合是 Spring Boot MyBatis-Plus MySQL Redis Vue后台管理。选择 Spring Boot 是因为它的生态最完整几乎你能想到的电商中间件都有成熟 starter选择 MyBatis-Plus 而不是 JPA是因为电商 SQL 复杂、需要精细控制批量更新和分页MP 在单表操作和代码生成上效率很高复杂查询又可以直接写 XML。Redis 在这里承担四个职责商品缓存、购物车存储、分布式锁、秒杀库存预热。这套组合在中小规模下几乎没有明显短板。工程目录我习惯按业务模块分包而不是按技术层分包。按层分包controller / service / mapper 各一层会导致商品、订单、支付三个业务互相纠缠改一个订单查询要翻遍整个 service 目录。按业务分包的效果是这样com.shop ├── common # 通用返回体、异常处理、工具类 ├── product # 商品与SKUcontroller / service / mapper / entity ├── cart # 购物车Redis操作与DB持久化 ├── order # 订单下单、状态机、超时处理 ├── payment # 支付回调接收、对账、退款 ├── user # 用户登录、收货地址、余额 └── admin # 后台管理商品上架、订单查询、数据统计按业务分包后代码内聚性明显提升。新同学接手时直接按业务找包不会迷路。另外所有跨模块调用统一走 service 接口不允许 controller 直接调别的模块的 mapper这样将来真要拆微服务时拆分边界已经天然画好了。2.3 四张核心表的结构设计与字段说明购物商城的表可以拆出几十张但核心链路只需要四张表就能讲清楚商品表、SKU 表、订单表、订单明细表。商品是 SPU标准化产品单元商品表存标题、描述、类目、图片等公共信息SKU 表存具体规格颜色、尺码、价格、库存。用户在购物车和订单里看到的其实是 SKU 级的数据下单库存扣减也必须锁在 SKU 上。CREATE TABLE product ( id bigint NOT NULL AUTO_INCREMENT, title varchar(255) NOT NULL COMMENT 商品标题, sub_title varchar(255) DEFAULT NULL COMMENT 副标题/卖点, category_id bigint NOT NULL COMMENT 类目ID, main_image varchar(500) DEFAULT NULL COMMENT 主图URL, status tinyint NOT NULL DEFAULT 0 COMMENT 0草稿 1上架 2下架, created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category_status (category_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表(SPU); CREATE TABLE sku ( id bigint NOT NULL AUTO_INCREMENT, product_id bigint NOT NULL, attrs varchar(500) NOT NULL COMMENT 规格JSON: {颜色:白,尺码:M}, price bigint NOT NULL COMMENT 价格(分), stock int NOT NULL DEFAULT 0 COMMENT 可售库存, frozen_stock int NOT NULL DEFAULT 0 COMMENT 冻结库存(下单未支付), version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, status tinyint NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY idx_product_id (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTSKU表;订单表的关键设计在于状态字段和订单号。状态字段用 tinyint 表示0 待支付、1 已支付待发货、2 已发货、3 已完成、4 已取消、5 售后中不要存字符串。订单号不要用数据库自增 ID容易暴露销量且不利于分库分表常见做法是用“日期 用户ID后四位 随机序列”生成一个 20 位以内的业务单号。订单明细表关联 SKU 快照注意这里要冗余商品标题、规格、下单时的价格图片因为商品后续可能改价或下架但订单里的历史信息不能被影响。CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号, user_id bigint NOT NULL, total_amount bigint NOT NULL COMMENT 总金额(分), status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已完成 4已取消 5售后中, address_snapshot varchar(1000) NOT NULL COMMENT 收货地址快照JSON, paid_at datetime DEFAULT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表; CREATE TABLE order_item ( id bigint NOT NULL AUTO_INCREMENT, order_id bigint NOT NULL, sku_id bigint NOT NULL, product_title varchar(255) NOT NULL COMMENT 商品标题快照, sku_attrs varchar(500) NOT NULL COMMENT 规格快照, price bigint NOT NULL COMMENT 成交单价(分), quantity int NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;price 字段这里统一用 bigint 存“分”而不是用 decimal 存“元”。这个选择背后的坑在后面的避坑章节会细说这里先记住一个原则购物商城所有金额相关的存储和计算一律用最小货币单位整数。3. 购物流程代码落地加购、下单、库存扣减与支付回调3.1 商品浏览的缓存策略读多写少怎么扛压商品详情页是购物商城里读压力最大的接口一个热门商品的详情页在活动期间可能每分钟被刷几百次。常见做法是使用缓存投影模式详情页的后端接口在收到请求时先读 Redis 里的商品缓存命中就直接返回没命中就去查数据库异步把结果写回 Redis 并设置过期时间。同时后台商品编辑保存时要主动删除相关缓存保证下一次读取拉到的还是新数据。这个模式代码逻辑很简单但缓存击穿、穿透、雪崩三个问题要一起防住。public ProductVO getProductDetail(Long skuId) { String key cart:product:detail: skuId; // 1. 先查缓存 String cached redisTemplate.opsForValue().get(key); if (StringUtils.hasText(cached)) { return JSON.parseObject(cached, ProductVO.class); } // 2. 加短时间本地锁防止大量请求同时击穿DB String lockKey lock:product:detail: skuId; boolean locked lockUtil.tryLock(lockKey, Duration.ofSeconds(3)); if (!locked) { // 没拿到锁就先查一次DB不阻塞等待 ProductVO data queryFromDb(skuId); if (data ! null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(data), Duration.ofMinutes(30)); } return data; } try { // 3. 双检锁内再查一次缓存 String cachedAfterLock redisTemplate.opsForValue().get(key); if (StringUtils.hasText(cachedAfterLock)) { return JSON.parseObject(cachedAfterLock, ProductVO.class); } ProductVO data queryFromDb(skuId); if (data ! null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(data), Duration.ofMinutes(30)); } else { // 4. 防穿透空值也缓存60秒 redisTemplate.opsForValue().set(key, , Duration.ofSeconds(60)); } return data; } finally { lockUtil.unlock(lockKey); } }这段代码里两个容易被忽略的点一是空值缓存否则不存在商品 ID 的请求会反复穿透到数据库压垮 MySQL二是锁等待策略这里没有让请求阻塞等待锁释放因为 3 秒本地锁不足以让排队者都等到新鲜缓存不如直接放行查库这样极端情况下的代价是少数几个请求打到数据库而不是所有请求都串行化。缓存时间根据商品活动频率设置日常商品 30 分钟合理秒杀商品要缩短到几秒甚至不缓存。3.2 购物车模块用 Redis Hash 做存储还是落库购物车的实现有两种主流方案纯 Redis 存储和 Redis 数据库双写。对于“购物商城”级别的项目纯 Redis 有一个致命问题用户购物车里的商品如果超过 Redis 过期时间没访问直接被清空了这是不可接受的。所以常见做法是 Redis 做主存储、MySQL 做持久化备份用户在正常操作购物车时先写 Redis 异步落库Redis 数据丢失时从库里恢复。Redis 里用 Hash 结构key 是用户 IDfield 是 SKU IDvalue 是数量与加购时间的 JSON。public void addCartItem(Long userId, Long skuId, Integer count) { String cartKey cart:user: userId; // 1. 先写Redishash操作是原子的 String field String.valueOf(skuId); MapString, String item new HashMap(); item.put(count, String.valueOf(count)); item.put(checked, 1); item.put(addTime, String.valueOf(System.currentTimeMillis())); redisTemplate.opsForHash().put(cartKey, field, JSON.toJSONString(item)); redisTemplate.expire(cartKey, Duration.ofDays(15)); // 2. 异步落库防止阻塞主流程 cartPersistService.persistLater(userId, skuId, count); }购物车接口的响应速度很重要因为用户加购后的每一次改动都要实时反馈。这里把 Redis 操作放在主线程落库丢给异步任务购物车的读写全部走 Redis数据库只是“后悔药”。需要提醒的是Hash 里的 value 不要只存数量一定要带上“勾选状态”和“加购时间”否则后续做批量下单和购物车有效期清理时没有依据。购物车数量变化用opsForHash().increment而不是先读后写避免两个请求同时修改数量导致覆盖丢失。3.3 下单与库存扣减用一条 SQL 原子操作干翻超卖下单是整个购物商城最核心的事务涉及三件事扣减库存、创建订单、清空已勾选的购物车项。库存扣减绝对不能先查库存再判充足再 update这种“先查后写”在并发下必然超卖。常见的解法是让扣减在一条 UPDATE 里带上条件MySQL 的行锁和条件判断合在一起天然原子。Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long userId, ListCartItemVO items, AddressSnapshot address) { String orderNo generateOrderNo(userId); long totalAmount 0; ListOrderItem orderItems new ArrayList(); for (CartItemVO item : items) { // 1. 原子扣减库存stock quantity 才允许扣 int updated skuMapper.deductStock(item.getSkuId(), item.getQuantity()); if (updated 0) { throw new BizException(商品库存不足或已被抢光); } orderItems.add(buildOrderItem(item, orderNo)); totalAmount item.getPrice() * item.getQuantity(); } // 2. 创建订单主表和明细 orderMapper.insert(Order.builder() .orderNo(orderNo) .userId(userId) .totalAmount(totalAmount) .status(0) .addressSnapshot(JSON.toJSONString(address)) .build()); orderItemMapper.batchInsert(orderItems); // 3. 清空购物车已勾选商品 cartService.removeCheckedItems(userId); return OrderVO.of(orderNo, totalAmount); }update iddeductStock UPDATE sku SET stock stock - #{quantity}, frozen_stock frozen_stock #{quantity}, version version 1 WHERE id #{skuId} AND stock #{quantity} AND status 1 /update这段逻辑有几个关键设计。第一扣减的 WHERE 条件里带上stock #{quantity}数据库行锁保证同一时间只有一个事务能成功执行这一行并发请求中必然有且仅有一个满足条件超卖从根上被堵死。第二扣减同时把frozen_stock增加相同数量这就是“冻结库存”——用户下单未支付期间这部分库存要锁定给这个订单释放时机在“超时未支付解冻”或“订单取消解冻”。第三version 字段顺手加一在后续做订单取消恢复库存时要用这里提前维护好。整个下单方法用事务包住中间任何一步抛异常都会回滚库存扣减不会出现“库存扣了但订单没生成”的情况。3.4 支付回调幂等处理比业务逻辑更重要支付回调是购物商城最容易出生产事故的接口。支付平台会多次通知同一个支付结果并且不保证顺序如果你的回调处理逻辑没有幂等保护一笔订单可能被重复更新两次状态。常见的方案是在回调处理入口加“订单号 支付流水号”的唯一约束在事务里先查后改配合数据库唯一索引防住并发重复回调。Transactional(rollbackFor Exception.class) public void handlePayCallback(String orderNo, String paySerialNo, Long paidAmount) { // 1. 幂等校验支付流水表已存在则直接返回 PaymentRecord record paymentRecordMapper.selectByPaySerialNo(paySerialNo); if (record ! null) { return; } // 2. 校验订单状态只有待支付状态能流转到已支付 Order order orderMapper.selectByOrderNo(orderNo); if (order null || order.getStatus() ! 0) { log.warn(回调状态异常: order{}, currentStatus{}, orderNo, order.getStatus()); return; } if (!order.getTotalAmount().equals(paidAmount)) { log.error(支付金额不一致: order{}, expect{}, actual{}, orderNo, order.getTotalAmount(), paidAmount); return; } // 3. 插入支付流水并更新订单状态用状态条件更新防并发 paymentRecordMapper.insert(PaymentRecord.builder() .orderNo(orderNo) .paySerialNo(paySerialNo) .amount(paidAmount) .build()); int updated orderMapper.updateStatusByCondition( orderNo, 0, 1, LocalDateTime.now()); if (updated 0) { throw new BizException(订单状态更新失败可能已被其他回调处理); } }回调接口的返回值也要注意支付平台约定返回“SUCCESS”才算处理成功处理失败返回其他内容会促使平台继续重试。这里把幂等校验放在事务开头paySerialNo在支付流水表上有唯一索引极端情况下两个回调同时进来一个插入成功另一个会因唯一键冲突而报错回滚安全性有保证。金额校验必须严格相等因为现实中存在“用户支付了 1 分钱测试回调”或者“支付金额被篡改”的风险对不上就直接拒绝更新状态。4. 购物商城开发避坑指南超卖、回调风暴与金额计算的五个血泪教训4.1 超卖的“黑匣子”现象库存显示有货提交订单却失败现象压测时模拟 200 个并发用户同时抢购同一件商品数据库里库存只剩 10 件但实际生成的订单超过了 10 个甚至出现库存变负数的情况。原因代码用“先 SELECT 查库存再判断充足后 UPDATE”的流程两个操作之间没有事务隔离并发请求都读到相同的剩余库存 10然后各自执行扣减导致最终扣了 20 多件。解决扣减库存的 SQL 必须带上stock #{quantity}条件并确保扣减语句在事务里执行。不要依赖应用层代码判断库存让数据库行锁帮你挡并发。上线前用 JMeter 压测你的扣减接口观察一定并发下的订单量与库存变化量是否严格一致这一步血泪经验能帮你省下大促时赔给用户的损失。4.2 支付回调风暴一次支付触发了后端十条重复日志现象支付平台在 5 秒内连续回调同一个订单七次日志里同一笔订单的支付更新逻辑反复执行订单状态被覆盖twice支付流水表出现了两条不同流水号的数据。原因回调接口没有做幂等第一次回调更新了订单状态后续重复回调又进入更新逻辑状态字段从“已支付”被改回“待支付”。解决设计一个支付流水表paySerialNo字段加唯一索引每次回调先插入流水再更新订单插入失败说明重复回调直接返回成功同时订单状态更新要带条件UPDATE orders SET status1 WHERE order_no? AND status0保证状态只能从待支付流转到已支付不可能倒流。4.3 金额用 double 计算导致分账差出几毛钱现象商品单价 19.9 元买 3 件前端展示总价 59.7 元订单表里却存成了 59.699999 元用户支付时对不上账。原因double 类型的浮点数在计算机里是二进制近似表示小数运算会产生精度丢失19.9 在二进制里本身就是一个无限循环小数。解决金额存储一律用整数“分”。Java 里用 long 类型数据库用 bigint前端展示时再除以 100 转成元。涉及金额计算的运算全用整数运算避免任何浮点类型参与。这个原则要从商品表设计贯穿到订单、购物车、退款、对账每一个环节一旦中途出现 double返工成本极高。4.4 Redis 缓存与数据库库存不一致后台改库存前端永远不变现象运营在后台把某商品库存从 100 改成 50前端商品详情页显示的还是 100下单时却提示库存不足用户一头雾水。原因后台更新数据库后没有主动清除 Redis 缓存缓存过期时间又设得长用户读的一直是旧缓存。解决后台商品编辑接口在更新数据库后主动删除对应 SKU 的缓存 key同时缓存时间不要设太长日常 30 分钟是合理的上限。更稳的做法是延迟双删——更新数据库后先删缓存等几百毫秒再删一次防止“更新数据库期间有请求把旧值写回缓存”的竞态发生。4.5 订单超时未支付库存解冻的调度任务踩了重复释放的坑现象用户下单后一直没支付定时任务扫描超时订单并解冻库存结果同一订单被重复取消两次库存被加了两次。原因取消订单的逻辑没有幂等保护定时任务扫到状态已是“已取消”的订单依然执行了恢复库存操作。解决取消订单使用条件更新UPDATE orders SET status4 WHERE order_no? AND status0影响行数为 0 说明订单已被处理过直接跳过。库存恢复操作再插入一条库存变动流水用“订单号 操作类型”做唯一索引确保同一订单只能恢复一次。5. 从“能下单”到“能运营”后台管理、库存对账与订单超时处理5.1 库存流水表购物商城的“后悔药”与审计依据维护库存不能只盯着 sku 表里的一个 stock 字段随便改前台还是后台数据一旦出错根本没有追溯依据。常见做法是为库存变动建立流水表每一次扣减、解冻、手动调整都记录一条明细后台“库存调整”功能也要强制填写变更原因。这张表在产品初期看似多余但到了对账、差错排查、用户投诉时就是救命稻草。CREATE TABLE stock_log ( id bigint NOT NULL AUTO_INCREMENT, sku_id bigint NOT NULL, change_type tinyint NOT NULL COMMENT 1下单冻结 2支付确认 3超时解冻 4取消解冻 5后台手动调整, change_quantity int NOT NULL COMMENT 正数增加负数减少, before_stock int NOT NULL COMMENT 变动前可售库存, after_stock int NOT NULL COMMENT 变动后可售库存, order_no varchar(32) DEFAULT NULL COMMENT 关联订单号手动调整可为空, operator_id bigint DEFAULT NULL COMMENT 操作人ID系统操作为空, remark varchar(255) DEFAULT NULL COMMENT 原因备注, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_sku_id (sku_id), KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存变动流水表;在扣减库存的事务里把扣减和写入流水放在同一事务任何一步失败都整体回滚。这样库存主档、冻结字段、流水记录三者永远一致。后台对账时可以写一个对账脚本扫描某天所有库存流水汇总变动量对比 sku 表当前的 stock 值偏差为 0 才正常。我见过很多项目省略了库存流水结果运营手滑改错库存用户下单才发现问题但数据已经被污染连改回去的依据都没有。5.2 订单管理后台的查询优化百万订单量下的索引与分页策略后台订单管理页面最常用的操作是“按状态查订单”和“按用户查订单”听起来简单但只要订单量过几十万不带索引的全表扫描就会拖垮数据库。订单表的索引在设计阶段就要建好idx_user_status支持按用户查订单列表idx_status_created支持按状态加时间范围筛选。排序字段尽量和索引的列顺序对齐否则 MySQL 的 filesort 会在数据量上来后明显变慢。后台分页还有一个坑深度翻页。LIMIT 100000, 20这种写法会让 MySQL 扫描前 100020 行再丢弃前 100000 行速度极慢。常见的优化手段是改为“书签分页”上一次请求拿到最后一行的 ID下一页带上WHERE id last_id ORDER BY id LIMIT 20。这种方案在后台场景完全够用而且实现简单比各种复杂的游标方案可靠得多。如果后台又需要按时间范围筛选又需要大偏移分页就做一个宽表加 ES 的方案但这不是购物商城项目的优先项数据量到了再说。5.3 超时未支付订单延迟队列与定时任务的取舍用户下单后 15 分钟不支付系统要自动取消并释放库存。这个功能最简单的实现是定时任务每分钟扫一次订单表把超过 15 分钟的待支付订单批量取消。但扫描式任务有两个隐患第一数据库压力大订单多时每分钟全表扫超时订单的效率很低第二取消动作的精确时间不可控用户可能在第 15 分钟整的时候点击支付却发现订单刚好被取消了。更优雅的常见做法是延迟消息队列。下单时往 RocketMQ 或 RabbitMQ 发一条延时消息延迟时间设为 15 分钟消息到达时消费者去查订单状态如果仍是待支付就取消订单并解冻库存。下单和发消息要放在同一个事务里消息没发成功就回滚订单避免出现“订单创建了但没人管超时”的孤儿单。如果团队没有引入 MQ退而求其次用 Redis 过期事件也能实现类似效果但 Redis 的 Key 过期通知并不保证实时可靠可能延迟几分钟甚至丢失这一点务必在方案评审时说清楚。6. 进阶技巧把一次性查询变成缓存批量读取你的商品详情页还能再快一倍商品详情页在并发上来后一个容易忽略的瓶颈是“循环查数据库”。用户打开购物车结算页时前端一次性传了十个 SKU ID后端如果 for 循环里挨个查详情十次数据库往返的延迟会被页面完整等待这在 Redis 缓存命中率高时是纯浪费。正确的做法是改成批量读缓存一次管道命令把十个 SKU 的缓存全部取回缓存未命中的再回源数据库补查。public ListProductVO batchGetProducts(ListLong skuIds) { // 1. 用pipeline批量读缓存网络开销从N次降到1次 ListObject cacheData redisTemplate.executePipelined((RedisCallbackObject) connection - { for (Long skuId : skuIds) { byte[] key (cart:product:detail: skuId).getBytes(StandardCharsets.UTF_8); connection.stringCommands().get(key); } return null; }); // 2. 筛选未命中的SKU ID回源数据库 ListLong missIds new ArrayList(); MapLong, ProductVO resultMap new HashMap(); for (int i 0; i skuIds.size(); i) { String json (String) cacheData.get(i); if (json ! null !json.isEmpty()) { resultMap.put(skuIds.get(i), JSON.parseObject(json, ProductVO.class)); } else { missIds.add(skuIds.get(i)); } } if (!missIds.isEmpty()) { ListProductVO dbList productMapper.selectByIds(missIds); for (ProductVO vo : dbList) { resultMap.put(vo.getId(), vo); redisTemplate.opsForValue().set( cart:product:detail: vo.getId(), JSON.toJSONString(vo), Duration.ofMinutes(30)); } } return skuIds.stream().map(resultMap::get).collect(Collectors.toList()); }这里使用executePipelined把多次 Redis 读写合并成一次网络往返在高延迟网络环境下优化效果非常明显。还有个容易被忽略的细节missIds的兜底查询要限制一次最多查 50 个 ID防止前端传入超长 SKU 列表把数据库打爆超出的部分直接丢弃并记录告警日志。商品信息的缓存有效期要根据运营活动调整大促期间详情页数据要实时准确宁可缓存命中率下降也不能让用户看到已经变价的商品。最后说一个我自己的习惯任何缓存更新操作完成后我都会在测试环境用脚本模拟一次“缓存过期瞬间并发请求”的场景观察是否有大量请求穿透到数据库。这个习惯的来源是一次线上事故——大促开始时运营批量改了商品价格导致缓存大面积失效所有详情页请求同时打到数据库数据库连接池瞬间被打满整个商城响应超时。后来我在所有批量更新商品的地方都加了缓存热修复队列更新完数据库后立即由任务系统重建热门商品的缓存而不是等用户请求来触发。这个方向做了三个购物商城项目后我更加确信购物商城拼的不是谁的下单流程写得多花哨而是谁在流量冲击下还能保持数据一致和接口响应稳定。希望帮到你。本文还有配套的精品资源点击获取
返回列表