做同城生活服务这个方向,我前后折腾了不少时间。从最初单一的外卖配送系统,到把跑腿、团购一起揉进同一个Java后端体系,踩过的坑比想象中多得多。这个项目说白了就是一套支持"外卖+跑腿+团购"三类业务的同城生活服务后端,用Java技术栈统一承载用户端、商家端、骑手端三端诉求,让用户在一个App里完成"点餐—找人代买—囤优惠券"的完整闭环。
这篇文章不画PPT式的架构图,只讲我实际搭建这套多场景融合后端时的真实做法。会从三条业务线的共性与差异拆解、技术选型背后的考量、订单中心与状态机设计、LBS地理位置服务和派单引擎、营销券与结算分账的细节,一直讲到高并发下的稳定性和线上问题排查。整个过程会带上可复用的表结构、核心代码逻辑和踩坑记录,适合正在做同城类项目、或者打算从单一业务向多业务扩展的Java后端同学参考。
1. 同城多场景平台:先拆业务,再谈技术
1.1 三条业务线的共性与差异
先说清楚为什么非要把三个场景放在一个平台里。外卖的核心链路是"用户下单→商家出餐→骑手取送",跑腿的核心链路是"用户发单→骑手接单→按需执行",团购的核心链路则是"用户买券→到店消费→商家核销"。三条线看起来完全不同,但站在平台视角,它们共享的东西非常可观:
- 同一套用户账号、实名认证和收货地址簿;
- 同一批合作商家,外卖商家可以同时上团购套餐;
- 同一批配送运力,外卖闲时能接跑腿单;
- 同一个订单中心,只是订单的"履约方式"不同。
如果分开做三套系统,用户要装三个App,商家要维护三套后台,骑手要切换三个接单工具,这既不现实也不经济。所以从产品层面就得定调:一个平台、三套业务规则、一套底层能力。后端要做的,就是把底层能力抽象好,让各业务线像插卡一样接进来。
差异同样要看清。外卖是典型的B2C定时履约,午晚高峰集中、时效要求极高;跑腿以C2C为主,需求碎片化,距离和物品类型决定价格,订单密度远低于外卖;团购则是"券"的生意,核心在营销和核销环节,基本不依赖实时配送。这三类差异直接决定了订单模型、计费模型和派单策略必须分开设计,不能拿一套逻辑硬套三条线。
1.2 技术栈选型:为什么还是Java
项目定了Java,这没什么好争的。同城业务本质是"高并发交易+复杂状态流转+LBS检索"的组合,Java生态在这块相当成熟,招聘容易、稳定性有保障、踩坑案例也丰富。我最终选定的组合如下:
| 分层 | 选型 | 选型理由 |
|---|---|---|
| 基础框架 | Spring Boot + Spring Cloud | 生态成熟,微服务拆分、治理方便 |
| 服务治理 | 注册中心 + 配置中心 + 网关 | 多业务线独立部署、灰度发布 |
| 数据存储 | MySQL 8.0 + Redis | 交易数据进MySQL,热点与状态用Redis |
| 消息队列 | RabbitMQ | 订单事件解耦、异步通知、延迟队列 |
| 搜索 | Elasticsearch | 商家搜索、地理范围检索 |
| 定时任务 | XXL-Job | 订单超时兜底、结算跑批 |
| 文件服务 | MinIO | 商家证照、商品图片、用户头像 |
这套组合最大的好处是组件间配合成熟,出了问题社区里能找到大量现成案例。相比自研底层中间件,我更倾向用成熟组件搭积木,把研发精力留给业务设计。项目里很多人容易走偏的一点是过度追求新框架,比如为了异构微服务强行引入多语言,结果跨语言排查问题成本翻倍。在我这个体量的项目里,一套Java Spring体系走到底,是最省心的选择。
提示:微服务不是越多越好。我见过一上来就拆二十多个服务的项目,最后全是跨服务调用的分布式事务问题。合理做法是先按"订单、用户、商户、骑手、营销、结算"六大域拆分,每个域内部再视业务复杂度决定是否继续细化。
2. 订单中心:多场景融合的枢纽
2.1 一套订单模型打通三条业务线
订单中心是这套系统的心脏,也是最容易设计翻车的地方。很多项目第一版会为外卖、跑腿、团购各建一套订单表,后面做统计、做客服、做结算时会痛不欲生。我采用的是"订单主表+业务类型+扩展字段"的模型,核心建表语句如下:
CREATE TABLE `order_master` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单ID', `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT NOT NULL COMMENT '下单用户ID', `biz_type` TINYINT NOT NULL COMMENT '业务类型:1外卖 2跑腿 3团购', `order_status` TINYINT NOT NULL COMMENT '订单状态:见状态机', `pay_status` TINYINT NOT NULL COMMENT '支付状态', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总额(分)', `delivery_fee` DECIMAL(10,2) DEFAULT 0 COMMENT '配送费', `shop_id` BIGINT DEFAULT NULL COMMENT '商家ID,外卖和团购用', `receiver_info` JSON COMMENT '收货人信息 {name,phone,address,lat,lng}', `distance` INT DEFAULT NULL COMMENT '配送距离(米)', `estimate_finish_time` DATETIME DEFAULT NULL COMMENT '预计完成时间', `extra` JSON COMMENT '业务类型扩展字段', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', `created_at` DATETIME NOT NULL, `updated_at` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_shop_id` (`shop_id`), KEY `idx_biz_type_status` (`biz_type`, `order_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';核心思路是:三条业务线共性的字段放主表,差异化的信息塞进extraJSON字段。外卖订单的extra里存餐品清单快照、商家备注、餐具数量;跑腿订单的extra里存物品类型、重量、代买清单、小费金额;团购订单的extra里存券码列表、门店ID、核销有效期。
这个设计带来的直接收益非常明显:统计报表只查一张表,按biz_type维度聚合即可;订单列表一次查询就能同时展示三类订单;客服系统不需要跳多张表来回拼装信息。代价是JSON字段没法高效检索,所以凡是需要条件查询的字段(比如跑腿的物品类型),就得额外落地成一个冗余字段或者单独的子表,这一点规划时需要提前想清楚。
2.2 显式状态机:彻底告别状态"记事本"
订单状态是最容易出线上事故的地方。我见过直接把状态字段当记事本用的代码,update order set status = 99,业务上完全说不清楚这单到底经历了什么。正确做法是显式定义状态机,每个业务类型一套状态流转规则。
外卖订单状态机:
待支付 → 已支付(待接单) → 商家接单 → 骑手取餐 → 配送中 → 已完成 ↘ 已取消 ↘ 配送异常 → 退款中 → 已退款跑腿订单状态机:
待支付 → 已支付(待派单) → 骑手接单 → 取件完成 → 配送中 → 已送达 → 已完成 ↘ 已取消 ↘ 加价改派团购订单状态机相对简单:
待支付 → 已支付(券待使用) → 已核销 → 已完成 ↘ 已退款所有状态流转全部收敛到OrderStateMachine里,业务代码不允许直接修改order_status,只能通过transition(event)方法触发合法流转。同时每笔订单的状态变化都写入独立的日志表:
CREATE TABLE `order_status_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `from_status` TINYINT DEFAULT NULL, `to_status` TINYINT NOT NULL, `operator_type` TINYINT COMMENT '操作方:1用户 2商家 3骑手 4系统', `operator_id` BIGINT DEFAULT NULL, `remark` VARCHAR(255) DEFAULT NULL, `created_at` DATETIME NOT NULL, PRIMARY KEY (`id`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单状态流转日志';状态机的实现不需要引入复杂框架,一个枚举加一个记录合法流转边的Map就够了。我踩过的坑是把状态流转规则配置到数据库表里,结果性能差、逻辑分散,排查问题两头找,最后果断回退到代码实现。除非业务方真的需要天天调整流转规则,否则代码里的状态机就是最稳定也最好维护的方案。
2.3 订单号与下单幂等
下单接口的高并发幂等是我印象最深的问题。同一用户双击下单,或者App端网络超时后自动重试,都可能产生多笔相同订单。我的做法是在下单接口入口做幂等控制,核心逻辑如下:
// 下单幂等:基于Redis setNX,key为 userId:bizType:bizId String idempotentKey = "order:idempotent:" + userId + ":" + bizType + ":" + bizId; Boolean acquired = redisTemplate.opsForValue().setIfAbsent(idempotentKey, orderNo, Duration.ofSeconds(10)); if (Boolean.FALSE.equals(acquired)) { String existingOrderNo = redisTemplate.opsForValue().get(idempotentKey); // 直接返回已存在的订单号,而不是创建新订单 return buildDuplicateResult(existingOrderNo); }幂等键需要保证在业务上是唯一的,比如外卖场景用"用户+商家+餐品组合+下单时间窗口"拼一个业务特征ID,跑腿场景用"用户+发布内容摘要+发单时间"。订单号生成我选择了雪花算法为主、Redis自增段为辅的方案。纯数据库自增主键不适合对外暴露,雪花算法在单机房内只要处理好时钟回拨问题,生成的ID准确落在BIGINT范围内,性能足够。
注意:千万别把内部订单ID直接暴露给用户。客服和用户沟通时,一个"20260214-2107-外卖-8823"这样的可读单号,比"9384756201"好用得多。我见过用户报内部ID少一位数字,整个订单查半天的惨案。
3. LBS与派单:外卖和跑腿的命脉
3.1 距离计算与地理检索
同城业务绕不开LBS。商家搜索排序、骑手派单、配送费计算,全都要依赖"两点之间距离"这个基础能力。常见做法有两种。
第一种是直接用MySQL内置函数算。数据量小的时候没问题,但st_distance_sphere这类函数用不上空间索引,商家表超过十万行查询就开始明显变慢。第二种是引入ES的geo检索,写入时把坐标存成geo_point类型,查询时用geo_distance排序。实测在百万级POI数据下,附近商家检索依然能稳定在几十毫秒内返回。
// ES mapping 片段 { "properties": { "shop_id": { "type": "long" }, "location": { "type": "geo_point" }, "service_type": { "type": "keyword" } } }// 查询附近5公里、支持外卖的商家 { "query": { "bool": { "filter": [ { "term": { "service_type": "delivery" } }, { "geo_distance": { "distance": "5km", "location": { "lat": 39.90, "lon": 116.40 } } } ] } }, "sort": [ { "_geo_distance": { "location": { "lat": 39.90, "lon": 116.40 }, "order": "asc", "unit": "m" } } ] }距离计算还有一个工程细节:球面距离公式的选择。业务上算配送费和预估送达时间,用Haversine公式足够,误差在0.5%以内。但真实道路距离和直线距离差很多,尤其是存在桥梁、隧道等绕行地形的区域,所以派单阶段必须接三方地图的路径规划API,拿到真正可骑行的距离和时间。直线距离负责"圈定候选范围",路径距离负责"定价和派单决策",两层各司其职。
3.2 派单引擎:从抢单升级到智能派单
跑腿业务早期版本用的是纯抢单模式,骑手在App里刷单自己抢。这个模式开发简单,但问题太明显:热门区域订单被秒抢,偏远区域订单挂半小时没人接,平台完全失去调控能力。后来我加了系统派单模式,形成"先系统派单、超时转抢单池"的混合策略。派单引擎核心是一个打分函数:
score = 距离分×0.4 + 运力空闲分×0.3 + 顺路分×0.2 + 骑手等级分×0.1每来一笔新订单,先圈出以发货点为中心3公里范围内的空闲骑手,逐个算分,选最高分派送。骑手15秒内不接单,自动转派给下一名骑手;整个候选池超过3分钟无人接,订单丢进公共抢单池。派单逻辑大致如下:
// 派单入口核心逻辑(伪代码) public void dispatch(Order order) { // 1. 查候选骑手:半径3km、在线、非配送中 List<Rider> candidates = riderService.findIdleRiders(order.getPickupLng(), order.getPickupLat(), 3000); // 2. 按得分排序 candidates.sort((r1, r2) -> score(r2, order) - score(r1, order)); // 3. 推送第一候选 Rider target = candidates.get(0); pushService.pushOrder(target.getRiderId(), order); // 4. 超时未接,流转到下一候选(由延迟任务触发) delayedTaskService.schedule(() -> redispatchIfTimeout(order, target), 15, TimeUnit.SECONDS); }这里有个细节很多人忽略:骑手端是移动网络,服务端推单不能直接用HTTP长轮询,否则App一切后台就收不到通知。我用的方案是WebSocket长连接,配合Redis维护"骑手在线状态和当前坐标"。骑手GPS每5秒上报一次坐标,服务端收到后更新Redis里rider:{id}:location,同时做一次粗略判断"该骑手是否落在新订单派单候选圈内",命中就主动推送。实测这个方案在几千骑手在线时依然稳定,连接断开还能靠心跳检测及时剔除,避免给离线骑手重复派单。
3.3 骑手轨迹:两段式存储
外卖和跑腿都需要实时轨迹展示。最初我做成"骑手每5秒上报一次坐标,直接写数据库",结果轨迹表一天膨胀几百万行,查询还慢。后来调整成两段式存储:
- 实时坐标放Redis,TTL设为10分钟,供订单详情页实时展示;
- 每30秒把坐标聚合成一条轨迹点,批量写入轨迹表,供历史回放和售后审核。
轨迹表按order_no做分区,超过30天的数据定时归档到冷存储。聚合时记得做抽稀:直线运动时每50米记一个点就够,转弯多的路段适当加密,否则轨迹文件会大得离谱。这个优化做完,轨迹查询接口从秒级直接降到百毫秒级,存储成本也砍掉一大半。
4. 多场景融合:营销、结算与网关
4.1 券体系的统一设计
团购的"券"和外卖的"优惠券"本质上是两种东西。团购券是预付费权益凭证,核销后商家才能收到钱;外卖优惠券是支付立减凭证,不核销也存在。但用户视角只有一个"我的卡券包",所以后端必须统一管理。
我的设计是双层结构:coupon_rule定义券的发行规则,coupon_instance定义用户实际持有的每一张券。券实例上带can_use_biz_types字段,标识这张券能在哪些业务线使用。这样既能做全场通用券(促活),也能做外卖专用券、团购专用券(精细化运营)。
CREATE TABLE `coupon_instance` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `rule_id` BIGINT NOT NULL, `instance_no` VARCHAR(32) NOT NULL COMMENT '券实例编号', `status` TINYINT NOT NULL COMMENT '1未使用 2已锁定 3已使用 4已过期 5已退款', `amount` DECIMAL(10,2) NOT NULL COMMENT '券面额', `threshold_amount` DECIMAL(10,2) DEFAULT NULL COMMENT '满减门槛', `can_use_biz_types` VARCHAR(32) NOT NULL COMMENT '可用业务类型,逗号分隔', `expire_time` DATETIME NOT NULL, `used_order_no` VARCHAR(32) DEFAULT NULL, `locked_at` DATETIME DEFAULT NULL, `created_at` DATETIME NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_status` (`user_id`, `status`), KEY `idx_rule_id` (`rule_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户券实例';核销券必须走防并发路径。用户到店出示券码,商家扫码核销,一次扫码请求可能被重复提交。我的方案是"Redis分布式锁+数据库乐观锁"双重控制,锁的key是coupon:use:{instanceNo},数据库更新时带上status=1条件,更新影响行数为0说明券已经被核销过。两道防线都拦住,基本杜绝了"一张券核销两次"的事故。
4.2 结算系统:多方分账的核心原则
外卖和团购都涉及"平台—商家—骑手"多方分账。这里最容易出的问题是金额对不上。我的处理原则是:结算不实时算,按T+1跑批处理。
每天凌晨定时任务拉取前一天所有已完成的订单,逐单计算:
商家实收 = 订单金额 - 平台佣金 - 配送费(平台代收代付) 骑手收入 = 配送费 + 小费/奖励 - 平台信息服务费 平台收入 = 平台佣金 + 信息服务费每笔订单在结算系统里生成一条settlement_record,同时按商家维度汇总出当日settlement_bill。跑批过程所有计算都记录日志,一旦某笔订单金额不平,立即标记异常并告警。做结算系统最重要的一句话:宁可让账单红着,也不能让它错着。任何"静默抹平"的逻辑都是埋雷,财务对账发现问题时,你根本不知道从哪里开始查。
4.3 网关层:三端入口的统一治理
同城平台通常有用户端、商家端、骑手端三个App,外加一个运营后台。如果每端都直连业务服务,鉴权逻辑散落各处,维护成本极高。我统一用网关层做了三件事:
- 路由:
/user-api/**进用户服务,/merchant-api/**进商家服务,/rider-api/**进骑手服务; - 鉴权:网关统一校验Token,解析用户身份后写入请求头,下游服务拿到的都是可信身份;
- 限流:按接口路径、用户维度做分布式限流,防止个别脚本把服务拖垮。
网关层限流我用的令牌桶算法,针对高频接口如"发单"、"刷新订单列表"单独配置阈值。实测下来,同城业务最怕的不是瞬时大促峰值,而是恶意脚本的持续低频刷单,这种流量形态比瞬时峰值更难拦截,必须结合用户维度的异常行为检测来配合治理。
5. 高并发、数据一致性与线上问题排查
5.1 库存与超卖问题
团购卖券存在限量库存问题,比如某商家放出100张团购券,卖完即止。不做控制的话,用户同时下单第101单也能成功。我的做法是"Redis预扣+MySQL最终扣减":
- 下单时,用Lua脚本原子地在Redis里扣减库存,扣减失败直接返回"已售罄";
- 支付成功后再异步扣减MySQL库存,保证最终一致;
- 订单超时未支付,Redis和MySQL同时回补库存。
-- Redis扣减库存Lua脚本 local stock = redis.call('GET', KEYS[1]) if not stock then return -1 end if tonumber(stock) < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1这里最坑的问题是"超时回补"与"已支付扣减"并发时可能出现的库存错乱。我最终的做法是:支付回调到达时先检查Redis里预扣记录是否还存在,如果不存在说明库存已经被回补,这笔订单直接转为失败退款,不再占用库存。逻辑上可能损失一笔订单,但保证了库存数字永远正确。在业务上这是个可以接受的取舍,有库存争议远比少一单成交严重。
5.2 订单超时自动关闭与延迟任务
订单超时未支付自动关闭、骑手超时未接单自动改派,这两类需求都依赖可靠的延迟任务机制。我用RabbitMQ延迟队列实现,下单时把orderNo和过期时间投递到延迟队列,延迟时间到后消费者检查订单状态,决定是否需要关闭或改派。
延迟队列的坑在于:消息积压时,延迟时间会被拉长,订单关闭就变得不准时。我的对策是同时用XXL-Job跑一个"每分钟扫描待支付订单"的兜底任务,扫描条件为pay_status=0 AND created_at < 当前时间-15分钟,用数据库扫描兜住MQ的延迟误差。两个通道处理同一件事,但逻辑上做了幂等,谁先执行结果都一样。这种"主通道+兜底通道"的双保险,在同城高并发场景下基本是标配。
5.3 常见问题排查速查表
运维阶段我把高频问题整理成了一张速查表,这里直接分享出来:
| 症状 | 可能原因 | 排查手段 |
|---|---|---|
| 订单状态卡在"待接单" | 派单服务消费者挂了或消息积压 | 查看MQ消费积压数,检查派单服务日志 |
| 用户支付成功但订单未支付 | 支付回调丢失或处理接口异常 | 查支付回调日志,手动触发对账补单 |
| 骑手坐标长时间不更新 | App被系统杀后台或网络异常 | 查WebSocket在线状态,确认Redis坐标TTL |
| 团购券核销提示"已使用" | 用户重复扫码 | 查券状态流转日志,确认是否并发核销 |
| 结算金额不平 | 退款订单重复计入 | 查settlement_record,核对退款时间点 |
| 接口响应变慢 | 慢SQL或缓存穿透 | 打开慢查询日志,观察Redis命中率 |
5.4 缓存穿透与热点Key
外卖午高峰时,热门商家详情页会被大量用户同时点击。不加缓存的话,数据库分分钟被打爆;加了缓存,又可能被一个不存在的商家ID直接打穿。我的三层防护方案:
- 缓存空值:查不到的数据在Redis里存一个TTL为30秒的空标记,防止同一key反复打库;
- 布隆过滤器:商家ID写入时登记进过滤器,查询前先判断ID是否存在,不存在直接返回空;
- 热点Key自动识别:监控Redis单key访问频率,超过阈值自动在本地缓存层放一份副本。
实际效果是,午高峰时商家详情接口P99响应时间能稳定压在200毫秒以内。这套组合拳基本是同城业务的标准解法,可以直接抄作业。
5.5 跨服务事务的取舍
多场景融合后,一个下单动作可能涉及用户服务、订单服务、营销服务、支付服务。我最后放弃了强一致分布式事务框架,全部走"本地消息表+MQ最终一致"模式。关键业务操作和消息写入放在同一个本地事务里,MQ消费者读消息表执行下游动作,再配合对账任务兜底。
注意:两阶段提交在人少单少的内部系统里够用,但同城业务的峰值流量下,协调者会成为明显瓶颈,长事务锁库风险也大。最终一致性在业务上完全可以接受——订单状态从"支付成功"到"商家接单通知"之间差几秒,用户几乎没有感知。
6. 部署与监控的实战心得
6.1 一套环境支撑三套业务
开发环境我用了Docker Compose一键拉起全套中间件:MySQL、Redis、RabbitMQ、ES、MinIO。每台开发机跑一套隔离环境,新同事clone代码后执行一个脚本就能启动整个后端,再也不用因为环境不一致互相甩锅。
微服务本体用镜像构建,走流水线自动部署。这里我特别强调一点:灰度发布。像派单引擎这种核心模块,改动后先在5%的流量上观察半小时,看异常率和平均配送时长再逐步放量。同城业务最怕的是"改一处崩全城",灰度是底线操作。
6.2 监控指标怎么选
监控不是做得越多越好。我最终沉淀的核心指标只有五类:
- 接口维度:QPS、P99/P95延迟、错误率;
- MQ积压量:尤其订单事件和支付回调两个队列;
- 订单漏斗:下单→支付→接单→完成的转化率,哪一层掉了立刻能发现;
- 运力健康度:骑手在线率与接单率;
- 存储稳定性:缓存命中率与慢SQL数量。
这五类指标配合告警,足够覆盖同城平台90%的线上故障场景。告警阈值别拍脑袋定,建议用历史数据反推:取过去30天正常数据的P99值,再用这个值的80%作为告警线,误报率会低很多。
写到这,这套外卖+跑腿+团购多场景融合的Java后端骨架就算完整了。我个人最大的体会是:多场景融合的难点从来不在某个单一功能上,而在"共享与隔离"的平衡。用户、订单、骑手、券、结算这些底层能力要坚决共享,而派单策略、计费规则、营销玩法这些业务逻辑要坚决隔离。
最后再分享一个小技巧:在设计订单状态机和结算规则时,一定要让运营和客服参与评审。我最初设计的状态机是纯技术视角,结果客服反馈"用户说订单已送达,但钱还没到商家账上"这类问题时,才发现状态机里缺少了"配送完成"与"资金结算"的时序说明。后来我在状态表里增加一个capital_status字段,把物流状态和资金状态分开维护,很多说不清的客诉就迎刃而解了。这个细节建议做同城业务的同学都留意一下。