1. 项目概述:SpringBoot智能停车计费系统到底该怎么做
停车计费系统是我这两年做过的“看起来简单,做起来全是坑”的典型项目。很多团队一开始以为它就是一个“入库时间减出库时间乘单价”,结果一接入真实业务,免费时长、封顶价、夜间包时段、月卡、优惠券、支付回调、硬件对接全压上来之后,才发现计算逻辑要比想象中复杂太多。这篇文章我会把自己在做SpringBoot智能停车计费系统时的整体思路、表结构设计、计费引擎算法、核心接口实现和线上排查经验完整走一遍,希望能给正在做同类系统的开发者一个可以直接落地的参考。
先说明我的技术选型背景:项目使用Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis,前端对外只提供REST接口,小程序端、管理后台和硬件道闸全部通过HTTP调用。整体项目并不复杂,真正的复杂度集中在“计费引擎”和“状态流转”这两块。如果你正打算从单体应用开始做停车收费,或者已经在线上跑着一套老旧的桌面收费软件想改造成服务化,这篇文章应该能帮你避开不少我自己踩过的坑。
1.1 停车计费系统到底在解决什么问题
停车计费系统的核心不是“计费”,而是“把车在停车场的整个生命周期管起来”。从车辆入场、车位分配、出场结算,到微信/支付宝支付、月卡校验、优惠券抵扣,再到最后的财务对账和报表统计,每一步都有自己的状态和约束。
我在做需求调研时,真正频繁被业务方提到的问题有四个:
- 人工收费漏单、错单,管理层无法知道每一个车位的真实周转率。
- 计费规则不灵活,比如“白天5元/小时,上限30元;夜间包时段10元;免费15分钟”这类规则,普通物业软件根本配不出来。
- 支付依赖现金或扫码个人二维码,资金无法进入企业账户。
- 硬件设备品牌多,摄像头、道闸、地锁各自对协议,数据割裂,运营数据难以汇总。
因此整个系统的定位是很明确的:作为停车业务的中台服务,上游接硬件设备,下游接小程序和支付渠道,自己负责核心的业务规则和数据存储。
1.2 需求边界圈定:不做无人值守,先做接口平台
这里有一个很关键的经验,就是不要把“无人值守”一口吃下去。无人值守涉及车牌识别、异常抬杆、无牌车入场、占位纠纷等大量边缘场景,在一开始就全部做,会把项目拖垮。
我当时的做法是“先做计费核心,再做设备对接”,系统只负责三件事:
- 判定车辆是否有入场资格,比如月卡是否有效、车位是否已满。
- 计算车辆从进入到出场这段时间生成的费用,并应用优惠。
- 生成结算订单,对接支付回调,完成财务流水闭环。
至于道闸的开关、摄像头的抓拍,我通过硬件厂商提供的HTTP回调接口接入。摄像头拍到车牌后,向系统发送入场消息;系统返回“可入场”,厂商设备再去开闸。这样就把硬件和业务解耦了,后续就算换设备,只要改一个适配层就行。
2. 技术选型思路:Spring Boot生态里的取舍
2.1 为什么用Spring Boot而不是其他框架
停车系统本质上是一个典型的业务系统,需要事务、定时任务、接口文档、缓存、消息通知这些基础设施。Spring Boot在这方面非常成熟,代码写起来也足够直接,不需要自己造轮子。
在实际项目中,让我真正觉得选Spring Boot省事的是这三块:事务管理、定时任务、对Redis和数据库连接池的自动配置。出场结算时,我需要同时更新停车流水、生成结算单、释放车位,这三步必须在一个事务里完成,Spring的@Transactional可以直接搞定。凌晨费率切换需要一个定时任务做批量处理,@Scheduled也很方便。Redis缓存费率规则和分布式锁,spring-boot-starter-data-redis一站式接入。
如果让我现在再选一次,我还会继续用Spring Boot,但会明确把版本锁好。项目用的是2.7,如果新项目,我建议直接上3.x,JDK 17起步,因为Spring Boot 2.7在2023年后已经停止社区维护了。
2.2 持久层选择:MyBatis-Plus还是Spring Data JPA
持久层我见过不少团队用两种不同方案争得不可开交。这个项目我选了MyBatis-Plus,原因非常简单:停车计费的查询报表大概率是动态条件多、SQL复杂,尤其是查“某时段某停车场每小时车流量”这种数据,JPA的派生查询写起来会非常别扭,而MyBatis的XML里直接写SQL,性能调优也方便。
这里可以带一点个人使用感受:
| 对比项 | MyBatis-Plus | Spring Data JPA |
|---|---|---|
| 单表CRUD效率 | 封装好,BaseMapper直接提供方法 | 很好,Repository接口声明即可 |
| 复杂SQL | XML灵活控制,可优化SQL执行计划 | 需要@Query或走Entity关联,复杂场景较重 |
| 动态查询 | QueryWrapper条件构造器很舒服 | Specification逻辑偏繁琐 |
| 团队上手成本 | 国内开发者普遍熟悉 | 需要理解Hibernate原理和N+1问题 |
| 事务回滚配合 | 天然支持 | 天然支持 |
实际情况是,项目里大概70%是单表读写,30%是报表统计。MyBatis-Plus在这两种场景下都能覆盖,所以最终选了它。有条件的团队甚至可以再配一个JPA做简单CRUD,但我觉得对停车系统来说没必要。
2.3 缓存、消息与支付对接的选型
停车系统不需要大数据,但需要处理高并发短时间的进出场请求。我用Redis做了三件基础的事:
- 缓存每个停车场的费率版本,费率配置一改,所有历史账单的重新计费都会受影响,所以一定要有版本号。
- 缓存车位剩余数量,避免每次请求都去MySQL执行count(*),高峰期停车场闸口同时进车,这个count会把数据库打爆。
- 作为分布式锁,防止同一辆车的并发出场请求导致重复扣费。
消息队列在这个项目里属于“可以暂时不用但必须留接口”的模块。停车服务的高峰期非常聚集,比如写字楼工作日的18:00到19:00,大量车辆同时出场,如果用Redis里的列表做轻量级异步对账推送,就能把支付渠道的调用峰值削平。
支付对接上我重点强调的是异步回调。出场时系统生成订单,用户在手机端点支付,支付渠道异步通知服务端。这里最常见的坑就是回调消息重复投递,所以我的回调接口从第一步就要做幂等校验,以“业务单据号+支付渠道交易号”作为唯一键避免重复操作。
3. 数据库设计:一张“流水表”撑起全部核心逻辑
3.1 表结构总览与核心字段说明
停车系统的数据库设计不建议拆得太散。我用到的核心表只有几张:
- 停车场表:记录车场信息、总车位、固定车位数、启用的收费规则。
- 车位表:记录具体车位编号、类型、当前是否被占用。
- 停车流水表:车辆进场立一条记录,出场后更新;这是全系统最核心的表。
- 计费规则表:一个停车场可配置多条策略,按时段、按次、按小时不同计费。
- 结算表:每次出场生成一条结算记录,含应付、实付、支付时间和渠道。
- 月卡表和优惠券表:针对固定用户和营销场景。
停车流水表我建议这样设计,核心字段如下:
CREATE TABLE vehicle_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parking_lot_id BIGINT NOT NULL COMMENT '停车场ID', plate_number VARCHAR(20) NOT NULL COMMENT '车牌号', entry_time DATETIME NOT NULL COMMENT '入场时间', exit_time DATETIME DEFAULT NULL COMMENT '出场时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0入场,1已结算,2异常', entry_image_url VARCHAR(255) COMMENT '入场抓拍图片', exit_image_url VARCHAR(255) COMMENT '出场抓拍图片', fee_amount DECIMAL(10,2) DEFAULT 0 COMMENT '应收金额', discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额', received_amount DECIMAL(10,2) DEFAULT 0 COMMENT '实收金额', pay_channel VARCHAR(20) COMMENT '支付渠道', trade_no VARCHAR(64) COMMENT '支付渠道交易号', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_plate_number (plate_number), KEY idx_entry_time (entry_time), KEY idx_status (status) ) COMMENT='停车流水表';设计这个表时有一个容易忽略的问题:不要只存“计费总额”,一定要同时保留“应收金额、优惠金额、实收金额”三个字段。因为后续财务对账需要还原这笔订单当时的完整上下文,比如用户用了优惠券,总要能明确说出抵扣了多少钱。如果你只存一个最终实付,对账的时候会很痛苦。
3.2 计费规则表的设计思路
计费规则表设计成通用结构,可以应付绝大多数业务:
CREATE TABLE charge_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parking_lot_id BIGINT NOT NULL, rule_name VARCHAR(50), fee_type TINYINT NOT NULL COMMENT '1按次,2按小时,3按时段', free_minutes INT DEFAULT 0 COMMENT '免费分钟数', hourly_rate DECIMAL(10,2) DEFAULT 0 COMMENT '每小时的金额', max_daily_fee DECIMAL(10,2) DEFAULT 0 COMMENT '单日封顶金额', start_minute INT DEFAULT 0 COMMENT '生效开始分钟,从0点开始算', end_minute INT DEFAULT 1439 COMMENT '生效结束分钟', is_active TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );这里的start_minute和end_minute是我觉得最实用的设计。比如夜间包时段是晚上20:00到第二天早上8:00,那就存1200和480,因为跨了零点,判断逻辑会特殊处理。用分钟数字比用字符串“20:00”更能方便比较,类似“当前时间距离当天0点的分钟数”为1230时,直接判断是否落在start_minute和end_minute区间内。如果规则跨天,就拆成两条,一条是1200到1439,另一条是0到480,这样逻辑最干净。
3.3 索引与事务隔离级别的考量
流水表是典型的大表,车辆出入频繁的停车场一天几千条记录很常见。索引不能乱加,每个索引都有代价。我给车牌的索引是必须保留的,因为查询“这辆车现在停在哪个场”最频繁。入场时间索引用于按天统计报表。状态索引用于后台筛选未结算流水。
事务隔离级别我没有用太高。MySQL默认的REPEATABLE READ已经够用。停车计费场景中计费操作读取的是入场时间和规则配置,这些数据在短暂事务里基本不会出现幻读问题,所以不用死磕隔离级别。真正需要关注的是数据库连接池的耗尽。高峰期间很多请求可能在执行一个很小的SQL前等连接,这时把maximum-pool-size从默认10调到30左右,我实测对并发提升明显。
另外要强调一个设计经验:车位扣减不要直接通过UPDATE parking_space SET status=1 WHERE park_no=? AND status=0这种方式,看似原子,但如果车辆进场的上游和真正写数据库的事务不是同一个,很容易把状态弄乱。我在项目里用version字段做乐观锁,UPDATE ... SET status=1, version=version+1 WHERE id=? AND version=?,更新行数小于1就说明冲突,直接给前端返回“请重新尝试”,避免重复入场。
4. 计费引擎:从“按时计费”到“跨天封顶”
计费引擎是这个系统里面最值钱的部分,没有之一。它需要处理的核心问题包括:按小时费如何向上取整、免费时长怎么扣除、封顶价怎么计算、跨天停车怎么分段、夜间包时段怎么叠加。如果这些规则只写在硬编码的if-else里,后面规则的任何变化都意味着发版。我做的时候把整个计费过程抽成了一个策略类,业务只传进入场时间和出场时间,内部自动选择合适的策略。
4.1 基础计费模型:按小时费率怎么算
最基础也最常见的计费方式是“按小时计费”。这里有个细节很容易出错,那就是不足1小时是否按1小时算。绝大多数停车场采用“不足1小时按1小时”计费。计算时如果用整数除法,很容易出现多算或者少算的问题。
我在项目里使用的是BigDecimal和RoundingMode.CEILING,向上取整到小时:
public BigDecimal calcByHour(LocalDateTime entryTime, LocalDateTime exitTime, BigDecimal hourlyRate, int freeMinutes) { long totalMinutes = Duration.between(entryTime, exitTime).toMinutes(); long chargedMinutes = Math.max(0, totalMinutes - freeMinutes); if (chargedMinutes == 0) { return BigDecimal.ZERO; } BigDecimal hours = BigDecimal.valueOf(chargedMinutes) .divide(BigDecimal.valueOf(60), 0, RoundingMode.CEILING); return hours.multiply(hourlyRate).setScale(2, RoundingMode.HALF_UP); }这里我会额外说明:所有金额字段都必须是BigDecimal。这一点不是教条,而是实际出错逼出来的。我曾经在初版代码里用了double存储“每分钟费用”,结果出现了“应收3.60元,实收3.59元”的对账差异,后来排查发现就是二进制浮点数精度导致的。
4.2 进阶规则:免费时长、封顶价、跨天分段
免费时长比较好处理,把入场到出场之间的总分钟减掉免费分钟数,再进入计费逻辑。封顶价就要小心了。单日封顶的语义是“24小时内最多收多少钱”,而不是“整个停车时长最多收多少钱”。出现跨天停车时,必须把停车时长按自然日拆开,然后每天的封顶费用单独计算后再累加。
举一个实际的例子:一辆车从昨天22:00进场,到今天10:00出场。费率是白天5元/小时,单日封顶30元。如果不拆日,计算方式是12小时*5=60元,这就错了。因为昨天22:00到24:00应该属于昨天的时段,通常夜间的计费规则可能不同,就算用同一规则,昨天和今天也该各算各的封顶。正确做法是先按日期把时间区间拆段,对每段分别调用当天内费用的计算,再把每天的封顶价应用上去。
下面这个是我的核心分段方法:
public List<DateSegment> splitByDay(LocalDateTime entryTime, LocalDateTime exitTime) { List<DateSegment> segments = new ArrayList<>(); LocalDateTime cursor = entryTime; while (cursor.isBefore(exitTime)) { LocalDateTime dayEnd = cursor.toLocalDate().atTime(23, 59, 59); if (!cursor.toLocalDate().equals(exitTime.toLocalDate())) { if (exitTime.isBefore(dayEnd)) { segments.add(new DateSegment(cursor, exitTime)); break; } else { segments.add(new DateSegment(cursor, dayEnd.plusNanos(1))); cursor = dayEnd.plusNanos(1); } } else { segments.add(new DateSegment(cursor, exitTime)); break; } } return segments; }在将每个Segment传入费用计算函数时,我把maxDailyFee限定到每个Segment内部。这样就不会出现“整个时段的封顶价被错误应用”的问题。这个代码看起来不复杂,但很多线上事故恰恰就出在这一个分支判断上。
4.3 并发计费与金额精度处理
停车计费系统在出场时面临的并发问题比想象中大。高峰期可能同一辆车在小程序端、管理后台、人工收银台同时发起出场请求。如果不做并发控制,第一次请求已经结算了,第二次请求又把流水重新算了一遍,导致“同一笔订单被收款两次”,这是重大生产事故。
我处理并发有以下三个措施:
- 数据库层面,出场结算统一使用“状态机更新”:
UPDATE vehicle_record SET status=1, exit_time=NOW() WHERE id=? AND status=0,如果更新行数为0,说明之前已经有人处理过了,直接返回已完成结果。 - 应用层面,增加Redis分布式锁,将车牌号作为锁key,设置5秒自动过期,防止极端情况下数据库更新之前同时进来两个请求。
- 接口层面,对前端小程序的支付提交按钮做防重,同一个结算单只能生成一次支付参数。
金额精度的处理逻辑我一直固定不变:单价乘以时长以后,统一使用BigDecimal.setScale(2, RoundingMode.HALF_UP)做分位四舍五入,涉及优惠时就按“先计算应收、再抵扣优惠、最后计算实收”的顺序,禁止先减后算,避免莫名其妙的精度漂移。
5. 核心接口实现:入场、出场与支付闭环
5.1 车辆入场流程与车位扣减
入场接口是整个系统最先被调用的接口。它的核心要保证“流水先建、车位同步扣、状态唯一”。接口签名很简单:
@PostMapping("/entry") public Result<String> entry(@RequestBody EntryParam param) { return Result.ok(parkingService.entry(param)); }EntryParam包含parkingLotId、plateNumber、entryImageUrl、source等字段。入场Service核心流程如下:
@Transactional(rollbackFor = Exception.class) public Long entry(EntryParam param) { // 1. 查询停车场信息,判断车位剩余 ParkingLot lot = parkingLotMapper.selectById(param.getParkingLotId()); if (lot.getAvailableSpace() <= 0) { throw new BusinessException("车位已满"); } // 2. 月卡优先校验,如果月卡有效则直接标记为月卡入场 MemberCard card = memberCardMapper.findValidCard(param.getPlateNumber()); // 3. 创建停车流水 VehicleRecord record = new VehicleRecord(); record.setParkingLotId(param.getParkingLotId()); record.setPlateNumber(param.getPlateNumber()); record.setEntryTime(LocalDateTime.now()); record.setStatus(0); record.setEntryImageUrl(param.getEntryImageUrl()); vehicleRecordMapper.insert(record); // 4. 乐观锁扣减车位 int updated = parkingLotMapper.decreaseAvailableSpace(lot.getId(), lot.getVersion()); if (updated == 0) { throw new BusinessException("车位更新冲突,请重试"); } return record.getId(); }走进场流程时我遇到过一个小问题,就是月卡和普通车入场后,后台列表里要显示“本次是月卡或临时车”。这个我直接定义了一个carType字段,月卡传入2,临时车传入1。不要小看这个字段,后续的报表统计会用到它。
5.2 车辆出场结算与支付回调
出场接口比入场复杂得多。核心第一步是“计费”,第二步是“生成结算单”,第三步对于临时车要“生成支付二维码”。
出场接口如果车辆已经是月卡,直接结算0元并更新流水。临时车则走“生成结算单”逻辑:
@Transactional(rollbackFor = Exception.class) public SettlementVO settlement(Long recordId) { VehicleRecord record = vehicleRecordMapper.selectById(recordId); if (record == null || record.getStatus() != 0) { throw new BusinessException("订单状态异常"); } LocalDateTime now = LocalDateTime.now(); BigDecimal fee = chargeEngine.calculate(record.getEntryTime(), now, chargeRuleMapper.getActiveRule(record.getParkingLotId())); // 生成结算单 Settlement settlement = new Settlement(); settlement.setRecordId(recordId); settlement.setPlateNumber(record.getPlateNumber()); settlement.setTotalAmount(fee); settlement.setDiscountAmount(BigDecimal.ZERO); settlement.setPayAmount(fee.subtract(settlement.getDiscountAmount())); settlement.setStatus(0); settlementMapper.insert(settlement); // 更新流水状态为“待支付”,我这里用status=2表示已结算待支付 record.setStatus(2); vehicleRecordMapper.updateById(record); return new SettlementVO(settlement.getId(), settlement.getPayAmount()); }这里有个容易被坑的点:支付回调到达以后,一定要把“更新结算单状态”和“更新流水状态”和“释放车位”放到同一个事务里。回调同时更新三张表,任何一个失败都可能导致资金流水和实际状态不一致。
支付回调接口我做了严格的幂等控制:
@PostMapping("/pay/notify") public String payNotify(@RequestBody PayNotifyParam param) { // 通过 trade_no 查询是否已经处理过 Settlement old = settlementMapper.selectByTradeNo(param.getTradeNo()); if (old != null && old.getStatus() == 1) { return "success"; } // 处理支付回调 settlementMapper.markPaid(param.getTradeNo(), param.getAmount()); vehicleRecordMapper.markExited(param.getRecordId()); parkingLotMapper.increaseAvailableSpace(param.getParkingLotId()); return "success"; }不要小看这个“return success”,有些支付渠道会对业务方的返回结果做重试,如果你返回失败,它会重复推送几十次,所以幂等处理是回调接口的生命线。
5.3 月卡与优惠券场景的实现
月卡和优惠券可以把计费系统的复杂度再往上提一个台阶。
月卡的逻辑核心是“有效期重叠判断”。用户购买一张月卡,不是只存开始日期和结束日期,而是存“生效起始时间”和“生效截止时间”。入场时判断当前时间在不在有效期内,出场时直接走0元结算。
优惠券我分为两类:抵扣券和折扣券。抵扣券存固定的抵扣金额,比如5元;折扣券存折扣率,比如0.9。关键规则是“优惠金额不能超过应收金额”,如果应收3元,抵扣券面额5元,最终抵扣也只能3元。这些规则虽然听起来简单,但不提前约束好,运营配置时就会出现负数账单。
我的建议是把优惠计算直接放到Settlement生成服务里,先抛出按当前费率算出的应收金额,再叠加最优优惠策略。系统并不支持多张券同时叠加使用,因为财务模型会变得极难对账。这不是技术限制,是业务复杂度控制的取舍。
6. 上线过程中的排查清单与避坑心得
6.1 我在实际部署中踩过的坑
系统上线前,我自以为计费算法和接口逻辑都已经完整测试了,但真实环境总能在意想不到的地方暴露问题。
第一个坑是时区。测试环境用本机Windows开发,默认时区是东八区,一切正常。部署到云服务器后,服务器时区是UTC,所有LocalDateTime.now()虽然取的是服务器本地时间,但数据库的DATETIME如果依赖于MySQL的CURRENT_TIMESTAMP,就会出现时间偏差8小时。最后我在启动脚本里明确加了-Duser.timezone=Asia/Shanghai,并且在MySQL连接串里加了serverTimezone=Asia/Shanghai,才彻底解决。但从那以后,我要求项目里所有时间字段都用LocalDateTime,并且传入和传出一律在服务层转换,不允许数据库做隐式转换。
第二个坑是跨天封顶费。我前面已经提到过,不再重复展开。这里再说一遍,是因为这个坑我遇到过两次:一次没有考虑跨天,另一次考虑了跨天但把封顶价错误地应用到了总时长上。很多人以为“封顶30元”就是整段停车时长最多30元,这个理解在单日停车没问题,但一旦跨天,就会变成“两天停车只收了30元”的资损事故。
第三个坑是硬件设备回调乱序。摄像头识别到车牌后延迟几秒才回调入场,而后台扫描枪可能已经先扫描到同一辆车。两个请求同时进入,如果不做幂等,流水表就会产生两条入场记录,车位数越扣越少。解决方案是入场接口增加车牌号维度的小逻辑:如果该车牌在当前停车场存在状态为“入场中”的流水,直接拒绝重复入场,无论请求来源是哪里。
6.2 性能瓶颈与优化手段
停车系统的性能瓶颈通常集中在两个时段:早上8点到10点的入场高峰,以及晚上6点到8点的出场高峰。这时道闸或摄像头并发调用后台接口,高峰QPS可能一下冲到几百。
我最开始遇到的性能问题是车位余量查询。每次入场请求都执行一次SELECT COUNT(*) FROM parking_space WHERE status=0,车辆入场频繁时,这个SQL会把MySQL打满。后来我把“当前余量”放到Redis里,初始化时从数据库加载一次,之后入场和出场分别decr和incr。为防止Redis里数字和数据库不一致,我增加了一个定时任务,每10分钟用真实SQL做一次校准。
计费规则也放到了Redis缓存中。规则变更时,通过接口先写数据库,再刷新缓存。这里需要注意:如果计费算法读取旧缓存而规则已经更新,可能导致新入场车辆用新规则、旧入场车辆出场时用新规则,前端展示与后端扣费不一致。所以我给规则表增加version字段,车辆入场时把当时规则版本号存进流水表,出场时优先使用流水表里的版本号,确保同一账单前后计算口径一致。
我后来还接了一个轻量的本地消息队列做异步对账推送,结算完成以后把Settlement对象推给财务系统,避免出场高峰时段同步调用导致接口超时。这样做的代价是需要处理消息失败重发,但收益是高峰期核心接口的RT从280ms降到60ms左右。
6.3 常见问题速查表
| 问题 | 可能原因 | 建议处理方式 |
|---|---|---|
| 同一车牌重复入场 | 道闸回调重试、用户重复扫码 | 增加车牌+状态幂等校验 |
| 出场费用多收几毛 | 使用浮点数计算金额 | 全链路改用BigDecimal |
| 跨天停车费用异常 | 没有按自然日分段应用封顶价 | 先拆分时间段再逐段计算 |
| 支付回调重复处理 | 支付渠道重推消息 | 以trade_no作唯一键,先查后更 |
| 车位余量越减越少 | 入场多次扣减、出场未释放 | 通过流水唯一记录绑定车位扣减 |
| 报表统计慢 | 流水表数据量大,未走索引 | 按入场时间建索引,按天分区 |
| 凌晨车辆计费少算 | 时段规则跨零点处理混乱 | 用分钟数做跨天拆分 |
| 数据库连接池耗尽 | 默认最大连接数太小 | 合理调大连接池,并优化慢SQL |
| MySQL死锁 | 多个业务同时更新同一条流水 | 每个事务先固定更新顺序,避免交叉更新 |
如果让我重新做一遍这个项目,我会把计费规则引擎从业务代码里彻底抽出来,做成一个可配置的规则解析模块。上线的第一版代码里,我把“夜间包时段”和“单日封顶”都写在了Java策略类里,运营想调整费率,必须请开发改代码再发布,这样灵活性太差了。后来我把规则结构改成了数据库配置,效果立刻好了很多,但也正因为改动只是为了满足“配置化”,没有同时考虑“规则改动对历史账单的影响”,结果旧账单重新计费时又出现了不一致。
这也是我想最后给所有做同类系统的人一个建议:计费系统最重要的不是功能炫技,而是数据一致性和可追溯性。每一笔金额变化都要能解释清楚,每一个状态流转都要能查到操作记录,否则上线以后每个深夜的线上告警都会让你后悔当初少写的那几行状态判断。做停车计费系统,宁可慢一点,也要把规则引擎的边界、事务的粒度和幂等保障梳理清楚再动手。