简介社区团购系统看似与电商相仿实则因“预售集单自提”的业务形态在数据模型、订单状态机与库存扣减方案上有显著差异。在系统构建过程中高并发秒杀场景下的库存准确性是工程核心难点之一——不能依赖数据库行锁或单机锁需借助Redis与Lua脚本实现原子化的预扣与回补并以订单幂等表、事务边界控制配合定时对账保障最终一致性。这类设计不仅服务于社区生鲜的履约闭环对秒杀、活动促销等通用高并发场景同样有借鉴意义。通过一套Java单体多模块实现足以支撑城市级业务的稳定运转兼顾工程质量与交付效率。在实战层面对预收、核销、佣金结算等链路均有完整落地方案可直接复用于同类型项目。 社区团购系统不就是个电商系统吗这句话我听过太多次说这话的人大概率没做过生鲜类目。社区团购系统代码的复杂度恰恰藏在“预售集单自提”这三个词里面。用户在小程序里挑菜、下单、第二天去自提点取货看起来和普通电商差不多但库存模型、订单状态机、资金结算链路完全是另一套逻辑。这篇文章就以我实际开发的一版 Java 社区团购系统代码为主线从业务模型讲起拆到数据表设计、核心接口实现、秒杀并发处理最后把上线后最容易翻车的几个坑单独拿出来说。内容偏向实战读者如果是这几类人应该能省下不少试错时间正在找Java练手项目想拿一个“有真实业务复杂度”的系统去面试的开发者公司在做本地生活、社区零售相关业务需要快速起盘的团队技术成员单纯好奇生鲜团购和传统电商在代码层面到底差在哪的人。我尽量少说废话能贴代码的地方直接贴代码每个关键决策都会讲清楚为什么这么做。1. 社区团购系统到底在解决什么问题1.1 预售与集单和传统电商最本质的区别传统电商是“有货就卖下单即扣库存支付后立刻触发快递”。社区团购不是。社区团购按“团期”运转平台每天或者每周开放一个或者几个购买批次用户在团期内自由下单团期截止后系统把所有订单汇总起来统一采购、分拣再配送到各个自提点。这个模式对系统设计的影响是决定性的。第一库存不是实时仓库库存。团期内展示的“可卖数量”是运营在开团前配置的每日采购额度。用户下单后库存减少但这不代表仓库里真的少了一件货它只是告诉运营“今天大概要采购多少”。团期结束后的汇总数字才是真正驱动采购的依据。第二订单生命周期里有一个非常长的“等待团期结束”阶段。用户支付完成后订单不会像电商那样立刻进入发货流程而是安静地躺在“已支付”状态等到团期截止系统批量把订单打包成采购单和配送批次。第三整体履约以“团期自提点”为粒度。分拣员看的不是一张张订单而是“某个自提点今天有多少件货”司机送的也不是散单而是按自提点装好的周转箱。对应到代码里订单表必须冗余自提点ID和团期ID几乎所有批量查询都靠这两个字段。我用一个类比帮团队里新来的同学理解社区团购更像公司里面大家统一订盒饭中午12点前各自下单12点整截单行政统计完人数一起下单下午按部门分发。传统电商则是你随时打开外卖软件半小时后就有人送到工位。1.2 团长与自提点系统里最特殊的角色团长是社区团购独有的角色。他既是流量入口负责拉新、维护社群、推广商品又是履约末端负责收货、分拣、通知用户来自提。这意味着系统必须给团长提供一整套独立工具而不是简单地在用户端加个“团长标识”。团长端小程序至少要有三个核心能力订单核销用户到店出示自提码团长扫码确认提货佣金查看按周期查看自己名下的订单佣金和提现记录异常处理到货少件、商品破损时快速上报不需要走平台工单流程。还有一个容易被忽略的需求代取。小区里经常出现“我上班我妈去取菜”的情况自提码需要支持转发或者被团长手动改为代取人。这个小功能不做客服压力会大很多。在代码层面用户身份要区分“普通用户”和“团长”团长还有单独的账户表和结算表。因为团长的提现和平台对团长的结算本质上是一套资金账户体系。1.3 功能模块划分与工程结构整理一下社区团购系统的功能模块大致是这几块。模块核心职责用户端小程序浏览团期、搜索商品、下单支付、查看自提单、申请售后团长端核销订单、订单汇总、佣金查询、异常上报平台管理后台商品管理、团期管理、价格库存维护、订单管理、营销、财务结算商品中心商品/SKU维护、团期商品上下架、可售库存初始化订单中心下单、支付、取消、订单状态流转、售后单库存中心可售库存扣减与回补、库存流水记录支付与结算支付回调、退款、对账、佣金计算与提现消息与任务支付成功通知、团期结束批量处理、超时未付取消工程结构我用的是 Maven 多模块单体架构而不是一上来就拆微服务community-group-buy ├── group-buy-common 公共组件统一返回、异常、工具类 ├── group-buy-api 用户端和团长端 HTTP 接口层 ├── group-buy-admin 平台管理后台接口层 ├── group-buy-service 核心业务逻辑层 ├── group-buy-job 定时任务超时关单、对账、批次结算 └── group-buy-dao 数据访问层MyBatis-Plus这套结构做面试项目也很有说头面试官问起“你的项目为什么不做微服务”你能把上面的理由讲清楚比背一堆微服务组件名有用得多。2. 技术选型这套Java代码为什么这么搭2.1 技术栈清单我的技术栈选择如下组件选型选型理由JDK17长期支持版本Spring Boot 2.7 和 3.x 都能稳定运行框架Spring Boot 2.7.x生态成熟资料多出问题容易排查ORMMyBatis-Plus复杂SQL可控CRUD效率高适合中后台快速迭代数据库MySQL 8.0关系型数据为主订单、支付流水都需要事务保证缓存Redis库存预扣、限购计数、热数据缓存消息队列RocketMQ下单异步化、支付成功后的消息驱动后续动作定时任务XXL-Job超时关单、对账、佣金结算都依赖可靠定时任务部署单机/Docker Compose 起步前期避免K8s节省运维成本这套选型最大的特点是“稳”和“省”。社区团购系统不是超高并发互联网应用核心压力集中在开团秒杀那几分钟一台像样的服务器加一个 Redis 实例配合合理的异步化设计扛住一般的城市级社区团购业务量没有问题。2.2 为什么不用微服务架构聊一下我为什么没有选择 Spring Cloud Alibaba 那一套。很多同学拿到这类项目第一反应就是注册中心、网关、熔断、分布式事务全上实际上完全没必要。社区团购在业务上可以天然分为商品、订单、支付、库存、营销几个域但初期团队的开发人员少如果把订单服务和库存服务拆开一次下单操作就要涉及跨服务调用就得处理分布式事务。用 Seata 也好用本地消息表也好复杂度上去了收益却很小。单体多模块的好处是一次下单、扣库存、写订单、清购物车可以放在同一个本地事务里MySQL 的 ACID 直接保证数据一致性代码写起来简单排查问题也简单。等业务量真正上来再做按订单域、库存域拆分的演进模块边界已经划好拆分成本也不高。关于“为什么这样选型”我的经验是技术选型永远跟着业务阶段走。10 个人的团队非要去搞 30 个微服务最后时间全花在联调和治理上业务根本跑不起来。2.3 部署环境中最容易忽略的细节这里必须单独说一下环境问题因为社区团购系统的核心逻辑和“时间”强相关。一天一个团期开团时间、结束时间、配送时间、核销时间全是精确到秒的时间判断。环境不一致会出现非常隐蔽的线上事故。三个地方必须统一成 Asia/ShanghaiJVM 时区启动参数加-Duser.timezoneAsia/Shanghai或者容器环境变量配置TZAsia/ShanghaiMySQL 连接串jdbc:mysql://localhost:3306/group_buy?serverTimezoneAsia/ShanghaiJSON 序列化Spring Boot 里配置spring.jackson.time-zone: GMT8。我第一版上线时吃过一次亏服务器默认 UTC 时区JVM 里日期显示正常但存到数据库后所有时间都晚了 8 小时。用户晚上 23:30 下单系统认为时间已经过了 24:00触发“团期已结束”的校验用户一脸懵后台查数据也怎么都对不上。另外部署机上的 Java 环境变量配置虽然基础但也很容易栽跟头。新申请一台 Linux 服务器装完 JDK 之后务必确认下面几条export JAVA_HOME/usr/local/jdk-17 export PATH$JAVA_HOME/bin:$PATH注意不要只配 PATH 忘了 JAVA_HOME很多脚本比如 Tomcat 的 catalina.sh、Spring Boot 的启动脚本会直接读取 JAVA_HOME。配置完后执行java -version和echo $JAVA_HOME双确认。3. 数据模型设计用户-订单-库存背后的小心思3.1 团期表与商品表怎么设计社区团购的数据模型核心不是商品而是“团期”。所有售卖行为都发生在某个团期之内所以商品 SKU 表上会挂一个 batch_id 字段表示“这个 SKU 属于哪个团期的商品”。团期表t_groupon_batch的核心字段字段含义id主键batch_no团期编号格式建议yyyyMMdd业务上直接按天流转batch_name团期名称start_time开团时间end_time截单时间status状态待开始 / 售卖中 / 已截单 / 已结束SKU 表t_goods_sku在传统电商字段基础上增加了几个社区团购特有字段字段含义batch_id所属团期IDsell_stock可售库存运营配置的每日额度limit_num单用户限购数min_order_num起售数量生鲜很多商品必须按份卖origin_price / sale_price原价和团购价很多人在设计库存表时习惯直接用“实时库存”字段但在社区团购场景里这是个误区。这里的 sell_stock 不是某个仓库的实时剩余量而是“这个团期还能卖多少份”。每天团期开始前定时任务会根据运营配置把每日额度灌入 sell_stock团期结束后重置并归档。这种设计带来的好处是库存扣减逻辑非常简洁不受采购、退仓、盘点这些仓库动作干扰。3.2 订单主表、明细表、支付流水表订单相关表是整个系统最核心的部分我拆成了四张t_order订单主表核心字段有 order_no、user_id、batch_id、store_id自提点、total_amount、pay_amount、order_status、create_time、pay_time。这里特别强调冗余 batch_id 和 store_id。社区团购的后台操作几乎都按“自提点团期”维度批量进行比如“把某自提点某天所有已支付订单打包成配送批次”。如果订单表不冗余这两个字段每次都要去 join 团期表和自提点表查询性能差代码也啰嗦。t_order_item订单明细表记录每个订单下的商品快照包括商品名称、图片、单价、数量、规格参数。必须存快照不能只存 SKU_ID否则商品改价后历史订单的金额就对不上了。t_pay_flow支付流水表这是最容易被人忽略但极其重要的一张表。每次支付回调、退款动作都会在这里记录一条流水字段包含 pay_flow_no、order_no、channel支付渠道、amount、status、callback_raw回调原文。t_order_status_log订单状态变更日志表。订单每发生一次状态流转就插入一条记录方便线上排查“用户明明支付了为什么订单还是待付款”这类问题。订单状态机大概是这样的链路待支付(PENDING_PAY) - 已支付(PAID支付回调成功) - 备货中(PREPARING团期结束后批量流转) - 配送中(DISPATCHING司机出库) - 待自提(WAIT_PICKUP到店) - 已完成(COMPLETED团长核销) 待支付 - 已取消(CANCELED超时未付或主动取消) 已支付 - 售后中(REFUNDING用户申请退款)3.3 资金与结算佣金表和结算单团长不是白干活的系统需要按订单金额比例给他计算佣金。这块我单独建了t_commission_detail和t_settlement_bill两张表。t_commission_detail记录每一笔订单对应的佣金明细字段有 order_no、store_id、user_id团长、amount佣金金额、status待结算/已结算/已锁定。为什么要单独建佣金明细而不是在订单表上算因为佣金不是支付成功立刻结算的。售后窗口期内用户可能退款退款后佣金要回收或冻结。所以我做了一个“锁定”状态订单支付后进待结算售后窗口关闭通常是核销后24到48小时才转为已结算。t_settlement_bill是结算单按周期把这些明细汇总为一张账单团长按账单提现。这个设计保证了对账口径的清晰避免“明明有订单但佣金对不上”的扯皮。4. 核心链路代码拆解从加购物车到支付回调的完整落地方案4.1 下单接口的校验逻辑下单是社区团购系统最核心的接口几乎所有的业务规则都会在这里体现。先看一段订单 Service 的骨架代码Transactional(rollbackFor Exception.class) public Long createOrder(CreateOrderRequest request) { // 1. 校验团期状态 GrouponBatch batch batchMapper.selectById(request.getBatchId()); if (batch null || batch.getStatus() ! BatchStatus.ON_SALE) { throw new BizException(当前团期不可下单); } // 2. 校验商品和库存 GoodsSku sku skuMapper.selectById(request.getSkuId()); if (sku null || !sku.getBatchId().equals(request.getBatchId())) { throw new BizException(商品不存在); } // 3. 扣减可售库存使用条件更新保证原子性 int rows skuMapper.deductStock(request.getSkuId(), request.getBatchId(), request.getCount()); if (rows 0) { throw new BizException(库存不足); } // 4. 创建订单主表和订单明细 Order order buildOrder(request, sku); orderMapper.insert(order); orderItemMapper.insert(buildOrderItem(order.getId(), sku)); // 5. 清理购物车对应商品 cartMapper.deleteByUserIdAndSkuId(request.getUserId(), request.getSkuId()); // 6. 发送延迟消息团期截止后自动关闭未支付订单 mqTemplate.syncSend(order_pending_check, order.getId(), 1000 * 60 * 10); return order.getId(); }这个接口最关键的一行是第三步的扣库存 SQLUpdate(UPDATE t_goods_sku SET sell_stock sell_stock - #{count} WHERE id #{skuId} AND batch_id #{batchId} AND sell_stock #{count}) int deductStock(Param(skuId) Long skuId, Param(batchId) Long batchId, Param(count) Integer count);注意这里的技巧用WHERE sell_stock #{count}条件更新数据库行锁会阻塞其他同样操作这行记录的事务同时保证不会扣成负数。返回值 rows0 就说明库存不足或者已经被扣完不需要先 select 再 update也避免了并发下“查询时有库存更新时被扣完”的问题。这里的 Transactional 保证了“扣库存 建订单”在同一个本地事务里要么都成功要么都失败不会有“库存扣了但订单没建”的脏数据。4.2 事务边界怎么划分很多新手容易犯一个错误把支付回调里的所有操作都塞进一个大事务。我见过有人把“更新支付流水 更新订单状态 清购物车 发短信通知 推送小程序订阅消息”全部写在 Transactional 方法里结果下游通知服务超时整个事务回滚用户付了钱订单却显示未支付。正确做法是事务内只做最核心的状态变更事务外做所有非核心操作。支付回调的核心事务插入支付流水更新订单状态为已支付。其他动作比如清购物车、通知团长、生成核销码全部通过发送 MQ 消息异步处理。即使 MQ 消费失败也可以通过定时任务补偿不影响主链路。4.3 支付回调处理幂等与并发支付回调是所有接口里对幂等性要求最高的。支付渠道可能因为网络原因重复回调也可能同一个回调在极短的时间内被发送多次。如果不做幂等用户支付一单可能被系统重复处理成两单已支付状态。我的处理方案是这样的Transactional(rollbackFor Exception.class) public PayResult handlePayCallback(PayCallbackDTO dto) { // 1. 先查本地支付流水存在说明已经处理过直接返回成功 PayFlow payFlow payFlowMapper.selectByPayFlowNo(dto.getPayFlowNo()); if (payFlow ! null) { return PayResult.success(); } // 2. 插入支付流水唯一索引兜底并发情况 PayFlow insertFlow new PayFlow(); insertFlow.setOrderNo(dto.getOrderNo()); insertFlow.setPayFlowNo(dto.getPayFlowNo()); insertFlow.setAmount(dto.getAmount()); insertFlow.setStatus(PayFlowStatus.SUCCESS); try { payFlowMapper.insert(insertFlow); } catch (DuplicateKeyException e) { // 并发回调时另一个线程已经插入直接返回成功 return PayResult.success(); } // 3. 更新订单状态加上条件防止重复推进状态 int rows orderMapper.updateStatusIfCurrent( dto.getOrderNo(), OrderStatus.PENDING_PAY, OrderStatus.PAID); if (rows 0) { // 事务提交后发送MQ TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { mqTemplate.syncSend(order_paid, dto.getOrderNo()); } }); } return PayResult.success(); }这里有两层幂等保护第一层t_pay_flow表的pay_flow_no字段建唯一索引。并发回调时两个线程同时走到插入这一步只有一个能成功插入另一个会抛 DuplicateKeyException捕获后直接返回成功即可。第二层订单状态推进使用条件更新WHERE order_status PENDING_PAY。这样即使同一个订单收到两笔不同的支付流水也只有一个能把订单从待支付推进到已支付。4.4 购物车的批量加购优化社区团购的用户画像和传统电商差别很大家庭用户占比高复购极其频繁。很多用户每天早上打开小程序把昨天买过的菜再买一遍。所以购物车接口要支持批量操作而且最好有“一键加购上次购买”的功能。批量加购不要循环一条条执行SQL 用 INSERT ... ON DUPLICATE KEY UPDATE 一次搞定INSERT INTO t_cart (user_id, sku_id, count, create_time, update_time) VALUES (1001, 2001, 2, NOW(), NOW()), (1001, 2002, 3, NOW(), NOW()) ON DUPLICATE KEY UPDATE count count VALUES(count), update_time NOW();注意t_cart表要给(user_id, sku_id)建唯一索引这样用户重复加购同一商品时会自动累加数量而不是插入两条脏数据。5. 高并发场景社区团购秒杀与库存扣减的并发处理5.1 为什么不能用 synchronized 或数据库 FOR UPDATE社区团购的高并发主要来自两个场景每天早上开团时的流量尖峰以及运营发起的秒杀活动。流量特点是短时间内的读请求和下单请求暴涨持续几分钟。做秒杀最常见的错误是用synchronized锁方法。单机部署时 synchronized 能挡住并发但系统上线后大概率是多实例部署A 实例的锁管不住 B 实例的请求超卖问题照样出现。还有人喜欢用SELECT ... FOR UPDATE锁库存行。这在低并发下没问题但秒杀时大量请求同时阻塞在同一行记录上数据库连接池很容易被占满其他业务的 SQL 全部排不上队整个系统卡死。所以秒杀场景的库存扣减不能走数据库行锁必须前置到 Redis 做预扣。5.2 用 Redis 预扣库存 Lua 脚本保证原子性具体方案是开团前由定时任务把每个 SKU 的可售库存灌入 Redis键名规则建议stock:{batchId}:{skuId}。用户下单时先执行 Lua 脚本扣减 Redis 库存扣成功后再发 MQ 异步创建数据库订单。Lua 脚本如下if redis.call(exists, KEYS[1]) 0 then return -1 end local stock tonumber(redis.call(get, KEYS[1])) if stock tonumber(ARGV[1]) then return 0 end redis.call(decrby, KEYS[1], ARGV[1]) return 1这段脚本的逻辑是KEYS[1] 是库存键ARGV[1] 是本次要扣减的数量库存不存在返回 -1说明该 SKU 没有初始化库存库存不足返回 0扣减成功返回 1。Redis 单线程执行特性保证了 Lua 脚本的原子性“判断库存是否充足”和“扣减库存”两步不会被其他命令打断。Java 侧调用代码private boolean deductStockFromRedis(Long batchId, Long skuId, Integer count) { String key stock: batchId : skuId; Long result redisTemplate.execute( stockLuaScript, Collections.singletonList(key), String.valueOf(count) ); return Long.valueOf(1L).equals(result); }这里还有一个细节Redis 扣减成功后数据库里的 sell_stock 也要更新。我是先写本地t_stock_change_log流水表包含 batch_id、sku_id、change_count、status、幂等键再发 MQ 异步更新数据库库存。这样即使 MQ 消费失败定时任务也能扫描流水表兜底补发。5.3 用户维度限购与防刷秒杀场景除了控制库存还要控制每个用户的购买数量。否则一个人用脚本疯狂下单普通用户什么都抢不到。用户维度限购我用 Redis 计数器实现// key: limit:{batchId}:{skuId}:{userId} String limitKey limit: batchId : skuId : userId; Long bought redisTemplate.opsForValue().increment(limitKey); if (bought ! null bought 1L) { redisTemplate.expire(limitKey, Duration.ofMinutes(30)); } if (bought sku.getLimitNum()) { throw new BizException(超出限购数量); }下单成功前这个计数先加了如果后续逻辑失败要从 Lua 脚本里一并把库存回退。所以实际生产代码我是把“扣库存”和“加限购计数”一起放进 Lua 脚本保证要么都成功要么都失败避免“库存没扣限购数却增加了”。5.4 异步化后的数据一致性问题秒杀接口不能同步阻塞等着创建订单那样响应时间完全不可控。我的做法是接口收到请求后只做三件事Lua 脚本扣减 Redis 库存发送 MQ 消息消息体里带上 batchId、skuId、userId、count、orderToken立即返回“排队中”给前端前端轮询订单查询接口等待结果。MQ 消费者负责创建数据库订单和扣减数据库库存。这里有个关键问题消费者消费消息失败怎么办消息重复消费怎么办首先消息幂等靠的是t_order表里的幂等键。订单号我们可以提前生成好作为消息的一部分发送消费者插入订单时遇到重复订单号会抛出 DuplicateKeyException捕获后直接确认消息消费成功不做二次处理。其次消费失败要有重试机制。RocketMQ 默认支持重试如果重试多次还是失败消息会进入死信队列。我在死信队列的消费者里做了告警同时写了一个定时补偿任务扫描t_stock_change_log表凡是“Redis 已扣但数据库订单 30 分钟还没创建成功”的记录重新投递一次创建订单的消息。这套“本地流水表 MQ 定时补偿”的方案比直接依赖分布式事务框架简单得多而且完全够用。面试时讲清楚这个设计比背一堆分布式事务理论强很多。6. 上线后最耗时间的坑状态一致性、对账与售后链路6.1 超时未支付订单的自动取消社区团购的支付窗口通常很长很多平台允许用户当天下单、当天指定时间前支付比如“23:59 前未支付自动取消”。这样设计有业务原因生鲜采购必须等到支付结果相对确定后才能下采购单所以支付窗口给得很宽松。自动取消我优先选定时任务而不是延迟队列。原因很简单支付窗口太长几小时到一天让延迟消息在队列里等那么久浪费资源每天按固定节奏扫描一次数据库成本低逻辑简单。UPDATE t_order SET order_status CANCELED, cancel_time NOW() WHERE order_status PENDING_PAY AND create_time NOW() - INTERVAL 2 HOUR执行这条更新前必须判断当前团期是否已经截单。如果团期已经结束订单已经进入采购批次就不能再取消了否则会给采购环节留下一个大窟窿。所以取消逻辑里要校验“当前时间是否早于订单所属团期的截止时间”确认还能取消才执行。取消后还要回补库存。如果这个 SKU 的 redis 库存还没过期需要同步 INCR 回去否则用户端看到的剩余库存会偏少影响销售。6.2 支付渠道对账不能只信回调“支付成功但订单显示待付款”这个投诉社区团购系统上线后一定遇到过。原因一般是回调丢失、回调延迟或者回调到达时订单已经被定时任务自动取消了。只靠支付回调处理一致性问题是不够的必须做渠道对账。我建议系统上线第一天就把对账任务加进去而不是等出了问题再补。对账任务的流程每天凌晨 2 点调用支付渠道的对账单下载接口拉取前一日所有交易流水解析对账单按支付流水号与本地t_pay_flow表逐笔匹配渠道有记录但本地没有 - 支付成功但本地订单状态可能还是待支付触发“补单”逻辑把订单置为已支付本地有记录但渠道没有 - 通常是测试数据或者回调异常记录告警人工确认。对账任务我放在独立的group-buy-job模块里不依赖管理后台手动触发。这类任务不需要人盯着但要保证每天稳定运行并且对账结果有日报通知。6.3 核销与退款的状态边界问题核销和退款绕在一起是社区团购系统 bug 率最高的环节。先看核心码的状态。我专门为每个自提单生成一个核销码核销码独立表字段有 pickup_code、order_no、store_id、status待核销/已核销本文还有配套的精品资源点击获取