阶段一:小付哥拼团交易系统总结
这是一篇关于「拼团交易系统」阶段一的总结。我会从产品经理、用户和应用(技术)三个视角讲清楚一件事——一个拼团小项目是怎么从「打开网页」走到「下单完成」的,最后再说说这个阶段我们是怎么防超售、扛并发的,以及这个阶段到底有哪些亮点。文章基于我已经部署上线到 https://yanshen.online/group-buy/ 的版本(源工程 fork 自小傅哥的 group-buy-market-yanshen),前端 + 后端 + 数据库我都跑通了,所以下面所有接口和流程你都可以直接在浏览器里验证。一、作为产品经理视角:一条完整的拼团业务流
站在产品经理的视角,我想清楚一件事:这个产品到底要解决什么问题? 答案很简单——用「拼团折扣」撬动用户自发传播,用社交关系链降低获客成本。所以整个产品形态要满足三个核心诉求:
- 明确的优惠感知:用户一进来就能看到「直降 ¥20」「3 人成团」这种字眼;
- 极低的成团门槛:凑不齐人就自己开一个团,反正不亏;
- 清晰的订单状态:什么时候锁单、什么时候付款、什么时候拼团成功——每一步都要告诉用户。
围绕这三点,我把整条业务流程拆成 4 个环节:
| 阶段 | 用户行为 | 系统响应 | 备注 |
|---|---|---|---|
| 浏览 | 打开商品页 | 返回商品 + 进行中拼团列表 + 拼团统计 | 激励「再抢一下就成团了」 |
| 开团 | 点「开团购买」 | 创建拼团小队 + 锁单 + 唤起支付 | 自己就是团长 |
| 参团 | 点别人团里的「参与拼团」 | 加入拼团小队 + 锁单 + 唤起支付 | teamId 由前端从列表里传 |
| 单独购买 | 点「单独购买」 | 不进拼团,直接锁单 + 唤起支付 | 享受不了拼团价 |
这四种走向最终都会汇到同一个支付回调:用户扫码付钱 → 后端做「结算」动作 → 拼团人数到 3 人 → 整个团标记为「成功」→ 后端异步通知调用方(这里是个 mock 接口,但生产里一般是对账系统或者发券系统)。
PM 视角的关键判断:把「单独购买」和「拼团」放在同一个商品详情、用同一个支付弹窗,是有意的设计——用户看见的是「两个按钮」,但后端看到的只是 teamId 字段为不为空。这种「前端分流、后端同构」的策略,让后面的对账、统计、优惠计算逻辑都可以复用一套代码。二、作为用户视角:实际打开一次拼团页面
我用真线上地址(https://yanshen.online/group-buy/)走一遍:
- 进站:先弹登录。用户名密码随便写(mock 登录,校验 userId 是否为空),把用户名写到 cookie 的
username里; - 看商品:登录后看到商品《手写 MyBatis:渐进式源码实践》,原价 ¥100,拼团价 ¥80;
- 看拼团:下面有进行中的拼团列表,比如「xfg01 还差 1 人成团,倒计时 00:14:42」;
- 点按钮:底部有「单独购买(¥100)」「开团购买(¥80)」两个按钮,列表里每个团右边还有一个「参与拼团(¥80)」按钮;
- 支付:点任何一个按钮都会弹二维码弹窗(这里是张占位图),里面有「取消支付」和「支付完成」两个按钮;
- 付钱:点「支付完成」,前端把
outTradeNo(前端随机生成的 12 位数字)传回去,后端做结算。
到这里就完成一次完整的拼团闭环。
用户视角的几个体感细节:
- 倒计时是真的:前端在拼团列表里塞了一个
Countdownclass,每秒减 1,到 0 就停。这样用户有紧迫感;- 不用重复登录:cookie 里塞了 userId,下次直接进;
- 支付二维码是占位:生产里这里会接微信/支付宝 SDK,这里只是演示链路。
三、深入应用视角:从前端到后端,一行行看
下面这部分是技术同学最关心的——「这一段从前到后到底调用了什么、为什么这么调」。我会把前端、后端、数据库三端都画清楚。
3.1 工程结构:DDD 分层
整个后端工程是按 DDD 分层的(这是阶段一最重要的架构决策),模块拆成 6 个:
| 模块 | 作用 | 关键类 |
|---|---|---|
group-buy-market-yanshen-types | 公共枚举/常量/设计模式抽象 | ResponseCode、Constants、DCCValue 注解、BusinessLinkedList 责任链 |
group-buy-market-yanshen-api | 对外暴露的 API 接口 + DTO | IMarketIndexService、GoodsMarketRequestDTO |
group-buy-market-yanshen-domain | 领域服务、聚合根、规则过滤器 | TradeLockOrderService、TradeSettlementOrderService、GroupBuyOrderAggregate |
group-buy-market-yanshen-infrastructure | 数据库、Redis、网关实现 | TradeRepository、RedissonService、GroupBuyNotifyService、DCCService |
group-buy-market-yanshen-trigger | HTTP 入口(Controller)+ 定时任务 | MarketIndexController、MarketTradeController、GroupBuyNotifyJob |
group-buy-market-yanshen-app | Spring Boot 启动、配置 | Application、DCCValueBeanFactory |
依赖方向严格遵守 trigger → domain ← infrastructure,domain 不依赖任何外部框架,是纯粹的 Java 业务逻辑——这是后期能写单测、换存储、改消息中间件的根本保障。
3.2 打开网页,前端调用了什么接口?
我们打开 index.html,所有逻辑都在 js/index.js 和页面里的 <script> 块里。打开那一刻,前端做了一件事:
fetch(baseUrl + '/api/v1/gbm/index/query_group_buy_market_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: 'xfg01', // 从 cookie 取
source: 's01',
channel: 'c01',
goodsId: '9890001'
})
});这个接口就是商品详情页的「数据总入口」,它一次返回前端要展示的所有东西:
{
"code": "0000",
"info": "成功",
"data": {
"goods": { "goodsId": "9890001", "originalPrice": 100, "deductionPrice": 0, "payPrice": 100 },
"teamList": [
{ "userId": "xfg01", "teamId": "43375770", "targetCount": 3, "completeCount": 1, "lockCount": 2, "validTimeCountdown": "已结束", "outTradeNo": "..." }
],
"teamStatistic": { "allTeamCount": 9, "allTeamCompleteCount": 1, "allTeamUserCount": 11 }
}
}我贴一段我刚刚用 curl 真实调用的返回(来自我部署的线上服务,2026-08-26 跑出来的):
{"code":"0000","info":"成功","data":{"goods":{"goodsId":"9890001","originalPrice":100.00,"deductionPrice":0.00,"payPrice":100.00},"teamList":[{"userId":"xfg01","teamId":"43375770","activityId":100123,"targetCount":3,"completeCount":1,"lockCount":2,"validStartTime":"2026-08-26T01:15:40.000+00:00","validEndTime":"2026-08-26T02:15:40.000+00:00","validTimeCountdown":"已结束","outTradeNo":"278793607273"}, ... ]}}注意validTimeCountdown字段:「已结束」三个字是前端用differenceDateTime2Str(new Date(), validEndTime)现算的,前端拿到时间戳就能渲染倒计时,不必再问后端。
3.3 后端收到这个请求后做了什么?
后端入口是 MarketIndexController.queryGroupBuyMarketConfig,步骤是:
校验参数 → 试算 (trial) → 查本人拼团中订单 → 查全团统计 → 组装返回关键在于第二步「试算」——它不只算价格,还决定「这个用户能不能买、能用哪个优惠」。
3.3.1 试算:indexMarketTrial 走的是策略树
调用 IIndexGroupBuyMarketServiceImpl.indexMarketTrial(),里面用了一个策略树 + 责任链的混合结构(DefaultActivityStrategyFactory)。整棵树大概长这样:
根节点 (Root)
├─ SwitchNode: 根据 source+channel+goodsId 查 sc_sku_activity 表
├─ NullReturnNode: 查不到就返回空
└─ MultiThreadStrategyRouter (并发执行下面两路):
├─ LeftNode: 查活动 + 折扣 → 算价格
└─ RightNode: 人群标签过滤 (crowd_tags)它把「查活动」「算价格」「人群过滤」三件事并发跑(用了 AbstractMultiThreadStrategyRouter),最后汇总。
3.3.2 算价格:策略模式 + 工厂方法
AbstractDiscountCalculateService 是抽象类,定义了「先看有没有人群标签限制 → 调具体算价方法」。具体算价有 4 种实现,靠 Spring 的 @Service("MJ") / @Service("ZK") / @Service("ZJ") / @Service("N") 区分:
| Bean 名 | 类型 | 算法 | 适用场景 |
|---|---|---|---|
MJ | 满减 | price - y (若原价 > x) | 满 100 减 20 |
ZK | 折扣 | price * ratio | 打 8 折 |
ZJ | 直减 | price - amount | 直接减 20 |
N | N 元购 | N | 1 元购、9.9 元购 |
具体用哪个?看数据库里 group_buy_discount 表的 market_plan 字段——我这里写的是 ZJ,market_expr = 20,所以原价 100 - 20 = 80。
为什么这么做? 一个商品可能有多个优惠活动,不同渠道/不同人群用不同算法。把「算法选择」做成数据库配置 + 工厂注册,后期加活动不用改代码,只改库。这就是策略模式的精髓。
3.3.3 组装返回:3 张表 + 1 个聚合
MarketIndexController 自己从 repository 里查了三块:
userGroupBuyOrderDetailEntities:自己开/参与的拼团中订单(拼团列表那条橙色横条的数据来源)teamStatisticVO:全团统计(allTeamCount / allTeamCompleteCount / allTeamUserCount)goods:商品 + 算价结果
然后用 GoodsMarketResponseDTO.builder() 一拼,返回。
3.4 前端拿到数据后做了什么?
前端拿到 JSON 后做 4 件事:
- 更新顶部促销文案:
promotionText.textContent = '直降 ¥${goods.deductionPrice},${teamStatistic.allTeamUserCount}人再抢,参与马上抢到'——这是「社会证明」式的紧迫感话术; - 渲染拼团列表:
teamList.forEach(team => groupList.innerHTML += ...),每个团右边画一个「参与拼团」按钮,绑data-teamid; - 底部按钮:
买单独显示goods.originalPrice,开团购买显示goods.payPrice; - 倒计时:列表里的
validTimeCountdown字段丢进new Countdown(el, el.textContent),每秒减 1。
这里有个幂等设计值得说:前端一加载就把「自己有没有进行中的拼团订单」扫一遍。如果有,就把这个订单的outTradeNo存到变量outTradeNo里,下次点「支付完成」直接用——这样用户刷新页面也不会丢订单。
3.5 拼团数据 & 锁单下单的实现
这是整个系统最关键的一段。点「开团购买」或「参与拼团」时,前端调用的是:
POST /api/v1/gbm/trade/lock_market_pay_order
{
"userId": "xfg99",
"teamId": null, // 开团传 null,参团传具体 ID
"activityId": 100123,
"goodsId": "9890001",
"source": "s01",
"channel": "c01",
"outTradeNo": "123456789012", // 前端生成的 12 位随机数
"notifyUrl": "https://yanshen.online/api/v1/test/group_buy_notify"
}后端在 MarketTradeController.lockMarketPayOrder 里的处理是个典型的责任链 + 数据库事务组合。我把链路画清楚:
┌──────────────────────────────────────────────────────────┐
│ 1. 参数校验 (string not blank) │
│ 2. 幂等检查:outTradeNo 存在则直接返回 (REPEAT) │
│ 3. 如果传了 teamId:查锁单进度,满人则 E0006 │
│ 4. 试算 (复用 query_group_buy_market_config 那套) │
│ 5. 责任链 tradeRuleFilter.apply(...) │
│ ├─ ActivityUsabilityRuleFilter: 活动生效+在时间窗内 │
│ └─ UserTakeLimitRuleFilter: 用户参与次数 < takeLimit │
│ 6. TradeRepository.lockMarketPayOrder(@Transactional) │
│ ├─ teamId 空 → 插入新团 (target=3, complete=0, lock=1)│
│ └─ teamId 非空 → UPDATE lock_count + 1 │
│ 返回 1 才算成功,否则 E0005 │
│ 7. 插入 group_buy_order_list 一行 (单个用户的订单) │
│ 8. 返回 { orderId, deductionPrice, tradeOrderStatus } │
└──────────────────────────────────────────────────────────┘3.5.1 数据库表的角色
group_buy_order = 拼团小队(团长是创建者)
- 主键自增,
team_id8 位唯一(uq_team_id) - 关键字段:
target_count=3、complete_count、lock_count、status (0-拼单中/1-完成/2-失败)、valid_end_time - 一条记录代表「一团」
group_buy_order_list = 单个用户的订单
- 主键自增,
order_id12 位唯一(uq_order_id) - 关键字段:
user_id、team_id、out_trade_no(外部幂等号)、biz_id(活动_用户_序号)、status (0-锁单/1-完成/2-退单) - 一个团可以有 N 条这样的记录(N=团人数)
notify_task = 异步通知队列
- 拼团成功后插入一条;定时任务轮询、回调 HTTP 或 MQ
- 字段
notify_count< 5 时重试,>= 5 标记失败
3.5.2 biz_id 的小细节
仔细看 SQL 里这一段:
`biz_id` varchar(64) NOT NULL COMMENT '业务唯一ID'入库时拼成 activityId + "_" + userId + "_" + (userTakeOrderCount+1)——意思是「这个用户参加这个活动的第几次」。配合 take_limit_count 字段(活动配置里写的「每人限购 1 次」),就能在前面的 UserTakeLimitRuleFilter 里挡住重复刷单。
3.6 用户从打开到下单的完整流程图
把上面所有片段拼起来,用户从打开 https://yanshen.online/group-buy/ 到下单完成走的是这条路:
[用户浏览器]
│
├─ ① 打开 /group-buy/index.html
│ └─ 读 cookie.username → 没值就跳 login.html
│
├─ ② JS 调 query_group_buy_market_config
│ └─ 后端返回 goods + teamList + teamStatistic
│ └─ 前端渲染页面 + 倒计时
│
├─ ③ 用户点「开团购买 / 参与拼团 / 单独购买」
│ └─ 前端生成 outTradeNo (12 位随机)
│ └─ 调 lock_market_pay_order
│ ├─ 后端: 责任链 (活动可用 + 用户限购) 通过
│ ├─ 后端: INSERT/UPDATE group_buy_order (团信息)
│ ├─ 后端: INSERT group_buy_order_list (个人订单)
│ └─ 返回 { orderId, status: CREATE }
│ └─ 前端弹二维码弹窗
│
├─ ④ 用户扫码 (mock) → 点「支付完成」
│ └─ 调 settlement_market_pay_order
│ ├─ 后端: 责任链 (SC黑名单 → outTradeNo存在 → 在有效期内)
│ ├─ 后端: UPDATE group_buy_order_list.status = 1
│ ├─ 后端: UPDATE group_buy_order.complete_count + 1
│ └─ 如果 complete_count == target_count:
│ ├─ UPDATE group_buy_order.status = 1 (团完成)
│ └─ INSERT notify_task (异步回调)
│
└─ ⑤ (异步) GroupBuyNotifyJob 每 15 秒扫一次 notify_task
└─ HTTP/MQ 回调调用方 (这里是 mock /api/v1/test/group_buy_notify)四、整个过程如何防止超售?
超售的本质:两个用户同时看到「还剩 1 个名额」,同时点了「参与拼团」。这种场景在拼团里特别常见——一个 3 人团,前 2 人已经锁单,第 3 个名额引来 50 个人抢。防超售有三道防线:
4.1 第一道防线:Controller 层的乐观预检
MarketTradeController.lockMarketPayOrder 在进入事务之前先做了一次预检:
if (null != teamId) {
GroupBuyProgressVo vo = tradeOrderService.queryGroupBuyProgress(teamId);
if (null != vo && Objects.equals(vo.getLockCount(), vo.getTargetCount())) {
return Response.<...>builder()
.code(ResponseCode.E0006.getCode()) // "组队已满"
.info(ResponseCode.E0006.getInfo()).build();
}
}简单说——如果进来就发现 lock_count == target_count,直接拒。
4.2 第二道防线:数据库行锁 + 事务
即便预检通过了,并发进来还是会撞车。这时候靠 group_buy_order 的 uq_team_id 唯一索引 + MySQL 的行锁。UPDATE group_buy_order SET lock_count = lock_count + 1 WHERE team_id = ? 这条 SQL:
- MySQL 会先对那一行加排他锁;
- 同一时刻第二个并发进来时必须等;
- 一个一个排队执行,
lock_count严格递增; - 关键防御点在仓库层:
int updateAddLockCount = groupBuyOrderDao.updateAddLockCount(teamId);
if (1 != updateAddLockCount) {
throw new AppException(ResponseCode.E0005); // 抛异常,事务回滚
}@Transactional(timeout = 500) 包裹整段,保证要么都成功、要么都回滚。
4.3 第三道防线:结算时的二次校验
就算锁单这一关被绕过去,结算时还有 4 个过滤器在排队:
SCRuleFilter (黑名单拦截)
↓
OutTradeNoRuleFilter (订单必须存在)
↓
SettableRuleFilter (必须在拼团有效期内)
↓
EndRuleFilter (返回上下文)特别是 SettableRuleFilter——它会拿当前时间比对 group_buy_order.valid_end_time,超时就直接拒。这就是为什么 valid_time 字段(15 分钟默认)一旦过了,整个团即使有人锁了单也结不成。
4.4 阶段一防超售的局限性
说句实话,阶段一的这套防线是有缺陷的:
- 预检(Controller)和更新(Repository)之间存在TOCTOU 时间窗——你读完
lock_count=2,在你更新前别人已经更新到 3 了; - 行锁保护住了「不会超 3」,但用户体验是「一直点一直失败」;
RedissonService类里其实有getLock(String key)/getSemaphore等分布式锁 API,但阶段一完全没用上。
阶段二会做的优化:把行级预检升级成「UPDATE ... WHERE team_id = ? AND lock_count < target_count」,影响行数 = 1 才算成功。同时用 Redisson 的 RSemaphore 做一层前置信号量,把无效请求挡在数据库外面。这是后话了。
五、高并发处理:阶段一能扛多少?
老实说,阶段一不是为高并发设计的,它是一套「能跑通」的最小实现。但它在几个关键点上做了铺垫,让阶段二能很快补上:
5.1 已经做的
| 措施 | 在哪 | 作用 |
|---|---|---|
@Transactional(timeout = 500) | TradeRepository | 单事务不超过 500ms,避免长事务 |
| HikariCP 连接池 15~25 | application-dev.yml | 复用连接,控制并发 DB 连接数 |
| 自定义线程池 20~50 | ThreadPoolConfig | 异步任务有界队列 5000 |
幂等字段 out_trade_no | group_buy_order_list | 防止用户重复点导致多笔订单 |
| 责任链按需加载 | TradeLockRuleFilterFactory | 不会因为一个过滤器拖垮整条链路 |
notify_task 异步队列 + 定时回调 | GroupBuyNotifyJob | 拼团完成的通知不和主链路耦合 |
| DCC 动态配置(Redis Pub/Sub) | DCCService + DCCValueBeanFactory | 活动期间能不停机调整白名单/降级开关 |
5.2 还没做的(阶段二、三要补的)
- Redis 分布式锁:
RedissonService.getLock(...)已经写好,但TradeLockOrderService里没有调用; - 本地缓存(Caffeine):
sku/group_buy_activity这种几乎不变的数据,每次请求都查 DB 浪费; - 消息队列削峰:拼团成功后的通知走的是「数据库轮询」,应该换成 RabbitMQ 异步消费;
- 限流:没有 Sentinel/Hystrix,瞬时流量进来会直接把 DB 打挂;
- 降级开关:
DCCService.downgradeSwitch字段已经预埋,但没有任何业务逻辑读它——这是个伏笔。
六、阶段一的亮点到底在哪里?
聊完技术细节,我想单独拎出来说说这套阶段一设计里我个人最喜欢的几个点。它们不一定是最 fancy 的技术,但都是「能让我后续省事」的设计。
6.1 责任链 + 策略模式:业务的可扩展性
TradeLockRuleFilterFactory 用一个 LinkArmory 把所有规则串起来,新加一条规则只需要写一个 @Service 类、在工厂里加一行,不用动现有代码。AbstractDiscountCalculateService + 4 个子类也是同理,新加一种优惠(满 200 减 50、买二送一)只是再写一个 @Service("M2J1") 类。
这是「面向扩展开放、面向修改关闭」的最朴素实践,但非常有效。
6.2 DCC(Dynamic Config Center):不停机调参
这个是我最喜欢的。DCCService 里三个字段:
@DCCValue("downgradeSwitch:0") private String downgradeSwitch; // 降级开关
@DCCValue("cutRange:100") private String cutRange; // 切流比例
@DCCValue("scBlacklist:s02c02")private String scBlacklist; // 渠道黑名单启动时从 Redis 读默认值,运行时通过 GET /api/v1/gbm/dcc/update_config?key=downgradeSwitch&value=1 推送新值,Redis Pub/Sub 通知到所有 JVM 实例,BeanPostProcessor 反射改字段。不需要重启服务就能调降级开关、切流比例——这套机制在生产环境做活动运营会救命。
6.3 异步通知 + 重试:和主链路解耦
notify_task 表 + GroupBuyNotifyJob 每 15 秒扫一次的组合,是经典的「事务性发件箱」(Transactional Outbox)模式:
- 拼团成功的事务里只做一件事——插一行
notify_task,不依赖任何外部服务; - 定时任务异步消费,重试最多 5 次,最终失败入「死信」人工处理;
- HTTP 和 MQ 两种回调都能支持,看
notify_type字段。
这样做的好处是——拼团结算的事务永远不会因为通知服务挂了而回滚,两个系统的可用性彻底解耦。
6.4 DDD 分层 + 聚合根:领域逻辑独立可测
GroupBuyOrderAggregate / GroupBuyTeamSettlementAggregate 把「一次拼团操作」涉及的实体打成一个包,外部只通过聚合根改状态。这让领域层可以完全脱离 Spring、MyBatis、Redis 来写单元测试——TradeLockOrderService.lockMarkctPayOrder() 接收的是 POJO,返回的是 POJO,没有一行 SQL、没有一行 Redis。
后期想重构存储(比如把 MySQL 换成 TiDB),domain 层一行都不用动。这就是 DDD 的好处。
6.5 一个完整的 demo 可观测
我把这套部署到了 https://yanshen.online/group-buy/,线上服务是 systemd 托管的 group-buy-market.service,后端 Java jar 跑在端口 8091,前面 nginx ^~ /group-buy/ 静态 + ^~ /api/ 反代。你现在打开这个页面,点几下按钮就能走完整个流程。所有 SQL、Redis、DCC 的状态都能在 phpmyadmin (8899)、redis-commander (8081) 里实时看到。
这种「写完就能跑、能跑就能看」的可观测性,比任何 PPT 上的架构图都有说服力。
七、小结 & 下一步
阶段一做的事情可以一句话概括:用 DDD 分层 + 责任链 + DCC + 异步通知,建了一个能跑起来、能讲清楚、但不抗压的拼团最小系统。
下一步(阶段二)我打算补这几个:
- 真分布式锁:用
RedissonService.getLock(teamId)把锁单入口收口; - 本地缓存:
Caffeine缓存group_buy_activity+sku+group_buy_discount,读多写少; - MQ 替换轮询:
notify_task表的扫描改成 RabbitMQ 消费; - 降级开关生效:
DCCService.downgradeSwitch接到具体业务(比如切流、白名单); - 单元测试:给责任链每个过滤器写单测,确保加新规则不破坏老的;
- 集成测试:用 JMeter 模拟 1k 并发锁单同一个 teamId,验证 100% 不超售。
这套工程在 https://yanshen.online/group-buy/ 上一直跑着,欢迎随时打开试。如果有想交流的、想看源码的,或者发现什么 bug,评论区见 👋
本文基于已部署上线版本 https://yanshen.online/group-buy/ 写作,所有接口调用、字段含义均与线上实现一致。源码 fork 自小傅哥的 group-buy-market-yanshen 项目。