Knowledge note
正在加载知识笔记
正在加载知识笔记
Knowledge note
面向刚接触本项目的 Java 后端开发者与面试准备者。
本文只讲当前仓库中最值得掌握的业务主链路,重点回答面试中的典型问题: “试算怎么做的?”、“锁单怎么做的?”、“支付成功后怎么结算?”、“退款怎么做的?”、“库存和消息如何保证最终一致?”
说明口径:文中“当前实现”指仓库现有代码;“生产优化建议”指可以继续演进的方案,不代表当前项目已经具备这些能力。
这是一个拼团营销系统。用户可以在指定渠道、指定商品和指定活动下发起拼团或加入已有拼团。系统需要完成以下核心工作:
flowchart LR
A[HTTP 请求] --> B[trigger/http 控制器]
B --> C[domain 应用/领域服务]
C --> D[责任链、策略树、聚合对象]
D --> E[domain adapter repository/port]
E --> F[infrastructure Repository]
F --> G[MySQL]
F --> H[Redis]
F --> I[本地通知任务表]
I --> J[MQ 或 HTTP 外部通知]
J --> K[消费者/对端系统]
本项目里容易混淆的是“营销订单”“用户参与明细”“拼团主单”:
| 概念 | 主要数据 | 作用 |
|---|---|---|
拼团主单 group_buy_order | team_id、target_count、complete_count、lock_count、状态、有效期 | 表示一个拼团团队的整体进度 |
用户明细 group_buy_order_list | user_id、order_id、out_trade_no、价格、状态、有效期 | 表示某个用户在某个拼团中的一次参与记录 |
通知任务 notify_task | 通知类型、MQ/HTTP 信息、重试次数、状态、参数 JSON | 保存需要异步通知外部系统的任务 |
可以把它理解为:
一个拼团团队
├── 拼团主单 group_buy_order:团队维度
└── 多条用户明细 group_buy_order_list:参与者维度
TradeOrderStatusEnumVO本项目核心使用:
CREATE:已锁单,待支付。COMPLETE:支付成功。CLOSE:订单关闭或已退款,退款接口再次调用时可返回幂等结果。GroupBuyOrderEnumVO本项目核心使用:
PROGRESS:拼团进行中。COMPLETE:拼团完成。FAIL:拼团失败,例如未成团订单退款后团队失败。COMPLETE_FAIL:曾经成团,但发生退款后团队变为部分失败或完成失败语义。notify_task 中使用数字状态:
0:待执行。1:执行成功。2:执行失败但允许重试。3:超过重试次数,最终失败。“你们项目的试算怎么做的?”
试算入口是 MarketIndexController.queryGroupBuyMarketConfig,服务层通过 DefaultActivityStrategyFactory 获取以 RootNode 为根的策略树,依次完成降级和切量判断、活动与商品数据加载、折扣策略计算、人群标签判断,最后由 EndNode 组装 TrialBalanceEntity 返回商品原价、优惠金额、支付金额、成团人数以及活动可见和可用状态。
文件:
group-buy-market-trigger/src/main/java/cn/bugstack/trigger/http/MarketIndexController.javagroup-buy-market-domain/src/main/java/cn/bugstack/domain/activity/service/IndexGroupBuyMarketServiceImpl.java接口:
POST /api/v1/gbm/index/query_group_buy_market_config
请求中主要需要:
userId:用户身份,用于切量、人群标签和用户参与信息。source:来源,例如某个业务来源。channel:渠道,例如某个销售渠道。goodsId:商品编号。flowchart TD
A[MarketIndexController] --> B[IndexGroupBuyMarketServiceImpl.indexMarketTrial]
B --> C[RootNode: 参数校验]
C --> D[SwitchNode: 降级开关与切量]
D --> E[MarketNode: 加载活动配置和商品]
E --> F[折扣策略: N/ZJ/ZK/MJ]
F --> G[TagNode: 人群标签判断]
G --> H[EndNode: 组装 TrialBalanceEntity]
E --> X[ErrorNode: 缺少活动或商品配置]
RootNode 做最基础的参数校验:
userId 不能为空。goodsId 不能为空。source 不能为空。channel 不能为空。校验失败直接抛出 ResponseCode.ILLEGAL_PARAMETER,不会继续执行后面的营销逻辑。
这里体现的是“策略树的根节点负责入口条件”,控制器负责 HTTP 层参数接收,领域策略树负责业务流程内部的节点路由。
SwitchNode 通过 IActivityRepository 调用动态配置能力:
repository.downgradeSwitch()
repository.cutRange(userId)
当前行为:
downgradeSwitch() 为 true,抛出降级异常 E0003。E0004。面试中可以这样理解:
MarketNode 需要加载两类相互独立的数据:
GroupBuyActivityDiscountVO。SkuVO。当前实现使用线程池和 FutureTask 并行查询:
FutureTask<GroupBuyActivityDiscountVO> activityTask
FutureTask<SkuVO> skuTask
threadPoolExecutor.execute(activityTask)
threadPoolExecutor.execute(skuTask)
随后在 timeout 时间内等待结果,并写入 DynamicContext:
dynamicContext.setGroupBuyActivityDiscountVO(...)
dynamicContext.setSkuVO(...)
这样做的价值是:活动查询和商品查询可以同时进行,整体等待时间接近两者中较慢的一次,而不是简单相加。
需要注意:当前项目不是完全异步返回,主流程最终仍然会等待两个 FutureTask 的结果;它是“并行查询”,不是“不等待结果”。
MarketNode2CompletableFuture 是项目中提供的另一种 CompletableFuture 并行实现示例,但当前类上的 @Service 被注释,默认主流程使用的是 MarketNode。
活动配置中的 groupBuyDiscount.marketPlan 作为策略 Key,Spring 注入的 Map 负责定位具体实现:
IDiscountCalculateService discountCalculateService =
discountCalculateServiceMap.get(groupBuyDiscount.getMarketPlan());
当前主要策略实现:
| Bean 名称 | 类 | 含义 |
|---|---|---|
N | NCalculateService | 直接指定优惠后的固定价格 |
ZJ | ZJCalculateService | 直减金额 |
ZK | ZKCalculateService | 折扣比例 |
MJ | MJCalculateService | 满减,例如满 100 减 10 |
计算结果:
payPrice = 折扣策略计算后的支付价
deductionPrice = originalPrice - payPrice
其中 ZJ、ZK、MJ 都会做最低支付金额保护,避免结果小于等于 0,当前代码最低返回 0.01。
活动配置可能包含:
tagId:人群标签。visible:是否对普通用户展示。enable:是否允许用户参与。处理逻辑:
tagId,默认 visible=true、enable=true。tagId,调用 repository.isTagCrowdRange(tagId, userId) 判断用户是否在人群范围内。isVisible = 活动自身 visible || 用户命中标签
isEnable = 活动自身 enable || 用户命中标签
这意味着被配置为可见或可用的活动可以直接通过;普通用户未命中标签时,可能看不到或不能参与;命中目标人群时,可以获得相应权限。
EndNode 从 DynamicContext 取出:
然后组装 TrialBalanceEntity,包括:
goodsId、goodsName。originalPrice、deductionPrice、payPrice。targetCount。isVisible、isEnable。groupBuyActivityDiscountVO。控制器拿到试算结果后,还会额外查询:
所以首页接口不是只返回价格,它还返回“当前用户的拼团信息”和“活动统计信息”。
MarketNode.get() 会根据上下文选择后继节点:
任一条件成立,路由到 ErrorNode。
ErrorNode 会统一判断是否缺少营销配置,缺少时抛出 E0002。
我们的试算不是把价格计算写在 Controller 里,而是通过策略树编排。请求先进入 RootNode 做参数校验,然后 SwitchNode 读取 DCC 相关的降级开关和切量配置。通过后,MarketNode 并行查询活动优惠配置和商品信息,再根据活动里的 marketPlan 从 Spring 策略 Map 选择 N、直减、折扣或满减算法,算出支付价和优惠价。之后 TagNode 根据人群标签判断用户是否可见、可参与,最后 EndNode 将商品、价格、活动时间、成团人数和可见可用状态组装成 TrialBalanceEntity。这样新增优惠算法主要增加策略实现,不需要改动主流程节点。
追问:为什么不把所有逻辑写在一个 Service 里?
因为试算包含降级、切量、数据加载、折扣计算、人群判断等多个容易变化的步骤。策略树把每个步骤拆成节点,节点之间通过 DynamicContext 传递数据,便于增加节点、调整路由和单独测试。
追问:当前实现是否保证了价格绝对不会被篡改?
不能这样夸大。试算结果会在锁单时重新执行和校验,但如果要做到更强的防篡改,还需要服务端保存价格快照、校验签名或以服务端最终价格为准。当前代码的核心保护是锁单阶段再次进行服务端试算,而不是信任前端传价。
“锁单怎么做的?为什么要先锁单再支付?”
锁单接口先以 outTradeNo 做幂等查询,再检查目标拼团是否已满,随后重新执行营销试算并校验人群权限,然后通过锁单责任链完成活动可用性、用户参与次数和已有团队库存校验,最后使用 Redis 原子占用团队名额并在事务中写入拼团主单和用户明细,生成待支付订单。
文件:
group-buy-market-trigger/src/main/java/cn/bugstack/trigger/http/MarketTradeController.javagroup-buy-market-domain/src/main/java/cn/bugstack/domain/trade/service/lock/TradeLockOrderService.javagroup-buy-market-infrastructure/src/main/java/cn/bugstack/infrastructure/adapter/repository/TradeRepository.java接口:
POST /api/v1/gbm/trade/lock_market_pay_order
核心请求字段:
userId。source、channel、goodsId、activityId。outTradeNo:外部业务订单号,用于幂等。teamId:为空表示新开团,不为空表示加入已有团队。notifyConfigVO:拼团完成后的 HTTP 或 MQ 通知配置。sequenceDiagram
participant C as MarketTradeController
participant S as TradeLockOrderService
participant R as TradeRepository
participant Redis as Redis
participant DB as MySQL
C->>S: 按 userId + outTradeNo 查询未支付订单
S->>R: queryMarketPayOrderEntityByOutTradeNo
R-->>S: 已存在 CREATE 订单或空
alt 已存在待支付订单
S-->>C: 直接返回原订单,幂等成功
else 新请求
C->>S: queryGroupBuyProgress(teamId)
S->>R: 查询团队进度
R-->>S: targetCount / lockCount
C->>C: 目标已满则拒绝
C->>C: 重新执行试算并检查 isVisible/isEnable
C->>S: lockMarketPayOrder
S->>S: 活动责任链
S->>Redis: 原子占用团队库存
Redis-->>S: 成功/失败
S->>DB: 事务写入主单和用户明细
DB-->>S: 返回订单视图
S-->>C: CREATE 待支付订单
end
Controller 先检查必要参数,然后调用:
tradeOrderService.queryNoPayMarketPayOrderByOutTradeNo(userId, outTradeNo)
如果查到同一用户、同一 outTradeNo 且订单状态为 CREATE,直接返回已有订单的:
orderId。这是一种“查询式幂等”:同一个业务请求重复到达时,不重复创建订单,直接返回第一次创建的结果。
注意当前 Controller 的幂等查询发生在创建之前,最终数据库还依赖 bizId 唯一约束捕获并转换 DuplicateKeyException。生产环境还应确保数据库唯一索引、接口幂等键语义和并发场景共同闭环。
如果请求携带 teamId,Controller 先查询团队进度:
if (targetCount == lockCount) {
return E0006;
}
这里的 lockCount 表示已经锁定的参与名额,不是已经支付成功的人数。用锁定人数提前拦截,可以减少明知无法加入时继续执行后续试算和落库。
这一步是快速失败,真正的并发保护还在后面的数据库条件更新和 Redis 库存占用。
锁单不会直接使用前端传来的价格,而是使用服务端重新调用:
indexGroupBuyMarketService.indexMarketTrial(...)
然后检查:
trialBalanceEntity.getIsVisible()
trialBalanceEntity.getIsEnable()
如果用户不在允许的人群内,或活动当前不可参与,返回 E0007。
接着从服务端试算结果中构造:
PayActivityEntity:活动、团队、有效期、目标人数。PayDiscountEntity:商品、渠道、原价、优惠价、支付价、外部订单号、通知配置。这一步面试时要强调:前端展示价格只是展示结果,锁单阶段还要由服务端重新计算最终价格。
责任链由 TradeLockRuleFilterFactory 组装,顺序是:
ActivityUsabilityRuleFilter
-> UserTakeLimitRuleFilter
-> TeamStockOccupyRuleFilter
领域服务通过:
tradeRuleFilter.apply(command, new DynamicContext())
传递命令和动态上下文。
ActivityUsabilityRuleFilter 查询活动实体并校验:
EFFECTIVE。startTime 和 endTime 范围内。不满足时分别抛出活动无效或不在可参与时间范围的业务异常。
同时把活动实体写入 DynamicContext,供后续节点使用。
UserTakeLimitRuleFilter 查询用户在该活动下的参与次数:
repository.queryOrderCountByActivityId(activityId, userId)
如果活动配置了 takeLimitCount,并且当前次数大于等于上限,则拒绝参与;否则将当前次数写入上下文。
这个次数还有第二个用途:生成用户参与记录的 bizId:
activityId_userId_(userTakeOrderCount + 1)
数据库唯一索引可以进一步防止同一个用户在同一活动下的同一参与序号重复写入。
TeamStockOccupyRuleFilter 的处理分两类:
teamId 为空:表示新开团,不需要占用已有团队库存。teamId 不为空:表示加入已有团队,需要占用团队名额。已有团队会生成两个 Redis Key:
group_buy_market_team_stock_key_{activityId}_{teamId}
group_buy_market_team_stock_key_{activityId}_{teamId}_recovery
然后调用:
repository.occupyTeamStock(teamStockKey, recoveryTeamStockKey, target, validTime)
成功后把恢复库存 Key 写入责任链返回值;失败则抛出 E0008。
当前 Redis 逻辑在 TradeRepository 中实现,核心思想是:
这里要区分两个概念:
Redis 库存:高并发下的快速名额控制
MySQL lock_count:持久化的业务进度和最终查询依据
Redis 不是最终账本,数据库仍然记录拼团主单和用户明细;Redis 主要承担热点并发拦截和库存占用。
TradeRepository.lockMarketPayOrder 使用 @Transactional(timeout = 500)。
当 teamId 为空时:
group_buy_order 主单。complete_count=0。lock_count=1,因为当前用户已经占用一个名额。当 teamId 不为空时:
update group_buy_order
set lock_count = lock_count + 1
where team_id = #{teamId}
and lock_count < target_count
如果更新行数不是 1,说明团队已满、团队不存在或状态不允许更新,抛出 E0005。
这个条件更新是数据库层面的并发保护。即使多个请求同时执行,也只有满足容量条件的更新才能成功。
无论新开团还是加入已有团队,最终都会插入一条 group_buy_order_list:
orderId。userId、teamId、activityId。outTradeNo。CREATE。bizId。插入发生重复键时,代码捕获 DuplicateKeyException 并转换为业务异常。
TradeLockOrderService.lockMarketPayOrder 在责任链成功后构造 GroupBuyOrderAggregate,调用仓储层落库。如果数据库写入失败:
repository.recoveryTeamStock(recoveryTeamStockKey, validTime)
把之前已经占用的 Redis 团队库存恢复回来,然后重新抛出异常。
这解决的是:
Redis 占用成功 -> MySQL 落单失败
如果没有回补,Redis 会少一个名额,形成库存泄漏。
锁单接口首先用 userId 和 outTradeNo 查询是否已经存在待支付订单,存在就直接返回,保证重复请求不重复建单。对于加入已有团队的场景,会先快速查询团队的 targetCount 和 lockCount,已满就直接拒绝。之后服务端重新执行一次试算,校验活动对当前用户是否可见、可参与,并用服务端计算的价格构造支付订单。真正锁单时走责任链,依次校验活动状态和有效期、用户参与次数、已有团队库存。加入老团时通过 Redis 原子占用团队名额,再在事务中更新 group_buy_order 的 lock_count 并插入 group_buy_order_list;新开团则生成 teamId、创建主单并写入第一条用户明细。如果数据库落库失败,就回补 Redis 库存。
追问:Redis 已经扣库存了,为什么数据库还要更新 lock_count?
Redis 负责高并发下快速控制和削峰,数据库负责持久化业务状态、查询拼团进度和后续结算。两者职责不同,不能只依赖 Redis。
追问:Controller 里先判断团队是否已满,能不能防止超卖?
不能单独防住。Controller 的查询只是快速失败,真正的并发保护依赖 Redis 原子占用和数据库带条件的 update lock_count ... where lock_count < target_count。生产实现还应关注 Redis 与数据库之间的异常补偿和对账。
“支付成功后怎么结算?怎么判断这个支付回调是否合法?”
支付成功回调进入 settlement_market_pay_order,结算责任链先校验渠道黑名单、外部订单号和订单状态、支付时间是否早于拼团结束时间,之后构造团队结算聚合对象,在事务中把当前用户明细改为已支付、增加团队完成数,达到目标后把团队改为完成并创建通知任务,最后异步执行外部 HTTP/MQ 通知。
POST /api/v1/gbm/trade/settlement_market_pay_order
必需字段:
userId。source、channel。outTradeNo。outTradeTime:外部支付成功时间。Controller 将它们封装为 TradePaySuccessEntity,交给 TradeSettlementOrderService。
由 TradeSettlementRuleFilterFactory 组装:
SCRuleFilter
-> OutTradeNoRuleFilter
-> SettableRuleFilter
-> EndRuleFilter
调用:
repository.isSCBlackIntercept(source, channel)
当前实现通过 DCC 动态配置读取渠道黑名单。如果来源和渠道组合被拦截,抛出 E0105,不继续结算。
根据 userId + outTradeNo 查询营销订单:
CLOSE,认为已经退款或关闭,不再结算。DynamicContext,继续执行。先根据订单的 teamId 查询拼团团队,再校验:
outTradeTime.before(groupBuyTeamEntity.getValidEndTime())
如果支付完成时间不早于拼团结束时间,拒绝结算,避免活动已经结束后仍然把订单算入成团。
把团队信息复制到 TradeSettlementRuleFilterBackEntity,供结算服务构建 GroupBuyTeamEntity。
TradeSettlementOrderService 组装 GroupBuyTeamSettlementAggregate:
UserEntity
GroupBuyTeamEntity
TradePaySuccessEntity
然后调用:
repository.settlementMarketPayOrder(groupBuyTeamSettlementAggregate)
这里的聚合对象是一次结算所需的领域输入集合,仓储层负责把它落到多张表并创建通知任务。
当前仓储层的核心逻辑可以概括为:
complete_count。MyBatis 中团队完成数更新使用条件:
update group_buy_order
set complete_count = complete_count + 1
where team_id = #{teamId}
and complete_count < target_count
团队状态更新使用类似的状态条件,避免重复把已完成团队再次结算。
拼团完成时,系统不能直接在数据库事务里调用外部 HTTP 或 MQ,因为外部调用可能慢、失败或超时,会拖长数据库事务,还可能导致数据库已经提交但通知没有发出。
当前实现的做法是:
数据库事务内:
更新订单和团队状态
插入 notify_task 本地通知任务
事务提交后:
在线程池中尝试执行通知
失败时由定时任务重新扫描
这样,核心业务状态和“需要通知”这件事可以一起落库。只要数据库事务提交成功,通知任务就不会因为应用瞬时异常而完全丢失。
这就是本地消息表/本地事务消息思路。
TradeTaskService 查询 notify_status in (0, 2) 的任务,然后调用 ITradePort.groupBuyNotify:
1。2,等待重试。3,标记最终失败。当前一次执行最多查询 50 条任务,具体以 SQL 为准。
GroupBuyNotifyJob 每天凌晨执行一次,并使用 Redisson 分布式锁:
group_buy_market_notify_job_exec
避免多实例同时扫描和执行同一批通知任务。
拼团完成通知根据 NotifyConfigVO 选择:
对于退款恢复库存,项目固定使用 MQ 通知类型,发布到退款主题,由 RefundSuccessTopicListener 消费。
支付成功后不是直接把订单改成完成,而是先走结算责任链。第一步检查 source 和 channel 是否在 DCC 黑名单中,第二步根据 userId 和 outTradeNo 查询订单,确认订单存在且没有关闭,第三步查询团队并校验支付完成时间必须早于拼团结束时间。校验通过后构造团队结算聚合,仓储层在事务里更新用户明细支付状态、增加团队 complete_count。如果达到 target_count,就把团队状态改成完成,并把所有已完成订单号写入 notify_task 本地通知表。事务提交后异步执行 HTTP 或 MQ 通知,失败任务按次数重试,定时任务负责补偿。
追问:为什么支付时间要和拼团结束时间比较?
防止活动结束后才支付成功的订单被计入成团。判断依据应该是外部支付成功时间,而不是接口收到回调的时间,因为回调可能存在网络延迟。
追问:支付回调重复到达怎么办?
订单和团队更新使用状态条件与计数上限,重复回调时更新行数不会正常增加;订单已经关闭时结算责任链也会拒绝。生产环境还应进一步明确幂等状态机、唯一支付流水号和回调日志。
“退款怎么做的?已支付和未支付为什么要分不同策略?”
退款入口先通过退款责任链加载订单和团队数据,再做重复退款判断,最后根据“拼团状态 + 用户订单支付状态”选择退款策略:未支付未成团只关闭订单并释放锁定名额;已支付未成团要关闭订单、减少团队锁定数和完成数,并发送退款后的库存恢复消息;已支付已成团要更新订单和团队失败状态,但不再恢复团队锁定库存。
POST /api/v1/gbm/trade/refund_market_pay_order
Controller 将请求封装为 TradeRefundCommandEntity,调用:
tradeRefundOrderService.refundOrder(command)
由 TradeRefundRuleFilterFactory 组装:
DataNodeFilter
-> UniqueRefundNodeFilter
-> RefundOrderNodeFilter
根据 userId + outTradeNo 查询:
MarketPayOrderEntity。GroupBuyTeamEntity。并写入退款责任链上下文。
当前代码默认前置调用方已经提供有效订单号;生产环境建议在数据节点显式处理订单不存在,避免空指针类异常直接暴露为系统错误。
如果用户订单状态已经是 CLOSE,直接返回:
TradeRefundBehaviorEnum.REPEAT
不会再次执行扣减团队人数、更新订单或发送通知。
读取:
GroupBuyOrderEnumVO。TradeOrderStatusEnumVO。通过 RefundTypeEnumVO.getRefundStrategy(...) 选择策略:
| 团队状态 | 用户订单状态 | 策略 | 业务含义 |
|---|---|---|---|
PROGRESS | CREATE | Unpaid2RefundStrategy | 未支付、未成团 |
PROGRESS | COMPLETE | Paid2RefundStrategy | 已支付、未成团 |
COMPLETE 或 COMPLETE_FAIL | COMPLETE | PaidTeam2RefundStrategy | 已支付、已成团或成团后发生退款 |
策略:Unpaid2RefundStrategy。
业务含义:锁单成功了,但用户没有支付,超时后需要关闭这条用户明细并释放团队的锁定名额。
核心数据库动作:
lock_count - 1。complete_count,因为用户从未支付成功。该策略主要服务于 TimeoutRefundJob 扫描出的超时未支付订单。
策略:Paid2RefundStrategy。
业务含义:用户已经支付,但拼团还没有达到目标,当前订单退款后,团队仍可能继续进行。
核心数据库动作:
lock_count - 1。complete_count - 1。为什么 lock_count 和 complete_count 都减一?
lock_count 表示这个用户之前占用过一个团队名额。complete_count 表示这个用户已经支付成功并计入完成数。策略:PaidTeam2RefundStrategy。
业务含义:团队已经成团,用户已经支付成功,此时退款会影响团队结果。
代码先查询团队当前 complete_count,再决定团队状态:
complete_count == 1
-> FAIL:最后一个已完成订单退款,整团失败
complete_count > 1
-> COMPLETE_FAIL:成团后部分订单退款,团队完成但存在失败成员
核心数据库动作:
FAIL 或 COMPLETE_FAIL。这是退款流程中的关键业务分叉,也是面试最值得展开的部分。
对于需要恢复库存的退款场景,仓储层会插入 notify_task,通知参数包含:
userId。teamId。orderId。activityId。然后通过线程池尝试执行通知。MQ 消费者:
RefundSuccessTopicListener
收到 TeamRefundSuccess 后调用:
tradeRefundOrderService.restoreTeamLockStock(teamRefundSuccess)
再根据退款类型选择对应策略的 reverseStock。
TradeRepository.refund2AddRecovery 使用:
refund_lock_{orderId}
作为 Redis 幂等 Key,通过 setNx 竞争一次性恢复锁定库存。
refund_lock_{orderId},直接跳过。这套设计解决了:
MQ 至少一次投递/消费
+
消费者重复执行
-> 不会因为重复消息而重复恢复库存
TimeoutRefundJob 每分钟执行一次:
group_buy_market_timeout_refund_job_exec
TradeRefundCommandEntity。refundOrder。这体现了统一入口思想:用户主动退款和超时自动退款都走同一套退款责任链与退款策略,不重复实现业务规则。
退款入口先通过 DataNodeFilter 加载用户订单和拼团团队,再由 UniqueRefundNodeFilter 根据订单是否已经 CLOSE 做幂等判断。真正处理时,根据团队状态和用户订单状态组合选择策略。未支付未成团走 unpaid2Refund,只关闭用户明细、减少 lock_count,并恢复之前占用的库存;已支付未成团走 paid2Refund,除了关闭订单,还要让 lock_count 和 complete_count 各减一,然后通过本地通知任务和 MQ 恢复库存;已支付已成团走 paidTeam2Refund,根据退款前剩余完成数决定团队是 FAIL 还是 COMPLETE_FAIL,更新团队和订单状态,但不重新开放团队库存。MQ 消费端通过 orderId 维度的 Redis 幂等锁保证重复消息不会重复回补库存。
追问:为什么退款不能只把订单状态改成 CLOSE?
因为订单状态只是用户维度的结果,拼团主单还维护 lock_count、complete_count 和团队状态。如果只关订单,不回滚团队数据,会导致团队人数虚高、库存无法恢复或后续结算错误。
追问:退款消息发送失败会不会导致库存永远不恢复?
当前实现通过 notify_task 本地通知任务和定时任务重试降低丢失风险,MQ 消费失败时抛异常触发重试,消费者又通过 Redis 幂等锁防止重复恢复。严格生产环境还应配合死信队列、告警、人工补偿和库存对账。
sequenceDiagram
participant U as 用户
participant API as TradeController
participant Trial as 试算策略树
participant Chain as 锁单责任链
participant R as Redis
participant DB as MySQL
participant Pay as 支付系统
U->>API: lock_market_pay_order
API->>API: outTradeNo 幂等查询
API->>Trial: 服务端重新试算
API->>Chain: 活动/次数/团队库存校验
Chain->>R: 占用团队名额
R-->>Chain: 成功
Chain->>DB: 条件更新 lock_count
DB-->>Chain: 成功
Chain->>DB: 插入 group_buy_order_list
DB-->>API: CREATE 待支付订单
API-->>U: 返回支付金额和 orderId
U->>Pay: 发起支付
Pay-->>API: 支付成功回调
API->>DB: 用户明细 COMPLETE
API->>DB: complete_count + 1
API-->>U: 结算成功
当前项目的并发保护不是单点完成,而是多层组合:
userId + outTradeNo 查询已有待支付订单。lock_count < target_count、complete_count < target_count。bizId 等业务唯一键防重复插入。orderId 防止重复恢复库存。面试时不要把当前实现说成“Redis 和 MySQL 完全原子”。它们是两个独立系统:
Redis 占用成功
-> 进程宕机/数据库超时/事务回滚
-> 需要回补机制
当前代码已经覆盖部分失败场景:
recoveryTeamStock。仍可继续增强:
当前最重要的事务边界位于 TradeRepository:
外部 HTTP 和 MQ 调用不应该被当成数据库事务的一部分。当前项目采用“事务内写本地任务,事务外异步通知”的方式。
group_buy_order它是团队维度的主表,重点字段:
team_id:团队 ID。activity_id:活动 ID。target_count:目标人数。complete_count:已支付完成的人数。lock_count:已锁定名额人数。status:拼团状态。valid_start_time、valid_end_time:拼团有效期。notify_type、notify_url:拼团完成通知配置。group_buy_order_list它是用户参与维度的明细表,重点字段:
user_id。team_id。order_id。out_trade_no。status:待支付、已支付、关闭。start_time、end_time:用户订单有效期。biz_id:活动、用户、参与次数组合形成的业务唯一号。notify_task它是可靠通知的任务表,重点字段:
notify_category:结算完成、退款等业务类别。notify_type:HTTP 或 MQ。notify_mq、notify_url。notify_count:已经尝试通知的次数。notify_status:待执行、成功、重试、最终失败。parameter_json:通知载荷。uuid:通知任务业务唯一标识。正确说法:首页展示时会试算一次;锁单时还会重新试算并做服务端校验,不能信任前端传价。
正确说法:Redis 用于高并发名额占用和快速拦截,MySQL 中的团队主单记录业务状态,二者之间需要补偿和对账。
正确说法:当前至少按三种状态组合分策略:未支付未成团、已支付未成团、已支付已成团。
正确说法:MQ 通常按至少一次语义设计,当前消费者通过 refund_lock_{orderId} 做幂等,失败时抛异常允许重试。
正确说法:本地消息表只保证“需要发送的任务”随核心事务落库;真正发送仍可能失败,需要重试、告警和人工补偿。
例如以下内容属于生产优化方向,不应说成当前项目已经实现:
这是一个拼团营销项目,核心链路分为试算、锁单、支付结算和退款。试算阶段通过策略树处理降级、切量、活动和商品加载、优惠计算以及人群标签判断,返回服务端最终价格和活动可见可用状态。用户锁单时会基于 outTradeNo 做幂等查询,重新执行试算,再通过责任链校验活动有效性、用户参与次数和团队库存。已有团队通过 Redis 做热点名额占用,最终在 MySQL 事务中更新团队 lock_count 并插入用户订单明细。支付成功后通过结算责任链校验渠道黑名单、订单存在性和支付时间,再更新用户支付状态和团队 complete_count,达到目标后把团队改成完成,并写入本地通知任务。退款按订单支付状态和团队状态选择不同策略,分别处理未支付释放锁定名额、已支付未成团回滚完成数和锁定数、已支付已成团更新团队失败状态。消息通知通过本地任务表、MQ、重试和定时补偿实现最终一致。
如果要真正读懂本项目,建议按下面顺序打开代码:
MarketIndexController.java:先看首页试算入口。IndexGroupBuyMarketServiceImpl.java:看如何启动策略树。RootNode.java、SwitchNode.java、MarketNode.java、TagNode.java、EndNode.java:看试算节点。MarketTradeController.java:看锁单、结算、退款三个 HTTP 入口。TradeLockOrderService.java 和 TradeLockRuleFilterFactory.java:看锁单责任链。TradeRepository.lockMarketPayOrder:看 Redis 与数据库如何衔接。TradeSettlementOrderService.java 和结算责任链:看支付回调校验。TradeRepository.settlementMarketPayOrder:看结算状态更新和通知任务。TradeRefundOrderService.java、RefundTypeEnumVO.java:看退款策略选择。RefundSuccessTopicListener.java:看退款成功消息如何恢复库存。TimeoutRefundJob.java、GroupBuyNotifyJob.java:看定时补偿和分布式锁。group_buy_order_mapper.xml、group_buy_order_list_mapper.xml、nofify_task_mapper.xml:最后核对 SQL 的条件更新和状态条件。读完本文后,建议不看答案,尝试口述下面的问题:
lock_count 各自负责什么?最后记忆一条主线:
试算决定“能不能买、买多少钱”
锁单决定“先占哪个拼团名额”
结算决定“支付成功后团队如何推进”
退款决定“失败或逆向时哪些状态和库存要回滚”
通知与补偿决定“跨系统操作最终能不能收敛”
知识笔记会随着实践和认知变化持续更新,不代表最终结论。