
这两年自助洗车机在一二线城市基本铺满了小区门口、加油站旁边、停车场角落经常能看到一个搭着雨棚的洗车位贴着二维码24小时没人值守。作为一个长期做Java后端的人我从第一次扫这种二维码洗车就开始琢磨这背后到底是个什么样的系统扫码之后发生了什么钱是怎么扣的设备又是怎么知道该出水的这套“Java赋能自助洗车24小时扫码共享源码”梳理下来其实就是一个非常典型的物联网支付并发控制的综合项目。它不是那种炫技的高并发秒杀系统也不涉及大模型、微服务这些重概念但它把Java后端最常见的几块硬功夫全占了微信扫码授权、TCP长连接设备通信、状态机设计、订单计费、分布式锁、幂等处理。说实话把这套系统的源码吃透比刷一百道Java面试题都管用。这篇文章我会把整套系统的业务设计、技术选型、核心源码思路、计费状态机和线上踩坑全部拆开讲适合正在做Java开发、准备面试、或者打算自己搞一套共享设备管理平台的朋友参考。1. 项目全景自助洗车系统到底在解决什么问题1.1 三个角色与一条业务主线很多人第一次接触自助洗车以为就是一个简单的“扫码付钱出水”。真正理下来你会发现一套可落地的系统里至少有三种角色用户车主扫码、登录授权、选择设备、支付/充值、洗车、查看订单。平台运营方设备管理、价格策略、订单对账、异常退款、站点监控。设备端洗车机控制器接收后端指令上报心跳和状态离线时记录本地流水。这三者凑在一起就构成了一条完整的主线业务流用户扫码 → 微信授权登录 → 绑定站点设备 → 预扣余额/支付 → 下发开机指令 → 设备启动 → 按时间计费 → 用户结束/超时结束 → 订单结算 → 流水对账这条链路里最关键的一步不是“扫码”而是扫码之后的那个瞬间后端怎么知道是哪个用户、在哪个设备、钱够不够、以及设备到底能不能启动。所有技术难点基本都集中在这几个判断上。我用“共享”这个词来定义它是因为它的商业模式和共享单车、共享充电宝完全一致把一台设备的闲置时间切片卖给不同用户。所以这套系统天然要面对三件事——并发抢设备、按时计费、异常兜底。后面所有技术选型都是围着这三件事转的。1.2 技术选型为什么是Java Spring Boot技术选型上我没有刻意追新用的是一套在中小型物联网项目中非常稳的组合模块选型理由应用框架Spring Boot 2.7.x生态成熟事务管理、自动配置完善招人也好招ORMMyBatis-Plus单表CRUD不用写SQL复杂统计走XML灵活度和效率都有数据库MySQL 8.0订单、用户、设备档案这些结构化数据事务可靠缓存/分布式锁Redis 6.x Redisson设备状态缓存、扫码状态失效、排队、并发扣费全靠它设备通信Netty 4.xTCP长连接设备端需要服务端主动下发指令HTTP轮询延迟太高、浪费流量支付微信支付API v3自助洗车场景用户基本都用微信H5/扫码支付体验顺部署Linux Docker Nginx单机起步后续按站点横向扩展有人可能会问为什么设备通信不直接上MQTT这个问题我纠结过。MQTT在工业物联网里确实是标准但自助洗车机这种场景有个特点设备数量不算大一个城市几百台而且要求指令实时到达和双向确认。用Netty写TCP协议虽然要多写一点编解码的代码但链路可控性最强出问题了好排查。等到设备规模做到几千上万台再迁移MQTT那是后话前期没必要背这个复杂度。还有一个现实原因这套系统的技术栈和市面上的Java岗位需求高度重叠。你做完了相当于把Spring Boot、MyBatis、Redis、Netty、微信支付API这五块全摸了一遍后面无论是写业务系统还是写接口平台底层逻辑都是通的。2. 核心链路拆解从扫码到洗车机转起来的完整流程2.1 扫码登录二维码背后的三种状态自助洗车的扫码和普通网站扫码登录有个区别用户扫的不是“登录码”而是贴在设备上的“设备码”。所以一次扫码要同时完成两件事确认用户身份和绑定目标设备。具体流程是这样的用户打开微信扫设备屏幕上的二维码二维码内容是一个带场景值的URL比如https://wash.example.com/s?deviceIdSH001siteId5。微信内置浏览器打开这个URL后端发现用户未登录发起微信网页授权OAuth2.0重定向到微信授权页。用户同意授权后微信回调后端接口携带临时code。后端拿code换openid和access_token同时在服务端创建登录会话返回一个token给前端H5。H5拿到token后调用后端接口GET /api/device/detail?deviceIdSH001展示设备状态和价格。用户点击“开始洗车”前端请求POST /api/wash/start后端创建订单并进入预扣款逻辑。这里有个很关键的细节二维码状态不能每次都查数据库否则一台设备被几百个人同时扫数据库就直接被打满了。我的做法是用Redis缓存设备二维码状态Key设计成device:scan:{deviceId}Value里存当前扫码用户的openid、扫码时间、会话token。状态分三种AVAILABLE空闲可以被扫。SCANNED有人扫了正在确认信息此时设备应该给一个“被占用”提示。OCCUPIED设备正在洗车中扫了之后展示“使用中”不能下单。Redis的Key一定要设置过期时间比如90秒没有完成登录和下单状态就回落到AVAILABLE防止有人扫了码不操作把设备占住。这里用Redis的SETEX一条命令就搞定了比走数据库事务舒服得多。2.2 计费引擎预付费余额与超时续费策略洗车机的计费规则看着简单实际上要处理的情况很多。最基本的规则是这样起步价比如10元包含10分钟的清水、泡沫、吸尘。超时续费超过10分钟后按分钟计费比如1元/分钟。会员折扣充值用户按等级打折。高峰时段晚上6点到10点上浮20%。计费引擎我建议单独抽一个类不要写在订单Service里。因为后面一定会遇到活动价、优惠券、站点差异定价如果计费逻辑散落在各个方法里改一处崩三处。核心计费的代码思路大概是public class WashFeeCalculator { private static final BigDecimal BASE_PRICE new BigDecimal(10.00); private static final BigDecimal EXTRA_MINUTE_PRICE new BigDecimal(1.00); private static final int BASE_MINUTES 10; /** * 计算一笔洗车订单的费用 * param durationMinutes 实际使用分钟数 * param userLevel 用户等级决定折扣 * param peakFlag 是否高峰时段 */ public WashFee calculate(int durationMinutes, int userLevel, boolean peakFlag) { // 不足1分钟按1分钟算防止用户1秒结束导致分账为0 int effectiveMinutes Math.max(durationMinutes, 1); BigDecimal baseAmount BASE_PRICE; BigDecimal extraAmount BigDecimal.ZERO; if (effectiveMinutes BASE_MINUTES) { int extraMinutes effectiveMinutes - BASE_MINUTES; extraAmount EXTRA_MINUTE_PRICE.multiply(BigDecimal.valueOf(extraMinutes)); } BigDecimal total baseAmount.add(extraAmount); // 会员折扣 BigDecimal discount UserLevel.getDiscountRate(userLevel); // 例如0.8 total total.multiply(discount).setScale(2, RoundingMode.HALF_UP); // 高峰上浮 if (peakFlag) { total total.multiply(new BigDecimal(1.20)).setScale(2, RoundingMode.HALF_UP); } return new WashFee(baseAmount, extraAmount, total); } }计费这件事上有一个原则所有的金额计算用BigDecimal禁止用double。洗车单笔金额虽然小但跑量起来之后double的精度误差会在对账汇总时暴露出来几分钱的差异在抽查时非常难看。我见过不止一个团队在这里翻车切记。另一个容易漏的地方是“最小计费单位”。设备上报的时长如果是秒后端必须向上取整到分钟。比如用户洗了10分01秒按11分钟收费不然用户只要卡着10分59秒结束平台一分钱超时费都收不到。这个逻辑还要考虑设备掉线后的离线时长对账后面我会单独说。2.3 设备状态机用Java对象管理一台洗车机的生命周期自助洗车机的背后是一台真实的水电设备后端任何指令下发之前都得先确认设备当前处于什么状态。如果用户点“开始”但设备还在上一单的收尾冲洗中这时候强行启动轻则设备逻辑错乱重则烧水泵。所以设备状态管理必须用一个严格的状态机不能用一堆if/else硬堆。我在源码里定义的状态有这么几个public enum WashDeviceState { OFFLINE, // 离线 IDLE, // 空闲可被扫码 SCANNED, // 已被扫码待确认启动 STARTING, // 启动中等待设备响应 WASHING, // 洗车中 PAUSED, // 暂停用户临时中断 FINISHED, // 本次服务结束待结算 MAINTENANCE // 维护中 }每个状态允许的迁移我建议写成一张迁移表而不是在代码里到处随便赋值当前状态允许动作目标状态IDLE用户扫码SCANNEDSCANNED用户确认启动STARTINGSTARTING设备上报启动成功WASHINGWASHING用户点击结束FINISHEDWASHING超时自动结束FINISHEDWASHING用户暂停PAUSEDPAUSED用户继续WASHINGANY心跳超时/设备上报异常OFFLINE/MAINTENANCE状态迁移在实际编码时我推荐用一个小巧的StateMachine组件不做那种重量级的状态机框架就用enum Mappublic class WashDevice { private WashDeviceState state; public synchronized void transition(WashDeviceEvent event) { WashDeviceState next StateTransitionTable.getNext(this.state, event); if (next null) { throw new IllegalStateException(设备状态不允许该操作: this.state - event); } // 迁移前检查设备在线情况、订单状态等 this.state next; // 落库或写Redis deviceStateRepository.sync(this.deviceId, next); } }这里有两个经验第一transition方法必须加synchronized或者走分布式锁。因为同一个设备可能同时收到用户端的HTTP请求和设备的TCP上报不加锁的话状态会被两个线程乱改。第二状态的持久化要双写Redis里放热数据保证查询快MySQL里落一条设备状态变更流水方便后面排查故障。真正重要的不是当前状态而是“从哪一步变到哪一步”的轨迹。3. 关键代码实现与源码导读一套可以直接复用的骨架3.1 扫码登录与轮询接口实现扫码登录的Controller层我贴一段骨架代码方便有基础的同学直接理解链路RestController RequestMapping(/api/wash) public class WashOrderController { Resource private DeviceService deviceService; Resource private OrderService orderService; Resource private RedisTemplateString, String redisTemplate; /** * 用户确认开始洗车 */ PostMapping(/start) public ResultWashOrderVO start(RequestBody StartWashRequest req, RequestHeader(token) String token) { // 1. 解析当前用户 Long userId userService.getUserIdByToken(token); // 2. 检查设备是否空闲Redis状态 WashDeviceState state deviceService.getState(req.getDeviceId()); if (state ! WashDeviceState.IDLE) { return Result.error(4001, 设备当前不可用); } // 3. 创建订单状态为待启动 WashOrder order orderService.createPendingOrder(userId, req.getDeviceId()); // 4. 尝试占用设备原子操作防止并发抢单 boolean locked deviceService.tryOccupyDevice(req.getDeviceId(), order.getOrderId()); if (!locked) { return Result.error(4002, 手慢了设备刚被别人抢先一步); } // 5. 下发启动指令给设备 deviceService.sendStartCommand(req.getDeviceId(), order.getOrderId()); // 6. 返回订单号和预计金额 return Result.success(WashOrderVO.of(order)); } }这段代码里最容易出问题的就是第4步“尝试占用设备”。并发场景下两台车同时扫码后端的两个线程可能同时通过了第2步检查然后一起去创建订单、下发指令。这时候必须保证“占用设备”是原子的。我实现原子占用的方式是用Redis的SETNXpublic boolean tryOccupyDevice(String deviceId, String orderId) { String key device:occupy: deviceId; Boolean result redisTemplate.opsForValue() .setIfAbsent(key, orderId, Duration.ofMinutes(30)); return Boolean.TRUE.equals(result); }先到先得。同一个设备第二个请求的SETNX会返回false直接提示“被别人抢先一步”。这个方案比数据库的SELECT ... FOR UPDATE快得多而且不会长时间锁行。3.2 设备指令下发与心跳保活设备端的通信我用的Netty设备主动建立TCP长连接连接建立后上报设备状态之后每30秒发一个心跳包。后端收到心跳包就更新device:online:{deviceId}的过期时间设为3个心跳周期也就是90秒。超过90秒没收到心跳设备判定离线正在进行的订单要进入异常处理流程。新连接到服务端的设备Netty通道和业务设备ID要做绑定。我用了一个ConcurrentHashMapString, ChannelKey 是 deviceIdValue 是Netty的ChannelComponent public class DeviceChannelRegistry { private final ConcurrentHashMapString, Channel channels new ConcurrentHashMap(); public void register(String deviceId, Channel channel) { channels.put(deviceId, channel); } public void unregister(String deviceId) { channels.remove(deviceId); } public Channel get(String deviceId) { return channels.get(deviceId); } public boolean isOnline(String deviceId) { Channel channel channels.get(deviceId); return channel ! null channel.isActive(); } }这里用ConcurrentHashMap是标准操作因为设备连接、断开、心跳是在Netty的IO线程里并发发生的用普通HashMap百分百出问题。热词里那句“java容器”指的就是这类实战选择——不背容器理论没关系但你在并发场景下得知道用什么。下发指令时走的也是这个注册表public void sendCommand(String deviceId, DeviceCommand command) { Channel channel deviceChannelRegistry.get(deviceId); if (channel null || !channel.isActive()) { // 设备离线指令存待补偿表等设备恢复后补发 offlineCommandService.save(deviceId, command); return; } channel.writeAndFlush(command); }发送指令时有一个很隐蔽的坑Netty的writeAndFlush是异步操作它只保证“写入成功”不保证“设备收到了”。要想确认指令真正到达需要设备回一条ACK。如果5秒内没收到ACK就要做重试。重试超过3次订单要标记为“启动失败”并且把钱退回用户。这个补偿环节是设备类项目的生命线没有它用户付款了设备却没启动投诉率直接拉满。3.3 订单扣费与异常补偿订单从“待启动”到“进行中”再到“已完成”中间每一步都涉及数据库事务。我不建议把整个洗车流程塞进一个大事务里正确的做法是订单状态机 分布式锁 幂等表。扣费的核心逻辑是这样Transactional(rollbackFor Exception.class) public void settleOrder(WashOrder order, int actualSeconds) { // 1. 防重复结算订单已经是终态直接返回 if (order.getStatus() OrderStatus.FINISHED || order.getStatus() OrderStatus.CLOSED) { return; } // 2. 计算最终费用 WashFee fee feeCalculator.calculate(actualSeconds, order.getUserLevel(), order.getPeakFlag()); // 3. 更新订单金额与状态 order.setTotalAmount(fee.getTotal()); order.setStatus(OrderStatus.FINISHED); orderService.updateById(order); // 4. 扣减余额/生成支付流水 boolean deductSuccess accountService.deductBalance(order.getUserId(), fee.getTotal()); if (!deductSuccess) { throw new InsufficientBalanceException(余额不足以支付需要补缴); } // 5. 写会计流水对账用 accountFlowService.record(order.getOrderId(), WASH, fee.getTotal()); }这里第4步“扣减余额”单独划出来说。用户可能在充值后、扫码前这段时间里账号余额有变化也可能同一时间有多个进行中的订单比如开了两分钟又去开另一台所以在扣费时必须对用户账户加锁。最稳妥的做法是用Redis分布式锁RLock lock redissonClient.getLock(account:lock: userId); lock.lock(5, TimeUnit.SECONDS); try { // 查询余额、校验、扣减 } finally { lock.unlock(); }这个锁锁的是用户账户不是设备粒度恰到好处。锁的粒度如果放大到全局所有用户的扣费都串行化性能撑不住如果按订单粒度锁又防不住同一用户的两单并发扣费。4. 高并发与线上稳定性共享场景下绕不开的几道坎4.1 高峰期抢设备排队要比崩溃体面自助洗车的活跃时间非常集中早上7-9点上班前、晚上6-10点回家后。一个站点只有四五个车位高峰期同时有几十个人在等。这时候如果让所有人同时去点“开始洗车”后端会收到大量无效的并发请求设备状态反复被扫描MySQL的锁竞争也会很激烈。我的处理方案是“扫码即排队”。用户扫码后不进设备详情页先去排队接口GetMapping(/api/wash/queue/status) public ResultQueueStatus queueStatus(RequestParam Long siteId, RequestParam Long deviceId) { // 设备空闲直接返回可进入 WashDeviceState state deviceService.getState(deviceId); if (state WashDeviceState.IDLE) { return Result.success(QueueStatus.idle()); } // 设备忙查当前队列位置 Long rank redisTemplate.opsForZSet() .reverseRank(queue:site: siteId, deviceId.toString()); return Result.success(QueueStatus.waiting(rank)); }排队的存储用Redis的ZSet分数填用户开始排队的时间戳。排到队首后给用户推送一条模板消息告诉他“设备空闲请在3分钟内确认启动”。3分钟不确认自动放弃排队把位置让给下一位。这套逻辑和医院挂号、银行叫号一模一样用户体验上不会觉得被冷落后端压力也小很多。再说限流。登录、下单、支付这几个关键接口的QPS一定要限住不然某一天一个站点在抖音上爆了流量涌进来整个服务集群都能给你打挂。我用的Redis Lua脚本粗略做了一个令牌桶限流单机QPS限制在50左右超出的直接返回“系统繁忙”。4.2 并发扣费与防超卖分布式锁和幂等表一个都不能少共享场景里最严重的事故就是“一台设备同时被两个人启动”。造成这个问题的原因通常是设备状态先查后改中间没有加锁两个请求都读到IDLE然后都发了开机指令。物理世界里洗车机不可能同时给两台车喷水但系统里会留存两张开启中的订单后面退款、投诉、对账全是烂账。这类问题在电商叫“超卖”在设备系统里叫“重复占用”。解法也类似更新设备状态时条件里带上旧状态利用数据库行锁保证原子性UPDATE wash_device SET state OCCUPIED, current_order_id #{orderId} WHERE id #{deviceId} AND state IDLE;Affected rows 如果等于1说明更新成功设备被当前订单占用如果等于0说明设备已经不是空闲状态订单要做失败处理。这条SQL是我整个项目里最关键的原子操作比RedisSETNX还要可靠因为它的状态依据是数据库里唯一的真实记录。Redis的占用标记其实是第一道防线数据库条件更新是第二道双层都过了设备才能真正启动。扣费方面的幂等同样要设计好。微信支付回调、设备离线账单上传、定时对账任务这些都会触发同一笔订单的结算。如果结算逻辑没有幂等同一笔订单会被反复扣款。幂等的实现不用想太复杂订单状态加上乐观锁版本号更新时带上where status oldStatus再加上一张settle_record表记录每次结算流水。重复触发的结算请求到了之后发现订单已是终态直接忽略。4.3 慢SQL与连接池数据库侧的几条实战调优这套系统数据库的压力没有电商大但慢SQL依然会出现。最容易出问题的查询有这么几类查询某用户的历史订单user_id create_time联合索引必须有不然用户翻订单列表就是全表扫描。查询某设备当日订单device_id date(create_time)如果date()函数直接写在字段上索引会失效。正确姿势是查create_time between 2024-01-01 00:00:00 and 2024-01-01 23:59:59。设备状态上报频率高设备心跳表每天几十万条要按天做分区或者定期归档。连接池我用的是HikariCPSpring Boot默认的。几个参数按实际情况调整过spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 10 connection-timeout: 3000 idle-timeout: 600000maximum-pool-size不要拍脑袋给个200连接池过大反而会让数据库线程切换开销变大。单机4核8G的配置30个连接顶天了。客户端请求量大的时候前端加一层Redis缓存扛读流量后端连接池维持稳定数据库才能稳。5. 避坑指南与面试真题盘点5.1 我踩过的五个坑这套系统我前前后后调了两周把典型问题总结成一个表格每个都值得刻在工位上坑现象根因解决方案二维码状态漂移用户扫了几次有的显示可用有的显示占用扫码状态直接查MySQL没走Redis缓存多实例数据不一致扫码状态统一走Redis过期时间90秒支付回调重复扣款同一订单出现两条扣费流水回调处理逻辑没有幂等网络重试导致重复通知订单状态乐观锁 结算流水表去重设备端时间不准用户洗了12分钟账单显示15分钟计费用的是设备本地时间戳设备断电后时钟漂移计费时长以服务端首次收到启动ACK的时间为准TCP粘包/半包收到指令乱码偶发指令丢失没有处理Netty解码器消息边界不明确自定义协议加长度字段用LengthFieldBasedFrameDecoder空闲连接被断开设备老是显示离线但设备端网络正常运营商NAT超时时间短于心跳间隔心跳间隔从60秒调到25秒服务端不响应就重连这里面最值得展开的是TCP粘包。设备端的消息如果都是“状态上报”每个包很短粘包问题不明显。但一旦涉及下发大一点的升级配置包或者设备连续上报多帧状态粘包就来了。我在协议设计里每个消息包包头固定8个字节2字节魔数0x55AA、1字节版本、1字节命令、4字节长度。解码器用Netty自带的pipeline.addLast(new LengthFieldBasedFrameDecoder( 8192, // maxFrameLength 4, // lengthFieldOffset 4, // lengthFieldLength 0, // lengthAdjustment 0 // initialBytesToStrip ));这样Netty读数据时会自动按长度字段拆包半包会在缓冲区等下一段数据拼完整粘包会被拆成多条消息彻底告别乱码。这是每个做设备通信的Java开发都应该掌握的基本功。5.2 这套系统在面试里怎么考正因为这套系统覆盖的知识面广它非常适合用来当面试题素材。面试官问的时候通常会在下面这几个点里挑着问面向对象设计设备状态机怎么设计为什么状态迁移要收敛到一个方法里Java容器设备连接管理为什么用ConcurrentHashMap而不是HashMap并发场景下computeIfAbsent有什么坑Java基础金额计算为什么用BigDecimal不用doublehashCode和equals在订单对象里重写了吗并发编程设备状态更新如何保证不超卖synchronized、数据库乐观锁、Redis分布式锁各自的适用场景数据一致性支付回调重复通知怎么处理订单结算和余额扣减如何保证一个成功一个失败时能自动补偿MyBatisMapper接口的工作原理是什么一级缓存和二级缓存的区别在设备状态这种实时数据上为什么不能用缓存Redis缓存和数据库的一致性怎么保证排队场景用ZSet的分数排序有什么注意点排序原理如果让你自己实现排队队列你可以用什么排序算法在Redis ZSet面前你会发现手写排序意义不大但面试官会问归并排序和堆排序的区别尤其是堆排序在Top N场景的优势。这些问题的答案只要你亲手写过这套系统几乎都能脱口而出。因为你不是背的题你是真的遇到并解决过。面试官也听得出哪些是项目经验哪些是背诵痕迹。最后聊一个自己的感受扫码自助洗车系统麻雀虽小五脏俱全它把Java后端日常80%的技术点都串起来了。如果你正好在学Java或者正在准备面试我真的建议找一套这类源码静下心来拆一遍。先不看完整代码自己画一遍状态流转图写一遍扫码流程的生序逻辑再对照源码看差异这个过程比看任何教学视频都长功力。分布式锁、幂等设计、状态机这些概念光看文档会觉得自己懂了只有真正被“设备重复启动”“重复扣费”“TCP粘包”这类线上事故毒打一轮才算真正掌握。这也是我为什么愿意把这套系统的骨架和踩坑经验毫无保留写出来的原因希望你少走一点我走过的弯路。