最近刚把一个从零搭建的订单交易后台完整落地,从架构设计、数据库建模到每一段核心代码都完整地走了一遍。借着这次复盘,我把整个过程中的设计思路、关键实现和踩过的坑整理出来,希望对正在做类似项目的朋友有参考价值。这篇文章不会只讲概念,而是把架构到代码的完整链路摊开讲,包括每个决策背后的原因、核心类怎么写、每条SQL为什么长这样,都尽量还原。
这个项目解决的核心问题很典型:订单创建、库存扣减、支付回调这一条交易链路,在并发场景下如何保持数据一致、系统稳定。它不负责解决大厂级别的海量流量问题,而是把常规并发量下的事务边界、幂等控制、缓存一致性问题做到清晰可控。适合的人群很广:后端开发、全栈工程师,以及准备从单体往服务化方向走的团队,都可以从这套设计里取到你需要的部分。项目做完以后,我最大的感受是:真正值钱的不是那几千行代码,而是前期做的四个架构决策——业务模型怎么拆、状态机怎么定、扣减模式怎么选、事务边界怎么划。后面所有代码,都是在为这四个决策买单。
1. 项目全貌与整体设计思路
1.1 要解决的问题与目标场景
先说清楚业务边界。这是一个典型的C端交易场景:用户浏览商品、下单、支付、后台发货。核心链路包括创建订单、锁定库存、支付回调、扣减库存或回补库存、异步通知下游系统。整个链路不算复杂,但坑非常多。
我归纳下来,需要重点解决的四个问题:
- 并发扣减:同一件商品同时被大量用户下单,库存怎么做到不超卖。
- 状态流转:订单从创建到支付、取消、发货,状态之间不能乱跳,不能让并发请求把订单状态改错。
- 幂等控制:客户端重复提交、支付平台多次回调、消息重复送达,这些场景都不能导致重复下单、重复发货。
- 一致性与性能的平衡:既不能为了强一致把所有操作都锁成串行,也不能为了性能把一致性丢掉。核心交易链路必须可靠,但也要扛得住流量冲击。
围绕这四个问题,整个系统从数据模型到代码实现,再到基础设施选型,都是围绕着它们去设计的。
1.2 技术选型与架构决策
技术栈选了 Spring Boot + MySQL + Redis + RabbitMQ,单体应用,内部模块化解耦。有一些朋友可能会问,都202X年了,为什么不直接上微服务?我的答案是:看业务复杂度。这个项目的业务核心是订单、库存、支付流水,看起来模块不少,但本质上还是同一个领域内的内聚逻辑。这时候强行拆微服务,引入分布式事务、服务治理、链路追踪的成本,远远大于收益。单体架构配合清晰的包结构,事务边界天然可控,部署调试也简单,后续业务复杂度上来了再按模块拆分,一点都不晚。
各个技术组件在这个架构里的分工是这样的:
| 技术组件 | 架构职责 | 关键权衡 |
|---|---|---|
| MySQL | 业务数据最终存储,订单、库存、流水的唯一权威数据源 | 事务能力强,但性能有上限,热点数据需要缓存挡在前面 |
| Redis | 商品库存的预扣计数、热点数据缓存、分布式锁 | 性能极高,但不是持久化强一致存储,只能做前置控制,不能当真相 |
| RabbitMQ | 下单、支付成功等事件的异步解耦,下游系统通过消息消费完成后续动作 | 引入最终一致性问题,消费端必须幂等,消息丢失需要补偿机制 |
| Spring Boot | 提供依赖注入、事务管理、Web框架等基础能力 | 生态成熟,开发效率高,坑少文档全 |
这四个组件各司其职,MySQL 负责最终一致,Redis 负责性能,MQ 负责解耦,Spring Boot 负责把这一切组织起来。
1.3 贯穿全项目的四个设计原则
下面这四个原则,可以说是我写这个项目之前就先想明白的地基,后面的代码都是在这个地基上盖房子。
原则一:单一职责与清晰分层。Controller 只做参数接收和结果封装,Service 只做业务编排,Repository 只做数据访问。严禁在 Controller 里写业务逻辑,也严禁在 Repository 里做业务判断。这个规矩看起来简单,但对长期维护的影响极大。很多人一开始图方便,在 Controller 里顺手写点判断,三个月之后代码结构就烂了。
原则二:数据库是最终真相,缓存只是高速缓冲。所有缓存数据都必须要能从数据库重建,所有并发扣减最终都以数据库的原子操作兜底。Redis 的库存数字可以暂时和数据库不一致,但绝对不能反过来,让 Redis 当权威数据源。
原则三:事务最短化。本地事务只包住必要的数据写入,远程调用、消息发送、外部服务访问,全部放到事务边界之外。事务里塞的东西越多,数据库连接被占用的时间就越长,系统整体吞吐量就掉得越快。这个坑我踩过,后面细说。
原则四:状态机驱动。订单状态的每一次变更,都必须基于当前状态做条件判断。任何直接跳状态、绕过程序直接改数据库的写法,都是故障的源头。这个原则几乎能解决所有订单状态错乱的问题。
有了这四个原则,后面所有的表结构、代码结构、并发控制方案,基本上就有了明确的方向。
2. 核心链路与数据模型设计
2.1 领域模型拆解
先把核心领域对象理清楚,再来聊表和代码。这个项目的领域模型分成三块。
第一块是订单域,Order 是订单聚合根,OrderItem 是订单明细,挂在聚合根下面。订单可以有一个或多个明细,比如用户一次下单买了三件商品,订单主表一条记录,明细表三行记录。用户对订单的所有操作,都是通过 Order 这个聚合根来发起的,OrderItem 没有独立的对外访问入口。拆成两张表的原因很简单:订单主表和明细表的更新频率不一样、查询方式也不一样,明细表按 order_id 建索引之后,查询和分页都比存成一个 JSON 字段要灵活得多。
第二块是库存域,Inventory 表记录商品的可用库存和锁定库存,InventoryFlow 表记录每一次库存变动的流水。库存流水非常重要,它是事后对账和排查问题的依据。每一笔扣减、回补、锁定转扣减,都要留下流水记录。
第三块是支付域,PaymentFlow 表记录每一次支付回调的流水。这个表的作用是幂等判断的关键依据。支付平台回调可能重复送达,收到一次就处理一次的话,订单状态就会乱,所以必须靠支付流水表来兜底。
这三个领域模型之间的交互关系是这样的:创建订单时锁定库存,支付成功回调后,把锁定库存转成扣减库存;如果超时未支付,就把锁定的库存回补回去。
2.2 数据库表设计与状态机
表结构直接决定整个系统的数据可靠性。我把核心表结构和设计理由都过一遍。
订单主表:
CREATE TABLE `t_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键', `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT NOT NULL COMMENT '用户ID', `total_amount` DECIMAL(10, 2) NOT NULL COMMENT '订单总金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 0-已创建 1-已支付 2-已发货 3-已完成 4-已取消', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单主表';order_no 业务订单号和 id 自增主键分离,是刻意的设计。自增主键用于索引性能和内部关联,业务订单号给外部系统和消息使用,不暴露真实的自增规律。唯一索引的另一个关键作用,就是做幂等兜底——同样的 order_no 永远插不进去第二遍。
订单明细表:
CREATE TABLE `t_order_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '关联订单号', `sku_id` BIGINT NOT NULL COMMENT '商品SKU ID', `sku_name` VARCHAR(128) NOT NULL COMMENT '商品名称冗余字段', `price` DECIMAL(10, 2) NOT NULL COMMENT '下单单价', `count` INT NOT NULL COMMENT '购买数量', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_order_no` (`order_no`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单明细表';这里冗余了 sku_name,是因为下单之后商品名称可能修改,但订单里要保留用户下单时刻的商品信息。这种冗余在交易系统里是标准做法,不是数据冗余,而是业务需要。
库存表:
CREATE TABLE `t_inventory` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `sku_id` BIGINT NOT NULL COMMENT '商品SKU ID', `available` INT NOT NULL COMMENT '可用库存', `locked` INT NOT NULL COMMENT '锁定库存', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_sku_id` (`sku_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '库存表';把库存拆成 available 和 locked 两个字段,是多花了一点设计心思的。创建订单时扣 available 加 locked,支付成功后把 locked 减掉、写扣减流水;超时未支付则把 locked 减掉、available 加回来。这么拆的好处是:系统的三类操作(锁定、扣减、回补)都只需要基于当前字段做原子加减,永远不会出现可用库存被直接扣到负数的情况。
库存流水表就不过多展开了,字段基本就是流水号、sku_id、变动数量、变动类型、业务订单号、时间,加一个变动类型的唯一约束。这个表支撑后续的对账任务。
订单状态机是整个系统的灵魂,用表格列出来最清晰:
| 当前状态 | 触发事件 | 目标状态 | 并发控制 |
|---|---|---|---|
| 已创建 | 支付回调成功 | 已支付 | status CAS 更新 |
| 已创建 | 超时未支付 | 已取消 | 定时任务 + status CAS |
| 已支付 | 后台发货 | 已发货 | 操作员确认 + status CAS |
| 已发货 | 用户确认收货 | 已完成 | status CAS |
| 已支付 | 用户申请退款 | 退款处理中 | 退款工单 + status CAS |
状态机的核心是:状态变更必须带上 where status = 期望值 的条件,一次 update 只能改变一个状态。任何跳级、逆流的改动,比如从已创建直接改成已完成,都必须被拒绝。这个设计解决了并发请求把订单状态改错的问题。
2.3 库存扣减三种模式的对比与选择
库存扣减模式的选择,直接影响用户体验和系统复杂度。市面上主要有三种做法,我把它们放在一起对比:
| 模式 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 下单即扣减 | 创建订单时直接扣减库存 | 实现最简单,流程直观 | 用户不支付会占用库存,需要定时回补 |
| 支付后扣减 | 支付成功才扣减库存 | 不会锁库存,用户体验好 | 支付成功瞬间可能库存不足导致超卖 |
| 下单锁库存,支付后确认 | 下单时锁定库存,支付成功才真正扣减,超时回补 | 库存控制与用户体验兼得 | 实现复杂度更高,需要锁定、扣减、回补三套流程 |
第三种模式是这个项目采用的方案。下单时通过 Redis 预扣和数据库锁定两步操作把库存锁住,防止其它用户把货买走;支付成功后执行锁定转扣减;超时未支付则由定时任务自动回补。虽然实现起来比第一种复杂,但不会出现"用户付了钱却告诉你没货"这种最伤用户体验的情况。多商品订单场景下,如果某一件商品锁定失败,还需要把之前已经锁定的其它商品全部回补,这里要处理部分成功回滚的逻辑。
3. 完整代码实现与关键细节
3.1 工程结构与分层规范
项目代码按下面的包结构组织。这套结构不是拍脑袋定的,而是结合了分层架构和模块化思想之后形成的最朴素的形态:
com.example.ordercenter ├── controller // 接口层:接收请求,参数校验,结果封装 ├── service // 业务层:业务编排、事务控制 │ └── impl ├── repository // 数据层:MyBatis Mapper 或 JPA Repository ├── model │ ├── entity // 数据库实体 │ ├── dto // 接口出入参对象 │ └── enums // 状态枚举、流水类型枚举 ├── mq // 消息生产与消费 ├── config // Redis、MQ、线程池等配置 ├── common // 统一返回结构、异常、错误码依赖方向是单向的:controller 依赖 service,service 依赖 repository,model 是各层共享的。严禁反向依赖,比如在 Repository 里写业务逻辑,或者在 Controller 里直接操作 Repository。还有一个容易被忽略的规范:Entity 和 DTO 必须分离。数据库实体字段基本和表结构一一对应,接口入参和出参则根据前端需要来定义,如果把 Entity 直接当请求对象暴露出去,后面改表结构会直接影响接口兼容性,非常被动。
3.2 订单创建核心链路代码解析
订单创建是整个系统的核心链路,从上到下串起来看。先看 Service 层核心方法:
@Service public class OrderServiceImpl implements OrderService { private final OrderRepository orderRepository; private final OrderItemRepository orderItemRepository; private final InventoryRepository inventoryRepository; private final InventoryFlowRepository inventoryFlowRepository; private final RedisTemplate<String, String> redisTemplate; private final OrderMessageProducer orderMessageProducer; private static final String STOCK_KEY_PREFIX = "stock:sku:"; @Override @Transactional(rollbackFor = Exception.class) public CreateOrderResponse createOrder(CreateOrderRequest request) { // 1. 参数校验,空参数、非法数量直接拒绝 if (request == null || CollectionUtils.isEmpty(request.getItems())) { throw new BizException(ErrorCode.PARAM_ERROR); } // 2. 生成业务订单号,本次使用 Redis 自增 + 日期前缀 String orderNo = IdGenerator.generateOrderNo(); // 3. Redis 预扣库存,快速拦截库存不足的请求 for (OrderItemRequest item : request.getItems()) { boolean deductOK = deductStockInRedis(item.getSkuId(), item.getCount()); if (!deductOK) { throw new BizException(ErrorCode.STOCK_NOT_ENOUGH); } } try { // 4. 写订单主表 OrderEntity order = buildOrderEntity(orderNo, request); orderRepository.insert(order); // 5. 写订单明细表 for (OrderItemRequest item : request.getItems()) { orderItemRepository.insert(buildOrderItemEntity(orderNo, item)); } // 6. 数据库原子扣减库存,并写入库存流水(最终强一致) for (OrderItemRequest item : request.getItems()) { int rows = inventoryRepository.deductStock(item.getSkuId(), item.getCount()); if (rows == 0) { throw new BizException(ErrorCode.STOCK_NOT_ENOUGH); } inventoryFlowRepository.insert(buildFlowEntity(orderNo, item, FlowType.LOCK)); } // 7. 事务提交之后发送消息,重要! TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { orderMessageProducer.sendOrderCreated(orderNo); } }); return buildResponse(orderNo); } catch (Exception e) { // 8. Redis 预扣需要补偿回补 revertStockInRedis(request.getItems()); throw e; } } }这个方法有四个关键点,我必须逐个拆开讲。
第一个是幂等。这里的 orderNo 是服务端生成的,天然唯一,但如果客户端重复点击下单,就需要客户端传入 requestId,或者依赖 Redis 的 setnx。更稳妥的做法是在订单表上保留 order_no 唯一索引,任何重复插入都会直接报错,这是最后一层兜底。
第二个是 Redis 预扣和数据库扣减的关系。很多人一开始会把这两步当成一回事,其实完全不对。Redis 预扣的目的是快速拦截明显库存不足的请求,保护数据库不被打垮。数据库扣减才是最终权威。为什么不做成强一致?因为 Redis 和 MySQL 本质上是两个不同的存储系统,想要强一致就必须引入分布式事务,成本极高。退一步设计,允许 Redis 和数据库短时间不一致,通过定时对账任务来补偿,这是性价比更高的方案。
第三个是消息发送时机。为什么一定要等事务提交之后再发?如果事务还没提交就发消息,消费端拿到消息去查订单数据,可能查到的是旧数据或者查不到,这就是经典的"事务内发消息"问题。Spring 的 TransactionSynchronizationManager 提供了 afterCommit 钩子,在事务提交后执行,完美解决这个问题。
第四个是异常回补。Redis 预扣成功之后,如果数据库扣减失败抛出异常,整个事务回滚了,但 Redis 里的预扣数字不会跟着回滚。所以 catch 块里必须对之前预扣的商品做补偿回补。这个坑比较隐蔽,如果忘了做,库存数字会偏小,导致明明有货却一直提示库存不足。由于库存场景的高并发特性,这个回补逻辑不能走简单的顺序循环,要用 Lua 脚本批量执行。
看一下 Redis 预扣的代码,用 Lua 脚本实现原子性:
private boolean deductStockInRedis(Long skuId, Integer count) { String script = "local stock = tonumber(redis.call('get', KEYS[1]) or '0') " + "if stock >= tonumber(ARGV[1]) then " + " redis.call('decrby', KEYS[1], ARGV[1]) " + " return 1 " + "end " + "return 0"; DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class); Long result = redisTemplate.execute( redisScript, Collections.singletonList(STOCK_KEY_PREFIX + skuId), String.valueOf(count) ); return Long.valueOf(1).equals(result); }如果不使用 Lua 脚本,而用 get 检查 + decrby 两步操作,在两个步骤之间会有并发窗口,高并发下就会出现超卖。这是并发扣减的原则性问题:获取和计算必须在同一原子操作里完成。
数据库原子扣减的 Mapper 写法:
@Update("UPDATE t_inventory " + "SET available = available - #{count}, " + "locked = locked + #{count}, " + "update_time = NOW() " + "WHERE sku_id = #{skuId} AND available >= #{count}") int deductStock(@Param("skuId") Long skuId, @Param("count") Integer count);这条 SQL 利用available >= #{count}这个条件,实现了数据库层面的 CAS 操作。虽然可用库存为 0 时没有直接报错,但影响行数为 0,代码里判断 rows == 0 后抛出异常即可。这种写法比先查库存再加锁判断的方式要高效得多,也是我极力推荐使用的:能用一条 SQL 解决的并发问题,绝对不要用多次查询加锁去解决。
3.3 并发扣减与幂等控制的落地
上面的订单创建链路里,通过 Redis 预扣和数据库原子扣减解决了并发超卖问题。接下来看支付回调的处理,这是幂等控制的主战场。
支付平台的回调接口可能会重复调用,同一个支付结果可能以不同的请求送到系统里,所以回调处理必须做到完全幂等。核心代码是这样的:
@Service public class PayCallbackService { private final PaymentFlowRepository paymentFlowRepository; private final OrderRepository orderRepository; private final InventoryRepository inventoryRepository; @Transactional(rollbackFor = Exception.class) public void handlePayCallback(PayCallbackRequest request) { // 1. 利用唯一索引插入支付流水,如果插入失败说明已经处理过 try { paymentFlowRepository.insert(request.getOrderNo(), request.getTransactionId()); } catch (DuplicateKeyException e) { // 流水已存在,直接返回,不再重复处理 return; } // 2. 订单状态 CAS:只有已创建状态的订单才能变更为已支付 int rows = orderRepository.updateStatusIfEquals( request.getOrderNo(), OrderStatus.CREATED.getCode(), OrderStatus.PAID.getCode()); if (rows == 0) { throw new BizException(ErrorCode.ORDER_STATUS_NOT_ALLOWED); } // 3. 库存锁定转扣减:把 available 预扣的库存转为实际扣减 // 这里实际上是把 locked 字段减掉,同时登记扣减流水 List<OrderItemEntity> items = orderItemRepository.findByOrderNo(request.getOrderNo()); for (OrderItemEntity item : items) { inventoryRepository.confirmDeduct(item.getSkuId(), item.getCount()); inventoryFlowRepository.insert(buildFlowEntity( request.getOrderNo(), item, FlowType.DEDUCT)); } } }第一步的幂等设计是整个回调处理的关键。插入支付流水表时,如果 order_no + transaction_id 的唯一索引已经存在,数据库会直接抛 DuplicateKeyException,代码捕获后直接返回成功。这样即使支付平台同一个回调连续打过来十次,也只有第一次能真正执行业务逻辑,其它九次全部被幂等挡住。这个方案比先查后插更可靠,因为查询和插入之间依然存在并发窗口,唯一索引才是真正的硬兜底。
订单状态 CAS 的写法需要注意,单独拎出来看:
@Update("UPDATE t_order " + "SET status = #{targetStatus}, update_time = NOW() " + "WHERE order_no = #{orderNo} AND status = #{expectStatus}") int updateStatusIfEquals(@Param("orderNo") String orderNo, @Param("expectStatus") Integer expectStatus, @Param("targetStatus") Integer targetStatus);表面上这是一条普通的 UPDATE,实际上是数据库层的乐观锁。如果没有这个条件判断,两个请求同时把订单从已创建改成已支付,第二个请求虽然改了第一遍之后的状态,但它本身并不知道订单已经被改过了。加上AND status = #{expectStatus}之后,行锁在数据库内部就能保证只有一个请求能成功。所有状态变更操作都走这个方法,任何状态乱跳的问题都能在这个环节被挡住。
消息消费端的幂等也是同样的思路。订单创建、支付成功之后,MQ 消息可能被重复消费,所以每次消费都要先查消息记录表,记录表不存在就插入,插入失败了直接返回。支付流水表保证了回调幂等,消息消费表保证了消费幂等,两张表缺一不可。
4. 性能与一致性细节打磨
4.1 事务边界控制与消息投递时机
事务边界这个问题,是很多性能问题的根源。我在这个项目里反复强调一个原则:事务里只放数据库写操作,别的什么都不要放。Redis 调用、HTTP 调用、消息发送,这些都不能出现在 @Transactional 方法内部。
有人可能会不理解,问"不就多调用一下 Redis 吗,能有多大事?"实际上问题很大。所有写在 @Transactional 方法里的操作,结束之前都要一直占用一个数据库连接。一次事务里如果包含了 Redis 操作,哪怕只耗时 10 毫秒,数据库连接就被多占用了 10 毫秒。当并发量上来的时候,数据库连接池一旦耗尽,整个系统的接口都会阻塞,DB 连接告警瞬间爆表。我见过太多这样的线上事故,根因都是事务方法里塞了无关的外部调用,没有任何其他技术原因。
所以这个项目的代码规范很明确:事务方法只做"更新订单表 + 更新库存表 + 插入流水表"这三件事。Redis 预扣放在事务之前,消息投递放在事务提交之后。这个顺序是一个硬性约束,也是整个系统性能的底线。
4.2 缓存优化:穿透、击穿、雪崩的应对
订单、商品、库存这类热点数据的查询,不能每次都扛到数据库,缓存是必须要做的。但这三个缓存问题如果不处理,反而会让系统更不稳定。
缓存穿透是什么?查询一个不存在的商品 ID,缓存和数据库里都没有,请求每次都打到数据库。防穿透的办法比较简单,查不到数据的 key 也缓存一个空值,过期时间设短一点,比如 30 秒,这样一层层拦截,数据库压力就能降下来。
缓存击穿更危险,指的是某个热点 key 刚好到过期时间,一瞬间大量请求绕过缓存打到数据库。比如秒杀场景下,某个爆款商品的库存缓存 key 失效的瞬间,几千个下单请求同时涌进来。应对方案是加互斥锁,同一个 key 只有拿到锁的请求去数据库加载数据并重建缓存,其它请求先短暂等待。我用的 Redis 分布式锁配合"双 key"策略,一个业务缓存 key 配一个锁 key,请求先尝试获取锁,获取成功才查数据库回填缓存。
代码示例:
public Object getProductInfo(String skuId) { String cacheKey = "product:info:" + skuId; Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return cached; } // 互斥锁重建缓存 String lockKey = "lock:product:info:" + skuId; boolean locked = tryLock(lockKey, 3000); if (!locked) { // 拿不到锁说明别的请求正在重建,短暂等待后重试 Thread.sleep(50); return getProductInfo(skuId); } try { // 双重检查 cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return cached; } Object dbData = productRepository.findBySkuId(skuId); if (dbData == null) { // 空值缓存,防穿透 redisTemplate.opsForValue().set(cacheKey, "null", 30, TimeUnit.SECONDS); } else { redisTemplate.opsForValue().set(cacheKey, dbData, 600, TimeUnit.SECONDS); } return dbData; } finally { unlock(lockKey); } }缓存雪崩是大量 key 同时过期,数据库瞬间接到海量请求。应对办法是在设置过期时间时加随机抖动,比如 5 分钟加 0 到 60 秒随机数,让 key 的过期时间分散开,不给数据库制造流量尖峰。
在这个项目里,商品基础信息缓存用互斥锁防击穿,库存数量缓存通过 Redis 的 decrby 操作做实时更新,订单详情缓存则在用户查询时动态加载。这套组合下来,数据库的压力能降一个量级。
4.3 消息可靠性与重复消费兜底
引入 RabbitMQ 之后,"消息丢了怎么办"就成了绕不开的问题。消息可靠性从三个层面考虑:生产者、服务端、消费者。
生产者层面,需要开启 RabbitMQ 的发布确认机制,发送方法在确认回调返回之前不能视为成功。如果确认失败,要记录日志并放入重试队列。我在项目中给消息发送方法增加了确认回调,失败后由定时任务重新投递。
消费者层面,核心是幂等。前面说过,每次消费前查消息消费记录表,不存在就插入,存在就直接返回成功。这样做的好处是不管消息怎么重复投递,业务逻辑只会被执行一次。消费失败怎么办?手动确认模式下,消费端抛异常后消息会重新入队,配合幂等表,重试多少次都不会产生重复业务操作。
这里有一个细节值得提醒:消息消费记录表的插入操作要放在整个消费事务的最前面,而不是在业务执行完之后。如果业务处理成功、但最后插入记录失败,事务会回滚,业务等于没执行,消息重新消费时又会跑一遍。而如果先插入记录,再执行业务,业务失败了事务回滚,记录也回滚,下次消费照常处理。这个顺序问题看着不起眼,但真出问题的时候定位成本极高。
5. 实操踩坑与问题排查实录
5.1 一次库存不一致故障复盘
项目上线后遇到的一个比较棘手的问题:线上出现了"Redis 里显示库存充足,但数据库已经没货"的情况。用户看到商品页面显示有库存,下单却提示库存不足,客服工单直接打到技术这里。
排查过程分三步走。第一步检查 Redis 和数据库的预扣、扣减、回补三条路径有没有逻辑缺陷。发现下单创建失败时,Redis 预扣补偿逻辑在 try-catch 里执行了,但补偿到一半程序进程重启,补偿动作丢失了,Redis 里就残留了一部分预扣数量没有回补。第二步看定时对账任务,发现任务在扫描差异时只记录日志,并没有自动修复 Redis 与数据库的差额。第三步定位根因后,把补偿逻辑改成基于数据库流水表扫描:对账任务扫描一段时间内状态异常的订单,计算应回补的库存数量,执行 Redis 回补。同时对 Redis 的库存 key 增加了过期重建机制,每次数据库库存变更后,主动删除对应 Redis key,让下次查询从数据库重建。
这个问题的本质是:Redis 预扣这个设计本身允许不一致,但一定要有补偿机制兜底。没有补偿机制的预扣方案,早晚会出问题。对账任务不是可有可无的功能,而是这个架构里必不可少的安全网。
5.2 大促场景下的慢接口排查
另一次印象深刻的故障是大促时下单接口 RT 突然飙升,紧接着数据库连接池告警。平台没有宕机,但用户明显感觉到下单卡顿。
排查链路从监控面板开始。先看慢 SQL 列表,发现一条订单明细查询耗时异常高,原因是订单明细表按 user_id 查询时没有索引,全表扫描。这个表数据量上来之后,几百毫秒的查询随手可见。加了一个联合索引之后,慢 SQL 直接消失。再看连接池监控,发现事务方法内除了数据库操作之外,竟然包含了两次 Redis 调用和一次 HTTP 回调,事务平均耗时长,连接被占用的时间过长,并发一上来连接池就不够用了。修复方式就是把外部调用移出事务,回调用可靠异步机制,Redis 预扣单独处理。
这两个问题,一个是索引缺失,一个是事务边界没控住。每一类问题都不复杂,但叠加在一起,系统的瓶颈就被放大到了极限。大促之前做一次这样的瓶颈排查,比什么都重要。
5.3 高频问题速查清单
最后把实践中高频遇到的问题整理成一张速查表,排障的时候可以直接对着查。
| 问题现象 | 可能原因 | 快速定位方法 | 解决方案 |
|---|---|---|---|
| 下单提示库存不足但商品页显示有货 | Redis 预扣与数据库扣减不一致,Redis 预扣未补偿 | 对账任务扫描 Redis 与 DB 库存差异 | 加强补偿逻辑,定时对账自动修复 |
| 订单状态被并发改乱 | 状态更新没有用 CAS 条件 | 查看订单状态变更日志 | 所有状态更新走 updateStatusIfEquals |
| 同一个支付回调重复发货 | 支付流水没有幂等控制 | 检查支付流水表是否允许重复插入 | 唯一索引 + 先插入后处理 |
| MQ 消息重复消费导致重复通知 | 消费端没有幂等控制 | 查看消费记录表数据 | 消息消费记录表 + 先记录后处理 |
| 大促期间接口 RT 飙升 | 事务内包含外部调用,连接池耗尽 | 看监控面板连接池活跃数 | 事务最短化,外部调用移出事务 |
| 慢 SQL 导致数据库 CPU 上涨 | 查询条件未走索引 | 看慢 SQL 日志和执行计划 | 补联合索引,优化查询条件 |
| 热点 key 失效瞬间数据库被打垮 | 缓存击穿 | 观察慢查询集中在同一 key | 互斥锁重建缓存 + 空值缓存 |
| 大量 key 同时过期 | 缓存雪崩 | 对比过期时间分布 | 过期时间加随机抖动 |
这张表基本覆盖了这个项目运行过程中遇到的主要问题。每次排查完,我都会把新的问题补充进去,让它变成团队的排障手册。系统上线只是开始,真正的修行在问题排查和不断优化中。
项目全部做完,我心里最深的体会是:架构设计这件事,本质上是提前为未来可能发生的问题做预案。数据怎么流、状态怎么转、并发怎么控、故障怎么恢复,这些问题在一开始想得越清楚,后面的代码就越省心。很多线上问题看似是代码写的不好,其实在表结构设计或架构选型的时候就已经埋下了种子。
如果你也在准备做类似的系统,我的建议是不要一开始就照搬微服务那套完整的东西。先踏踏实实把一个单体系统做好,把事务边界控制住,把幂等机制补齐,把缓存问题想清楚,这些基础打牢了,未来系统真的需要拆分了,你会发现每个模块都能被很平滑地抽出去。而如果你一上来就分布式,大概率会被分布式事务和网络问题缠住,反而忽略了业务本身。架构不是越复杂越好,而是越适合当前的业务复杂度越好。