去年有个做家政创业的朋友找我,说想做一个“同城上门做饭”的小程序,用户点一下就能约阿姨上门,后端问我用什么技术栈。我几乎没有犹豫就定了Java——不是因为它最时髦,而是因为这个场景对稳定性、团队上手成本、后续扩展的要求,Java生态都是成熟答案。这个项目从零到上线,踩了不少坑,也总结出很多经验,这篇文章就把整个思路和落地过程完整拆给大家。
如果你想用Java做一个同城上门服务平台,或者正在面试Java后端、想找一个能写在简历上的实战项目,这篇文章应该能给你不少启发。我会从业务分析、技术选型、表结构设计、核心接口实现,到抢单、支付、排障,把我们实际遇到的坑和解决办法都摊开讲。
1. 先别急着写代码,把“做饭家政”这个业务想透
很多开发者的习惯是拿到需求就建工程、写接口,但同城上门服务这种业务,如果不在前期把业务模型理清,后面改起来非常痛苦。做饭家政跟普通电商有本质区别,它卖的是“非标服务 + 人的履约时间”,而不是一件标准品。
1.1 它和外卖、打车有什么本质区别
外卖的核心是“货”和“配送”,货是标准化商品,骑手只负责搬运。打车是“车”和“路线”,司机和乘客的位置都是动态的。而同城做饭家政业务更复杂:服务的载体是“人”,同一个保洁阿姨,今天来你家做两小时保洁,明天去别人家做三小时收纳,服务质量高度依赖个人能力和临场状态。
这种业务对平台系统的要求是:时间撮合 + 地点撮合 + 服务能力撮合。用户选一个时间段,系统要找到那个时间段有空、距离合适、技能匹配的服务人员。所以一开始我们就把整个系统拆成三大块:用户端、服务端(阿姨/师傅端)、运营管理后台。
1.2 平台角色:撮合、履约还是全托管
初期最容易犯的错是把平台想成“全托管”,觉得自己要管培训、管排班、管服务质量。后来我们想清楚了:MVP阶段平台只做撮合 + 交易保障,服务人员可以自注册,平台做资质审核,订单完成后用户线上支付,平台抽佣,服务人员提现。
这个定位直接决定了系统边界。如果是全托管,你需要做排班系统、培训资料系统、服务 SLA 监控,复杂度直接翻倍;而撮合模式只需要围绕“人找服务、服务找人”打造闭环,也就是:
- 用户创建预约订单
- 系统分配或服务人员抢单
- 服务人员上门签到、开始服务、结束服务
- 用户支付、评价
- 平台结算、提现
1.3 最小可行版的范围控制
我们的MVP功能清单是:用户注册登录、服务项目展示(做饭/保洁/收纳)、地址管理、一键下单、订单状态流转、服务人员端接单/开始/结束、支付回调、简单评价。砍掉了优惠券、拼团、会员卡、复杂派单算法、实时定位追踪,这些等核心闭环跑通后再补。
砍需求的时候一定要果断。经常有产品经理想把砍掉的每个功能都说成“必须有”,但技术侧的判断标准很简单:没有它,用户能不能完成一次付费服务? 如果能,就先不做。这也是Java后端项目能快速上线的原因,单体应用加一套成熟框架,比一上来就拆微服务靠谱得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端技术选型:Java生态这套组合拳怎么打
技术选型要结合团队能力、业务阶段、部署成本。我们团队对Java最熟,所以整个后端基于Java生态,工具链选择也尽量“主流且保守”。
2.1 Spring Boot版本与JDK版本怎么选
Spring Boot 3.0之后强制要求JDK 17,Spring Boot 2.7.x支持JDK 8/11/17。我们最终选了 Spring Boot 2.7.14 + JDK 11,没有直接上JDK 17和Spring Boot 3。
原因很实在:第一,很多老牌的支付SDK、短信SDK、OSS SDK虽然已经兼容JDK 17,但总有例外,升级JDK可能会因为模块化限制碰到反射访问异常的坑;第二,团队部分同事对Spring Boot 3的Jakarta EE命名空间不熟,出问题排查成本高。如果你是新项目、团队水平也够,直接用Spring Boot 3和JDK 17完全没问题,但生产环境求稳的话,2.7.x依然是性价比很高的选择。
2.2 MySQL、Redis、Elasticsearch的边界
数据存储我们用了三个,各自分工清晰:
- MySQL:核心业务数据,包括用户、订单、服务人员、结算记录,用InnoDB引擎,数据一致性优先。
- Redis:服务人员位置缓存、实时在线状态、热点数据、分布式锁、接口幂等标记、延迟队列。
- Elasticsearch:服务项目的搜索、地理位置过滤、订单搜索。
Elasticsearch不是一开始就上的,是订单量起来之后才引入。前期用户搜服务项目直接刷MySQL的LIKE查询,数据量几百条毫无压力。等服务人员到了几百人、订单到了几万条,再把订单和服务人员的位置数据同步到ES,用来做范围检索。
2.3 其他基础设施怎么搭
消息队列选型上,我们用的是RabbitMQ。虽然市面上Kafka的热度更高,但在这个场景下没有海量日志和极高吞吐需求,RabbitMQ的路由灵活、延迟消息插件好用,团队也熟。如果没有MQ经验,初期可以先用Redis的List或Stream顶一顶,但订单超时这种需要延迟消息的场景,用MQ会舒服很多。
对外HTTP调用统一走OpenFeign,内部接口权限校验用Spring Security + JWT。部署用Docker Compose,服务端代码打成镜像后放到一台4核8G的云服务器上,MySQL和Redis也用Docker管理。没有上Kubernetes,因为业务体量还没到那个规模,硬上K8s只会增加运维复杂度。
3. 数据模型设计:把一次上门服务拆成一张张表
项目干到一半,我才意识到,同城上门服务的核心不是接口多花哨,而是表结构能不能支撑各种奇怪的业务规则。设计表时我们花了很大精力,踩过的坑也不少。
3.1 用户端、服务端、订单、结算四类核心表
先放一个我们核心表结构的简化版本:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| t_user | 用户端账号 | id, phone, nickname, avatar, status |
| t_address | 用户收货/服务地址 | id, user_id, province, city, district, detail, lat, lng |
| t_service_item | 服务项目 | id, name, category, base_price, unit, description |
| t_service_provider | 服务人员 | id, user_id, real_name, phone, avatar, id_card_no, status, avg_score |
| t_provider_skill | 服务人员技能绑定 | id, provider_id, service_item_id, level |
| t_order | 订单主表 | id, order_no, user_id, provider_id, service_item_id, address_id, plan_start_time, plan_duration, unit_price, total_amount, status, create_time |
| t_order_status_log | 订单状态流水 | id, order_id, from_status, to_status, operator, remark, create_time |
| t_settlement | 结算记录 | id, order_id, provider_id, user_pay_amount, platform_commission, provider_income, status |
订单状态流水表特别重要,它记录了每次状态变更的操作人和时间。后期跟用户或服务人员扯皮时,这张表就是最权威的证据。所有状态流转操作都要在同一个本地事务里写入主表和流水表。
3.2 地址与服务项目的建模容易踩的坑
地址表不能只存文本描述,必须存经纬度。同城服务里“距离”是刚需,我们一开始只存文字地址,导致后来做“附近服务人员”功能时只能用城市名称模糊匹配,毫无意义。
服务项目不要把做饭、保洁这种大类直接挂在维表里,还要有子类。比如“做饭”下面可能分“家常菜午餐”“月子餐”“素食餐”,价格不同、所需技能不同。我们用了t_service_item加parent_id的方式,形成两级分类树,查询时用parent_id查子类列表。
服务人员的技能绑定要单独建表,一个阿姨可以同时会做饭和保洁,每项技能还能设等级。订单创建时要校验服务人员是否具备对应技能,否则用户下单后没人能接,体验很差。
3.3 价格试算:做饭两小时到底怎么收费
价格模型稍微特殊。我们不能像电商那样一口价,因为做饭服务可能按小时计价、也可能按次计价。我们参考了附近家政公司的规则:
- 按小时:例如日常保洁50元/小时,2小时起约
- 按次:例如4菜1汤家庭餐128元/次,不含买菜
订单表里冗余了unit_price、total_amount、plan_duration三个字段,下单时后端根据服务项目计算总价,不能只信任前端传过来的金额。价格计算的核心逻辑统一放在Java服务端一个PriceCalculator类里,因为后续满减、优惠券都会加在这个类上。
大致的计算公式:
java复制public BigDecimal calculateTotalAmount(ServiceItem item, int duration) {
if (item.getBillingType() == BillingType.HOURLY) {
return item.getBasePrice().multiply(BigDecimal.valueOf(duration));
}
// 按次收费,时长仅作参考
return item.getBasePrice();
}
为什么坚决用BigDecimal而不用double,后面专门讲。
4. “一键下单”的后端实现:从接口到状态机
用户端所谓“一键畅享”,其实背后是从创建订单、锁定服务人员到支付的一系列动作。这一节是重头戏,直接给你能落地的方案。
4.1 下单接口的字段校验与幂等处理
用户点“一键下单”的时候,前端会带过来:服务项目ID、地址ID、预约开始时间、服务时长、备注。后端除了常规@Valid参数校验,还有几个容易忽略的点:
- 校验预约时间不能早于当前时间,也不能晚于30天
- 校验地址属于当前用户
- 校验用户手机号已完成实名绑定
- 校验服务项目处于上架状态
最容易被坑的是重复点击。用户网络卡顿、手抖连点两次,可能生成两笔订单。解决办法是:前端生成一个请求唯一编号requestId,后端在Redis里用SETNX写入requestId作为幂等键,已经存在就直接返回第一次创建的订单号。
java复制@PostMapping("/api/order/create")
public Result<Long> createOrder(@RequestBody @Valid OrderCreateRequest request) {
String idempotentKey = request.getRequestId();
Boolean first = redisTemplate.opsForValue()
.setIfAbsent("order:idempotent:" + idempotentKey, "1", Duration.ofMinutes(10));
if (Boolean.FALSE.equals(first)) {
throw new BizException("重复提交,请勿多次点击");
}
// 后续创建订单逻辑
}
这里有个细节:幂等键不要用用户ID拼时间戳,用户网络重试时前端重新生成时间戳,幂等就失效了。requestId要由前端一次性生成并随重试请求保持不变。
4.2 订单状态机:创建、待接单、服务中、完成、取消
订单状态机是我们整个项目里最容易乱的地方。我先给最终版本:
| 当前状态 | 可流转到状态 | 触发动作 |
|---|---|---|
| 待接单 | 已接单 / 已取消 | 服务人员抢单 / 用户取消或超时系统取消 |
| 已接单 | 服务中 / 已取消 | 服务人员开始服务 / 用户与服务人员协商取消 |
| 服务中 | 已完成 | 服务人员结束服务,用户确认 |
| 已完成 | 已评价 | 用户提交评价 |
| 已取消 | 无 | 终态 |
这里哪些状态允许哪个角色操作,需要在后端接口里做权限校验。比如用户不能直接把“服务中”改成“已完成”,必须由服务人员点击结束服务,或者由客服在后台兜底。
状态流转有一个重要原则:所有更新必须带状态条件,防止并发下状态被覆盖。比如接单操作:
sql复制UPDATE t_order
SET provider_id = ?, status = 'ACCEPTED'
WHERE id = ? AND status = 'PENDING';
如果更新影响行数为0,说明订单已经被别人抢走或取消了,需要重新加载订单判断业务。
4.3 超时未接单自动关闭:定时任务还是延迟队列
用户下单后如果15分钟没有服务人员接单,订单要自动取消并通知用户。“自动”这个动作最早我用Spring的@Scheduled每分钟扫一次表,后来订单量增大后很浪费数据库资源,而且不精确。
改进后的方案是RabbitMQ延迟消息:订单创建成功后,发送一条延迟15分钟的MQ消息,消费者收到后去检查订单状态,如果还是待接单就执行关闭。这个方案的好处是准实时,且不会把数据库当轮询缓冲区。
如果你不想上MQ,也可以用Redis的过期key + 过期回调,但Redis的过期事件并不可靠,可能丢失。或者用Redisson的DelayedQueue,单机场景够用但宕机有风险。生产环境我更推荐RabbitMQ延迟消息或RocketMQ的延时消息。
5. 高并发下的抢单、支付与数据一致性问题
等单量大起来后,你会发现最痛苦的不是功能多,而是并发和钱对不上。这一节专门说我们遇到的问题和落地方案。
5.1 抢单:乐观锁、MySQL行锁还是Redis原子操作
用户下单后进入“待接单”,附近的阿姨在手机上刷新订单列表,看到订单就会抢。抢单本质是:把订单的provider_id从NULL改成当前用户ID,同时状态从PENDING改成ACCEPTED,而且只能一个人成功。
最简单的实现就是前面那条UPDATE SQL,它依赖MySQL行锁保证同一时间只有一个事务能成功修改同一行。我们一开始也是这么干的,后来订单多了发现问题:高并发下大量连接同时更新同一行,行锁等待严重,抢单接口RT飙到几秒,用户体验极差。
优化方案分两种:
- 如果业务允许“最先抢到”,就继续用UPDATE + 行锁,但要把抢单请求先丢进MQ或Redis串行化,削峰
- 如果允许随机或按评分派单,就不要让用户并发抢,改成系统派单
我们最终采用的是:服务人员端点抢单按钮时,先走Redis的Lua脚本原子性校验并占用名额,成功后再发MQ异步落库。但这样做复杂度高,如果团队没有Redis Lua经验,初期直接用UPDATE也是可以的,并发没那么高时完全没问题。
5.2 支付回调:如何保证订单状态只变一次
支付环节我们接了微信支付和支付宝,回调接口必须做幂等。攻击者有可能伪造回调,或者第三方支付重试导致同一笔支付结果回调多次,如果每次都执行“把订单改成已支付”,轻则重复发券,重则重复分账。
套路是这样:
java复制public void handlePayCallback(PayNotify notify) {
String outTradeNo = notify.getOutTradeNo();
// Redis原子锁,防止并发回调
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent("pay:lock:" + outTradeNo, "1", Duration.ofSeconds(30));
if (Boolean.FALSE.equals(locked)) {
return;
}
Order order = orderMapper.selectByOrderNo(outTradeNo);
if (order.getStatus() == OrderStatus.WAIT_PAY) {
order.setStatus(OrderStatus.PAID);
orderMapper.updateStatusWithOldStatus(order, OrderStatus.WAIT_PAY);
// 发通知、结算预占等
}
}
关键点是支付前的查询订单状态和更新订单状态必须用带条件更新,而不是先查询判断再直接更新,否则并发回调可能两次都读到WAIT_PAY,然后两次都执行更新。
5.3 服务人员算钱时为什么不能用double
这个坑几乎是Java新人必踩。金额计算如果用double或者float,0.1 + 0.2可能等于0.30000000000000004。等真正算服务人员佣金、平台抽成的时候,一分钱对不上就会引发纠纷。
正确做法是:
- 存储一律用DECIMAL(10, 2)
- Java代码用BigDecimal接收,计算用BigDecimal的add、multiply、subtract
- 特别注意new BigDecimal("0.1")和new BigDecimal(0.1)的差异,后者会得到一个很长的小数
每次计算完,设置统一精度策略,比如ROUND_HALF_UP保留2位。平台抽佣比例、服务人员结算金额,这些规则都只允许在服务端计算,前端展示结果只能用于展示。
6. 实测中遇到的高频问题与排障思路
最后这部分,我挑几个真实环境里最典型的问题,不是理论推演,都是线上实际踩过的。
6.1 用户定位偏了导致派单距离失真
上线第三周,有用户投诉服务人员迟到,说是“显示离我只有1.2公里,结果走了40分钟”。排查发现,App端传给后端的是GPS经纬度,但服务人员列表用的是用户下单时的起点坐标去过滤附近人员,两个坐标又都是真实坐标,为什么距离差这么多?
因为我们没有做逆地理编码到结构化地址的偏移修正。用户实际定位可能在马路上、立交桥边,和实际下单地址存在几十米偏差,如果两个点横跨一条河,导航可能绕一大圈。解决办法是用户在订单地址页选好详细地址后,后端把详细地址通过地图API转换成POI坐标,过滤附近服务人员时使用这个POI坐标,而不是实时GPS坐标。
6.2 服务人员并发接单导致的重复订单
有一次运营活动带来大量订单,阿姨端抢单接口突然出现同一笔订单被两个阿姨抢到的情况。检查发现,有一个接口没有走带状态条件的UPDATE,而是“先查订单状态,再直接更新”,两个请求同时读到PENDING,然后先后更新成功。
后来我们在所有写订单状态的地方统一改成条件更新,并且把订单状态更新封装成一个独立的service方法,字段合法性、状态条件、流水记录都在里面完成,再也不在Controller里直接操作表。这个问题的本质不是MySQL的问题,而是代码里出现了“先读后写”的竞态条件,Java多线程并发知识点在这里真的用上了。
6.3 线上接口突然变慢,我怎么一步步排查
某个周五下午,用户反馈“一键下单”特别卡。我登服务器排查,发现接口RT从之前200ms涨到2秒。
第一步用top命令看了CPU和内存,发现CPU不高、内存正常,排除硬件资源不足。第二步看数据库慢日志,发现有大量SELECT * FROM t_order WHERE user_id = ? ORDER BY create_time DESC查询扫描行数几十万。原来运营后台做了一个“用户订单列表”页面,直接查订单主表,没有走分页插件索引,导致MySQL的临时文件排序拖垮了数据库。
解决方案很简单:给t_order加联合索引(user_id, create_time),同时给运营后台查询用单独从库或加缓存。这个排查过程并不神秘,核心思路是:接口慢先看资源,再看慢SQL,再看外部依赖耗时,用一个链路逐一排除。
写到最后,我想分享一点实际体会
同城上门服务这个项目让我最大的收获,不是我把Spring Boot、Redis、RabbitMQ用得多熟练,而是逼着我去理解“服务”和“商品”在系统设计上的差异。Java生态足够成熟,能帮你稳妥解决业务问题,但真正决定项目成败的往往是那些八股文里反复讲的基础知识:并发、事务、状态机、幂等。你在这里踩过的每一个坑,都会变成下一次做系统时本能的条件反射。
