恶劣天气下外卖配送的难点不只是骑手多跑几公里那么简单。对技术团队来说它涉及天气数据的实时接入、配送风险的分级计算、骑手接单范围的动态调整以及补贴金额的规则化发放。很多系统在晴好天气下表现正常一旦遇到暴雨或大风就会出现天气数据滞后、订单积压、骑手补贴算错、用户等太久等连锁问题。下面从一个可运行的后端原型出发拆解如何搭建一个“恶劣天气配送保障系统”完整覆盖天气同步、风险分级、调度和补贴计算四个核心环节。整个示例以 Spring Boot 3 MyBatis-Plus MySQL 8 为基础适合作为业务后端开发、架构设计和调度类项目的起点。1. 恶劣天气配送不只是一场“坚守”更是一套技术调度问题1.1 恶劣天气会影响订单链路中的哪些环节外卖订单在恶劣天气下并不是“配送慢一点”这么简单。从订单创建到骑手完成送达整个履约链路都会被天气放大。首先是出餐环节恶劣天气会导致门店订单集中出餐时间变长其次是取餐环节骑手到店等待耗时增加然后是配送环节路面湿滑、能见度下降、骑行速度变慢实际配送时长会明显超过平台预估最后是用户等待环节等待时间越长催单、取消、投诉的概率越高。这些环节叠加在一起对后端系统的直接影响是预计送达时间失真、骑手运力分配不合理、补贴结算不清晰。如果只用固定配送时长或固定配送半径系统在暴雨天几乎必然失效。因此技术上的核心任务不是“让骑手跑得更快”而是把天气因素纳入调度判断提前调整各环节参数。1.2 系统的核心目标不是“取消订单”而是“动态调整履约参数”有人在设计此类系统时第一反应是“天气太差就关闭下单入口”。这种思路过于粗暴。对平台来说恶劣天气仍然存在配送需求用户也有实际需要对骑手来说也有一部分人愿意在安全风险可控的情况下继续接单。所以更合理的策略是做动态调节天气轻度恶劣时缩短配送半径、控制接单上限、提高补贴天气中度恶劣时只能配送近距离订单强制休息提醒天气重度恶劣时暂停特定区域的配送或转由线下协调。这里的核心是“风险等级”这个中间概念。系统不能直接拿到“恶劣天气”这种模糊描述而是要把天气数据转换成可计算的分数再映射到配送规则。这样调度、补贴、通知和客服都能共用同一套判断结果。1.3 整体流程从天气感知到资金结算这套系统的最小闭环可以拆成五个步骤定时任务调用第三方天气 API获取各区域的实时天气数据把天气数据写入weather_snapshot表作为快照和追溯依据风险服务根据温度、风速、降雨、能见度等指标计算风险分数调度模块根据风险等级调整骑手接单范围和接单上限补贴模块在订单完成后按照风险等级和规则版本计算补贴金额。每一步都可以独立测试。下面从环境准备开始逐步实现这个闭环。2. 环境准备与技术选型先把依赖和环境对齐2.1 技术栈与版本以下版本是当前示例使用的组合。实际项目落地前要以对应框架的官方稳定版本为准。组件版本用途Java17运行时环境Spring Boot3.2.xWeb 容器、定时任务、配置管理Maven3.9依赖管理MySQL8.0业务数据持久化Redis7.x分布式锁、缓存、幂等控制本示例可选用MyBatis-Plus3.5.xORM简化数据访问天气 API和风天气或高德天气获取温度、风速、降雨、能见度等数据定时任务Spring Scheduled周期同步天气数据使用 Spring Boot 3 需要 JDK 17 以上。如果项目还停留在 JDK 8建议先用 Spring Boot 2.7 版本代码结构可以保持一致。2.2 创建 Spring Boot 工程并引入依赖先创建一个基础的 Maven Spring Boot 工程核心依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent properties java.version17/java.version mybatis-plus.version3.5.7/mybatis-plus.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version${mybatis-plus.version}/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies如果后续要使用 Redis 做分布式锁或幂等控制还需要加入spring-boot-starter-data-redis。在最小原型阶段可以先不引入 Redis用数据库唯一索引处理补贴幂等问题。2.3 配置 application.yml在src/main/resources/application.yml中配置数据源和第三方天气服务server: port: 8080 spring: application: name: delivery-weather-guard datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/delivery_guard?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root jackson: time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 weather: api: base-url: https://example-weather-api.com key: ${WEATHER_API_KEY} regions: - code: shanghai-pudong name: 上海浦东 - code: beijing-chaoyang name: 北京朝阳这里把天气 API Key 放到环境变量WEATHER_API_KEY中避免直接写入代码库。示例中的地址要替换成实际服务商的访问地址。2.4 准备天气 API Key 和区域编码天气 API 的选择需要结合业务覆盖范围和数据维度。常见接口会返回天气现象、温度、体感温度、风速、风向、相对湿度、能见度等字段。注册服务商后先确认两个问题是否支持按城市或经纬度查询实时天气免费额度是否满足定时轮询的频率。本示例会模拟一个外部返回结构所以只要在配置中留好 key 和 base-url 即可。如果服务商返回结构不同只需要修改WeatherClient中的 JSON 解析部分不影响上层调度逻辑。3. 数据库设计用天气快照和风险配置驱动调度规则3.1 表结构设计思路业务规则经常调整所以风险等级、接单上限、补贴金额不能硬编码在 Java 代码里。较好的做法是让“风险等级配置表”成为规则中枢天气数据仅计算风险分数调度模块从配置中读取具体数值。同时天气数据必须存快照。假设用户质疑某笔补贴为什么是 8 元而不是 5 元系统需要能查到当天那个区域当时的天气数据、风险等级、规则版本。如果只存最终补贴事后很难解释清楚。3.2 核心建表 SQL以下 SQL 是一个最小可用结构按业务需要可以继续扩展。实际项目要注意字符集、排序规则和索引设计。CREATE DATABASE IF NOT EXISTS delivery_guard DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE delivery_guard; CREATE TABLE delivery_region ( region_id BIGINT PRIMARY KEY AUTO_INCREMENT, region_code VARCHAR(64) NOT NULL UNIQUE, region_name VARCHAR(128) NOT NULL, center_lng DECIMAL(10, 6) NOT NULL, center_lat DECIMAL(10, 6) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT 配送区域表; CREATE TABLE weather_snapshot ( weather_id BIGINT PRIMARY KEY AUTO_INCREMENT, region_code VARCHAR(64) NOT NULL, weather_text VARCHAR(64) NOT NULL COMMENT 天气现象如暴雨、大风, temperature DECIMAL(5, 1) NOT NULL COMMENT 温度单位摄氏度, wind_speed DECIMAL(6, 1) NOT NULL COMMENT 风速单位km/h, rainfall DECIMAL(6, 1) NOT NULL DEFAULT 0 COMMENT 降雨量单位mm/h, visibility DECIMAL(6, 1) NOT NULL DEFAULT 10 COMMENT 能见度单位km, report_time DATETIME NOT NULL COMMENT 天气报告时间, sync_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 系统同步时间, deleted TINYINT NOT NULL DEFAULT 0, INDEX idx_region_time (region_code, report_time), UNIQUE KEY uk_region_report (region_code, report_time) ) ENGINEInnoDB COMMENT 天气快照表; CREATE TABLE risk_level_config ( config_id BIGINT PRIMARY KEY AUTO_INCREMENT, risk_level TINYINT NOT NULL COMMENT 风险等级 1-3, level_name VARCHAR(32) NOT NULL COMMENT 等级名称, min_score INT NOT NULL COMMENT 风险分数下限, max_score INT NOT NULL COMMENT 风险分数上限, max_delivery_distance DECIMAL(5, 1) NOT NULL COMMENT 最大配送距离km, max_active_orders INT NOT NULL COMMENT 骑手最大同时接单数, subsidy_base DECIMAL(10, 2) NOT NULL COMMENT 基础补贴, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, version INT NOT NULL DEFAULT 1 COMMENT 规则版本, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT 风险等级配置表; CREATE TABLE delivery_order ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL UNIQUE, region_code VARCHAR(64) NOT NULL, rider_id BIGINT NOT NULL, amount DECIMAL(10, 2) NOT NULL, delivery_distance DECIMAL(5, 2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0创建 1配送中 2已完成 3取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME NULL, INDEX idx_rider_status (rider_id, status) ) ENGINEInnoDB COMMENT 配送订单表; CREATE TABLE rider_subsidy ( subsidy_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, order_no VARCHAR(64) NOT NULL, rider_id BIGINT NOT NULL, region_code VARCHAR(64) NOT NULL, risk_level TINYINT NOT NULL, risk_score INT NOT NULL, subsidy_amount DECIMAL(10, 2) NOT NULL, rule_version INT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order (order_id) ) ENGINEInnoDB COMMENT 骑手补贴表;表中比较关键的是uk_region_report唯一索引它可以防止天气同步任务重复入库uk_order唯一索引可以防止同一订单被重复计算补贴。这两个索引是幂等性的基础。3.3 关键字段与索引说明表名关键字段设计意图delivery_regionregion_code业务区域编码作为调度维度weather_snapshotreport_time天气报告时间用于判断数据新旧risk_level_configmin_score / max_score把风险分数映射到风险等级risk_level_configversion规则版本号方便补贴对账rider_subsidyuk_order保证一个订单只产生一条补贴记录在这个设计中risk_level_config是动态配置的核心。变更规则时不要直接修改老记录建议插入一条新版本记录让补贴历史按版本追溯。4. 核心代码实现从天气同步到补贴计算的完整链路4.1 天气数据同步调用第三方 API 并落库先定义一个查询天气的实体 DTOpublic class WeatherPoint { private String regionCode; private String weatherText; private BigDecimal temperature; private BigDecimal windSpeed; private BigDecimal rainfall; private BigDecimal visibility; private LocalDateTime reportTime; }创建WeatherClient负责调用第三方接口。为了演示这里使用 Spring Boot 3 内置的RestClientService public class WeatherClient { Value(${weather.api.base-url}) private String baseUrl; Value(${weather.api.key}) private String apiKey; private final RestClient restClient; public WeatherClient(RestClient.Builder builder) { this.restClient builder.build(); } public WeatherPoint fetchWeather(String regionCode, String regionName) { String url baseUrl /v7/weather/now?key apiKey location regionCode; MapString, Object response restClient.get() .uri(url) .retrieve() .body(new ParameterizedTypeReferenceMapString, Object() {}); // 这里按示例结构解析实际字段以服务商文档为准 MapString, Object now (MapString, Object) response.get(now); WeatherPoint point new WeatherPoint(); point.setRegionCode(regionCode); point.setWeatherText((String) now.get(text)); point.setTemperature(new BigDecimal(now.get(temp).toString())); point.setWindSpeed(new BigDecimal(now.get(windSpeed).toString())); point.setRainfall(new BigDecimal(now.get(precip).toString())); point.setVisibility(new BigDecimal(now.get(vis).toString())); point.setReportTime(LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)); return point; } }需要说明的是不同天气服务商返回的字段名不一样。示例中的precip、vis只是常见命名实际接入时必须先看透接口返回样例再调整字段映射。接着写定时任务每隔 15 分钟把每个区域的天气写入快照表Component public class WeatherSyncJob { private static final Logger log LoggerFactory.getLogger(WeatherSyncJob.class); private final WeatherClient weatherClient; private final WeatherSnapshotMapper snapshotMapper; private final DeliveryRegionMapper regionMapper; public WeatherSyncJob(WeatherClient weatherClient, WeatherSnapshotMapper snapshotMapper, DeliveryRegionMapper regionMapper) { this.weatherClient weatherClient; this.snapshotMapper snapshotMapper; this.regionMapper regionMapper; } Scheduled(fixedDelay 15 * 60 * 1000, initialDelay 5000) public void syncWeather() { ListDeliveryRegion regions regionMapper.selectList(null); for (DeliveryRegion region : regions) { try { WeatherPoint point weatherClient.fetchWeather(region.getRegionCode(), region.getRegionName()); WeatherSnapshot snapshot new WeatherSnapshot(); snapshot.setRegionCode(point.getRegionCode()); snapshot.setWeatherText(point.getWeatherText()); snapshot.setTemperature(point.getTemperature()); snapshot.setWindSpeed(point.getWindSpeed()); snapshot.setRainfall(point.getRainfall()); snapshot.setVisibility(point.getVisibility()); snapshot.setReportTime(point.getReportTime()); snapshotMapper.insert(snapshot); log.info(天气同步成功, region{}, text{}, region.getRegionCode(), point.getWeatherText()); } catch (Exception e) { log.error(天气同步失败, region{}, region.getRegionCode(), e); } } } }这里的fixedDelay表示一次执行完成后等待 15 分钟再执行避免任务阻塞。DatabaseField中uk_region_report唯一索引能防止同分钟重复插入。生产环境如果部署了多个实例必须在任务入口加分布式锁否则多个节点会同时写库。4.2 风险等级计算多维评分风险分数由三部分组成降雨量、风速、能见度。下面是一个简单评分实现权重可以配置化这里先用常量演示Service public class RiskScoreService { public int calculateScore(WeatherSnapshot snapshot) { int rainScore scoreRain(snapshot.getRainfall()); int windScore scoreWind(snapshot.getWindSpeed()); int visibilityScore scoreVisibility(snapshot.getVisibility()); return (int) (rainScore * 0.4 windScore * 0.4 visibilityScore * 0.2); } private int scoreRain(BigDecimal rainfall) { double mm rainfall.doubleValue(); if (mm 50) { return 100; } else if (mm 25) { return 75; } else if (mm 10) { return 50; } else if (mm 1) { return 25; } return 0; } private int scoreWind(BigDecimal windSpeed) { double kmh windSpeed.doubleValue(); if (kmh 50) { return 100; } else if (kmh 30) { return 70; } else if (kmh 17) { return 40; } return 0; } private int scoreVisibility(BigDecimal visibility) { double km visibility.doubleValue(); if (km 0.5) { return 100; } else if (km 1) { return 60; } else if (km 3) { return 30; } return 0; } }分数范围是 0 到 100。等级配置从数据库读取Service public class RiskLevelService { private final RiskLevelConfigMapper configMapper; private final RiskScoreService riskScoreService; public RiskLevelConfig getRiskLevel(String regionCode, LocalDateTime queryTime) { WeatherSnapshot latest queryLatestSnapshot(regionCode, queryTime); int score riskScoreService.calculateScore(latest); QueryWrapperRiskLevelConfig wrapper new QueryWrapper(); wrapper.le(min_score, score) .ge(max_score, score) .eq(status, 1) .orderByDesc(version) .last(limit 1); RiskLevelConfig config configMapper.selectOne(wrapper); config.setRiskScore(score); return config; } private WeatherSnapshot queryLatestSnapshot(String regionCode, LocalDateTime time) { QueryWrapperWeatherSnapshot wrapper new QueryWrapper(); wrapper.eq(region_code, regionCode) .le(report_time, time) .orderByDesc(report_time) .last(limit 1); return snapshotMapper.selectOne(wrapper); } }这个设计把“天气现象文字”排除在计算之外只用数值指标。好处是不同服务商的天气文案不同但数值可以统一映射。风险等级返回后调度和补贴模块都使用同一份配置。4.3 调度模块动态调整骑手接单范围调度模块的逻辑很直接拿到风险等级后用配置中的maxDeliveryDistance和maxActiveOrders覆盖默认值。Service public class DispatchPolicyService { public DispatchPolicy buildPolicy(String regionCode) { RiskLevelConfig config riskLevelService.getRiskLevel(regionCode, LocalDateTime.now()); DispatchPolicy policy new DispatchPolicy(); policy.setRiskLevel(config.getRiskLevel()); policy.setMaxDeliveryDistance(config.getMaxDeliveryDistance()); policy.setMaxActiveOrders(config.getMaxActiveOrders()); policy.setDispatchEnabled(config.getRiskLevel() 3); return policy; } }DispatchPolicy是一个简单的包裹对象public class DispatchPolicy { private Integer riskLevel; private BigDecimal maxDeliveryDistance; private Integer maxActiveOrders; private Boolean dispatchEnabled; }这里为什么用“最大配送距离”而不是直接关闭配送因为等级 3 时dispatchEnabledfalse才是关闭。等级 2 时系统仍然允许接单但半径从 5 公里缩小到 2 公里同时把同时接单数从 5 单降到 2 单。这样既降低了在途风险又没有下架所有订单。4.4 补贴计算规则化、可追溯补贴计算不能直接写if (rain) return 5。推荐做法是结合风险等级、规则版本和订单距离Service public class SubsidyCalculator { public RiderSubsidy calculate(SubsidyRequest request) { RiskLevelConfig config request.getRiskLevelConfig(); BigDecimal subsidy config.getSubsidyBase() .add(BigDecimal.valueOf(request.getRiskScore()).multiply(new BigDecimal(0.05))) .add(request.getDeliveryDistance().multiply(new BigDecimal(0.5))); RiderSubsidy subsidyRecord new RiderSubsidy(); subsidyRecord.setOrderId(request.getOrderId()); subsidyRecord.setOrderNo(request.getOrderNo()); subsidyRecord.setRiderId(request.getRiderId()); subsidyRecord.setRegionCode(request.getRegionCode()); subsidyRecord.setRiskLevel(config.getRiskLevel()); subsidyRecord.setRiskScore(request.getRiskScore()); subsidyRecord.setSubsidyAmount(subsidy.setScale(2, RoundingMode.HALF_UP)); subsidyRecord.setRuleVersion(config.getVersion()); return subsidyRecord; } }SubsidyRequest中包含订单号、骑手、区域、配送距离以及同步查询到的风险等级配置。关键点是计算补贴时不能再次实时查天气而应该使用订单创建或完成那一刻的天气快照和风险等级否则后续天气变化会导致金额不稳定。写库时利用rider_subsidy表的uk_order唯一索引处理幂等Transactional public void saveSubsidy(SubsidyRequest request) { RiderSubsidy subsidy subsidyCalculator.calculate(request); try { subsidyMapper.insert(subsidy); } catch (DuplicateKeyException e) { log.warn(补贴已存在, orderNo{}, request.getOrderNo()); } }这样即使订单完成回调重复执行也不会产生两条补贴记录。4.5 消息通知让骑手和用户提前感知调度规则变化后还要主动通知骑手和用户。这个模块在原型中可以用日志代替消息推送Service public class NoticeService { public void notifyRider(Long riderId, DispatchPolicy policy) { if (Boolean.FALSE.equals(policy.getDispatchEnabled())) { log.info([通知骑手] riderId{}, 当前区域暂停接单, riderId); } else { log.info([通知骑手] riderId{}, 当前区域风险等级{}, 配送距离限制{}km, riderId, policy.getRiskLevel(), policy.getMaxDeliveryDistance()); } } }生产环境会替换成 App 推送、短信或小程序订阅消息。这里的目的只是让契约先行后面接消息队列时只改实现不改调用方。5. 接口层与最小闭环验证5.1 提供查询接口为了让前端或测试工具能看到当前区域的调度策略增加一个查询接口RestController RequestMapping(/api/dispatch) public class DispatchController { private final DispatchPolicyService dispatchPolicyService; public DispatchController(DispatchPolicyService dispatchPolicyService) { this.dispatchPolicyService dispatchPolicyService; } GetMapping(/policy/{regionCode}) public ResultDispatchPolicy getPolicy(PathVariable String regionCode) { DispatchPolicy policy dispatchPolicyService.buildPolicy(regionCode); return Result.success(policy); } }Result是统一响应体实际项目可以替换成自己的封装。5.2 手动触发一次天气同步定时任务启动后 5 秒会执行一次。如果想手动触发可以在WeatherSyncJob上加一个调试接口或者直接等日志输出。启动项目后观察日志2025-01-10 12:00:05.001 INFO 11024 --- [ scheduling-1] c.example.guard.job.WeatherSyncJob : 天气同步成功, regionshanghai-pudong, text暴雨 2025-01-10 12:00:05.102 INFO 11024 --- [ scheduling-1] c.example.guard.job.WeatherSyncJob : 天气同步成功, regionbeijing-chaoyang, text晴然后请求接口curl http://localhost:8080/api/dispatch/policy/shanghai-pudong预期的 JSON 类似{ code: 0, message: success, data: { riskLevel: 3, maxDeliveryDistance: 0, maxActiveOrders: 0, dispatchEnabled: false } }如果risk_level_config中还没有对应数据查询结果可能是null。这时需要先初始化风险等级配置。5.3 初始化风险等级配置可以在数据库执行如下初始化数据INSERT INTO risk_level_config (risk_level, level_name, min_score, max_score, max_delivery_distance, max_active_orders, subsidy_base, status, version) VALUES (1, 正常, 0, 30, 5.0, 5, 0.00, 1, 1), (2, 中等风险, 31, 69, 2.0, 2, 3.00, 1, 1), (3, 高风险, 70, 100, 0.0, 0, 8.00, 1, 1);这里等级 3 的max_delivery_distance0和max_active_orders0代表暂停接单。实际业务如果希望继续配送也可以改成0.5公里和1单完全由配置控制。5.4 用单元测试验证风险分数转换业务规则需要自动化测试兜底。下面是一个针对RiskScoreService的 JUnit 测试Test void shouldScoreHighWhenHeavyRainAndStrongWind() { WeatherSnapshot snapshot new WeatherSnapshot(); snapshot.setRainfall(new BigDecimal(60)); snapshot.setWindSpeed(new BigDecimal(60)); snapshot.setVisibility(new BigDecimal(0.3)); int score riskScoreService.calculateScore(snapshot); assertThat(score).isEqualTo(100); }测试的意义在于当有人调整权重或阈值时至少能发现高风险组合没有被正确识别。6. 常见问题排查从现象倒推到根因6.1 天气数据同步失败或超时现象日志频繁出现天气同步失败数据库weather_snapshot表长时间不更新。排查顺序先用curl手动请求天气 API确认 key 是否失效检查服务商返回 JSON 字段与代码解析是否一致检查网络策略确认服务器出网权限不要为了测试把 key 提交到代码仓库查看第三方 API 的调用限制免费额度耗尽后接口会返回错误。解决办法在WeatherClient中增加超时设置和熔断逻辑。Spring 的RestClient可以配置连接超时和读取超时避免第三方接口阻塞线程。6.2 定时任务在多个实例上重复执行现象同样一个区域的天气快照出现多条但展示时只取最新一条暂时没问题一旦多个实例同时触发补贴计算就会出现重复数据。原因单机Scheduled只保证本 JVM 内执行一次多实例部署时每个节点都会执行。解决使用 MySQLSELECT ... FOR UPDATE或 Redis 分布式锁控制任务唯一执行将定时任务迁移到xxl-job、Quartz等支持分布式调度的中间件最终兜底依赖数据库唯一索引。如果原型阶段不引入分布式组件至少要把weather_snapshot的唯一索引保留住。6.3 风险等级计算与实际天气不一致现象天气预报显示暴雨但系统返回风险等级 1。常见原因取到的天气快照不是最新report_time过期服务商返回的降雨单位不是毫米每小时而是累计降水量risk_level_config中分数区间与业务预期不一致接口查询用了缓存但没有及时刷新。处理建议打印每次同步的原始数据确认单位在风险等级变化时记录变更日志建立一个“天气样例到风险等级”的测试用例集把历史上出过问题的天气数据固化到测试中。6.4 补贴重复计算或发放现象对账时发现同一订单出现了两条补贴记录。原因订单完成回调可能被重复投递而补贴计算代码没有做幂等处理。排查路径查rider_subsidy表是否有order_id重复查订单回调代码是否存在事务边界错误查唯一索引是否已建立。解决在表上建唯一索引并在插入时捕获DuplicateKeyException。同时补贴计算必须放在同一事务内避免先保存订单状态再计算补贴导致状态不一致。6.5 天气快照时间与本地时区不一致现象数据库里的report_time比实际时间早 8 小时或晚 8 小时。原因第三方 API 返回的是 UTC 时间数据库连接串使用serverTimezoneAsia/Shanghai但 Java 代码直接LocalDateTime.now()或解析时没有指定时区。处理方式第三方接口返回的时间统一转成Instant入库时明确使用Asia/Shanghai时区数据库连接串固定写serverTimezoneAsia/Shanghai不要依赖服务器本地时区部署环境变了会导致行为差异。6.6 常见问题速查表问题现象常见原因检查方式处理建议天气同步失败API Key 失效或超时手动 curl 接口更新 Key增加超时和重试天气快照重复多实例定时任务重复执行查唯一索引和实例日志加分布式锁或使用分布式调度风险等级不准确天气单位或字段解析错误打印原始响应统一单位和字段映射补贴重复回调重复且无幂等查唯一索引加唯一索引并捕获重复异常时间偏差时区处理不一致比较数据库和第三方时间统一 UTC 存储或显式指定时区7. 从原型到生产还需要补齐哪些工程能力7.1 学习环境与生产环境差异能力学习环境生产环境配置管理写死在 application.yml 或启动命令使用配置中心密钥加密存储定时任务单机 Scheduled分布式调度平台或自研分布式锁天气数据源单个服务商主备多源失败自动切换补贴计算直接调用数据库加消息队列削峰异步批量计算日志仅 logback 文件接入链路追踪、监控和告警安全测试数据权限控制、操作审计、数据传输加密7.2 监控和告警建议至少监控以下几个指标天气同步成功率低于 95% 触发告警风险等级为 2 和 3 的区域数量和分布补贴单量是否异常增长或下跌调度接口响应耗时和失败率。监控可以先用 Spring Boot Actuator 暴露/actuator/metrics再接入 Prometheus 等监控平台。Scheduled任务内部要记录执行耗时和成功数量否则出问题时难以定位。7.3 幂等与资金安全补贴涉及资金不能只依赖代码逻辑。除了表唯一索引还要考虑补贴记录和订单状态变更必须在同一事务中每天做一次对账统计每个骑手订单数与补贴条数是否一致金额变更必须有审计日志记录操作人、时间、规则版本风险等级配置的修改要走审批流不要直接改数据库。这些能力在原型阶段可以暂时不做但一旦进入生产就要第一时间补上。7.4 扩展方向这个系统的核心是把“天气”转换成“调度参数”。基于这个思路可以从几个方向继续扩展接入高德或百度地图的路径规划接口把封路、积水等实时路况纳入配送时长预估基于历史天气和订单数据提前预测未来 2 小时的区域风险等级从而更早调整调度策略使用 Kafka 或 RocketMQ 解耦天气同步、风险计算和补贴通知避免接口阻塞导致任务延迟增加人工兜底操作台运营人员可以手动修改某个区域的风险等级但每次修改都要留痕把风险阈值和补贴规则做成可视化配置页面降低开发和运营之间的沟通成本。7.5 上线检查清单发布前可以按这份清单逐项确认天气 API Key 已通过环境变量注入代码仓库中没有明文 Key天气快照表存在region_code report_time唯一索引定时任务已加分布式锁或已迁移到分布式调度平台风险等级配置表已初始化并启用补贴表存在order_id唯一索引补贴计算使用订单完成时的天气快照而不是实时数据天气同步、风险变化、补贴计算均有日志和监控指标多实例发布时业务侧已验证不会出现重复任务执行运营后台或配置中心可以修改风险等级配置且修改有审计记录已模拟暴雨、大风、能见度低三种场景验证调度策略。这套系统的核心判断是恶劣天气配送不能靠人盯也不能靠简单开关而是要把天气要素拆解成可计算、可配置、可追溯的调度参数。对研发团队来说先用最小闭环跑通天气同步、风险分级、调度和补贴一条链路比一开始就追求复杂的预测模型更实际。跑通之后再逐步接入地图路况、多天气源和消息队列系统会更容易演进。