秒配單!SpringBoot 與 GeoHash 聯(lián)手打造外賣(mài)騎手實(shí)時(shí)精準(zhǔn)派單系統(tǒng)!
隨著即時(shí)配送行業(yè)的加速發(fā)展,外賣(mài)平臺(tái)的訂單與騎手規(guī)模呈現(xiàn)指數(shù)級(jí)增長(zhǎng)。某頭部平臺(tái)每天處理超百萬(wàn)訂單,在線騎手?jǐn)?shù)量超過(guò) 20 萬(wàn)。這樣龐大的規(guī)模帶來(lái)了三大核心挑戰(zhàn):
- 位置更新高頻:騎手每 3 秒上報(bào)一次坐標(biāo),單日產(chǎn)生 5.76 億條位置數(shù)據(jù),傳統(tǒng)數(shù)據(jù)庫(kù)難以承載高頻寫(xiě)入。
 - 派單需快速就近匹配:系統(tǒng)需在 200ms 內(nèi)返回 3 公里范圍內(nèi)候選騎手,而傳統(tǒng) SQL 基于 
ST_Distance的全表計(jì)算常常超過(guò) 500ms。 - 高并發(fā)下避免數(shù)據(jù)競(jìng)爭(zhēng):高峰期同時(shí)觸發(fā) 1000+ 訂單派單,若處理不當(dāng)會(huì)出現(xiàn)鎖沖突與數(shù)據(jù)不一致,直接影響用戶(hù)體驗(yàn)。
 
傳統(tǒng)方案在 查詢(xún)效率、數(shù)據(jù)可靠性、并發(fā)處理與邊界匹配 上存在明顯短板。為破解瓶頸,本文將介紹如何借助 SpringBoot + GeoHash + Redis,搭建一個(gè)高效、可靠且可擴(kuò)展的實(shí)時(shí)派單系統(tǒng)。
為何選擇 GeoHash?
空間降維:二維轉(zhuǎn)一維
GeoHash 使用 Base32 編碼將經(jīng)緯度轉(zhuǎn)為字符串(如 39.908823,116.397470 → wx4g89)。這樣,本來(lái)需要在二維平面計(jì)算的“附近騎手”問(wèn)題,可以簡(jiǎn)化為字符串前綴匹配,查詢(xún)性能提升一個(gè)數(shù)量級(jí)。
精度靈活
GeoHash 的長(zhǎng)度決定了定位精度:
- 6 位(如 
wx4g89):約 1 公里范圍,適合全城范圍的粗粒度篩選。 - 7 位(如 
wx4g89e):約 100 米范圍,適合最后一公里的精匹配。 
這種靈活性避免了過(guò)度精確帶來(lái)的數(shù)據(jù)分散,同時(shí)兼顧效率與準(zhǔn)確性。
Redis 提供原生地理支持
Redis 內(nèi)置了 GEOADD、GEORADIUS 等命令,可以直接存儲(chǔ)騎手坐標(biāo)與執(zhí)行范圍查詢(xún)。結(jié)合 Hash 結(jié)構(gòu)存儲(chǔ) GeoHash → 騎手ID 的映射,可以輕松支撐 每秒十萬(wàn)次位置更新與查詢(xún)。
解決邊界問(wèn)題
僅查詢(xún)單個(gè) GeoHash 區(qū)域會(huì)漏掉邊界騎手。通過(guò) 目標(biāo) GeoHash + 相鄰 8 個(gè) GeoHash 的策略,可以覆蓋訂單周邊區(qū)域,確保不會(huì)遺漏臨近騎手。
系統(tǒng)設(shè)計(jì)
整體架構(gòu)
系統(tǒng)分為四層:
- 感知層:騎手端 APP 每 3 秒上傳位置;用戶(hù)端下單上傳收貨地址。
 - 接入層:SpringBoot 接收請(qǐng)求,校驗(yàn)參數(shù)。
 - 業(yè)務(wù)層:GeoHash 轉(zhuǎn)碼、派單計(jì)算邏輯。
 - 存儲(chǔ)層:Redis 保存騎手位置、GeoHash 映射、訂單狀態(tài)。
 
數(shù)據(jù)流程
騎手位置上報(bào)
- APP → 
POST /rider/report - 轉(zhuǎn)換為 GeoHash,更新 Redis(GEO + Hash)。
 
訂單派單
- 用戶(hù)下單 → 
POST /order/dispatch - 流程:
 
收貨地址 → GeoHash
獲取目標(biāo) + 相鄰 8 個(gè) GeoHash 下的騎手
計(jì)算距離,篩選 在線 + 未超載 + 3 公里內(nèi) 騎手
排序取 Top3,推送派單通知
數(shù)據(jù)模型
騎手位置模型
package com.icoderoad.dispatch.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
 * 騎手位置模型
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RiderLocation {
    private String riderId;      // 騎手ID
    private double lng;          // 經(jīng)度
    private double lat;          // 緯度
    private String geoHash;      // GeoHash
    private boolean online;      // 是否在線
    private int orderCount;      // 當(dāng)前接單量
}訂單模型
package com.icoderoad.dispatch.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
 * 訂單模型
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Order {
    private String orderId;     // 訂單ID
    private double recvLng;     // 收貨經(jīng)度
    private double recvLat;     // 收貨緯度
    private String geoHash;     // 收貨地址的GeoHash
    private String assignedRider; // 分配的騎手ID
    private String status;      // 狀態(tài):待派單/已分配/完成
}核心代碼實(shí)現(xiàn)
Service 層
騎手位置服務(wù)
package com.icoderoad.dispatch.service;
import com.icoderoad.dispatch.model.RiderLocation;
import com.icoderoad.dispatch.util.GeoHashUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.data.geo.Point;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class RiderLocationService {
    private final StringRedisTemplate redisTemplate;
    private static final String GEO_KEY = "delivery:riders";
    private static final String HASH_KEY = "delivery:rider:info:";
    /**
     * 騎手位置上報(bào)
     */
    public void reportLocation(RiderLocation rider) {
        // GEO 存儲(chǔ)坐標(biāo)
        redisTemplate.opsForGeo().add(GEO_KEY,
                new RedisGeoCommands.GeoLocation<>(rider.getRiderId(),
                        new Point(rider.getLng(), rider.getLat())));
        // Hash 存儲(chǔ)附加信息
        redisTemplate.opsForHash().put(HASH_KEY + rider.getRiderId(),
                "geoHash", GeoHashUtils.encode(rider.getLat(), rider.getLng(), 6));
        redisTemplate.opsForHash().put(HASH_KEY + rider.getRiderId(),
                "online", String.valueOf(rider.isOnline()));
        redisTemplate.opsForHash().put(HASH_KEY + rider.getRiderId(),
                "orderCount", String.valueOf(rider.getOrderCount()));
    }
    /**
     * 根據(jù) geoHash 獲取騎手列表(簡(jiǎn)化)
     */
    public String[] getRidersByGeoHash(String geoHash) {
        // 實(shí)際場(chǎng)景可用 redis scan + hash 過(guò)濾,這里演示簡(jiǎn)化返回
        return new String[]{"rider1", "rider2"};
    }
}派單服務(wù)
package com.icoderoad.dispatch.service;
import com.icoderoad.dispatch.model.Order;
import com.icoderoad.dispatch.util.GeoHashUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
@RequiredArgsConstructor
public class DispatchService {
    private final RiderLocationService riderLocationService;
    @Value("${dispatch.max-distance}")
    private double maxDistance;
    @Value("${dispatch.geohash-precision}")
    private int geoHashPrecision;
    /**
     * 創(chuàng)建訂單并派單
     */
    public Order createAndDispatch(Order order) {
        // 1. 計(jì)算訂單GeoHash
        String orderGeoHash = GeoHashUtils.encode(order.getRecvLat(), order.getRecvLng(), geoHashPrecision);
        order.setGeoHash(orderGeoHash);
        order.setStatus("待派單");
        // 2. 查詢(xún)目標(biāo) GeoHash + 相鄰 8 個(gè)區(qū)域
        Set<String> candidates = new HashSet<>();
        for (String gh : GeoHashUtils.adjacent(orderGeoHash)) {
            candidates.addAll(Arrays.asList(riderLocationService.getRidersByGeoHash(gh)));
        }
        // 3. 簡(jiǎn)化:隨便取一個(gè)候選騎手
        String assignedRider = candidates.stream().findFirst().orElse(null);
        // 4. 更新訂單對(duì)象
        if (assignedRider != null) {
            order.setAssignedRider(assignedRider);
            order.setStatus("已分配");
        }
        return order;
    }
}Controller 層
騎手位置上報(bào)接口
package com.icoderoad.dispatch.controller;
import com.icoderoad.dispatch.model.RiderLocation;
import com.icoderoad.dispatch.service.RiderLocationService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/rider")
@RequiredArgsConstructor
public class RiderController {
    private final RiderLocationService riderLocationService;
    @PostMapping("/report")
    public String reportLocation(@RequestParam String riderId,
                                 @RequestParam double lng,
                                 @RequestParam double lat) {
        RiderLocation rider = new RiderLocation(riderId, lng, lat, null, true, 0);
        riderLocationService.reportLocation(rider);
        return "騎手位置上報(bào)成功";
    }
}派單接口
package com.icoderoad.dispatch.controller;
import com.icoderoad.dispatch.model.Order;
import com.icoderoad.dispatch.service.DispatchService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/order")
@RequiredArgsConstructor
public class OrderController {
    private final DispatchService dispatchService;
    @PostMapping("/dispatch")
    public Order dispatch(@RequestParam String orderId,
                          @RequestParam double lng,
                          @RequestParam double lat) {
        Order order = new Order(orderId, lng, lat, null, null, null);
        return dispatchService.createAndDispatch(order);
    }
}環(huán)境與配置
Redis 啟動(dòng)
docker run -d --name redis-geohash -p 6379:6379 \
  -v redis-data:/data \
  -e REDIS_PASSWORD=redis123 \
  redis:6.2.6 --appendonly yesSpringBoot 配置
spring:
  redis:
    host: localhost
    port: 6379
    password: redis123
    lettuce:
      pool:
        max-active: 200
        max-idle: 50
dispatch:
  max-distance: 3000   # 派單最大距離(米)
  geohash-precision: 6 # GeoHash 精度前端派單可視化界面
dispatch.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>派單可視化</title>
    <link  rel="stylesheet">
    <script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"></script>
    <script src="https://webapi.amap.com/maps?v=2.0&key=你的高德Key"></script>
</head>
<body class="container mt-4">
    <h3 class="mb-3">外賣(mài)派單可視化</h3>
    <div id="map" style="width: 100%; height: 500px;" class="mb-3"></div>
    <div class="card p-3">
        <h5>模擬下單</h5>
        <div class="row mb-2">
            <div class="col"><input type="text" id="orderId" class="form-control" placeholder="訂單ID"></div>
            <div class="col"><input type="text" id="lng" class="form-control" placeholder="經(jīng)度"></div>
            <div class="col"><input type="text" id="lat" class="form-control" placeholder="緯度"></div>
            <div class="col"><button id="btnDispatch" class="btn btn-primary w-100">派單</button></div>
        </div>
        <div id="result" class="alert alert-info d-none"></div>
    </div>
    <script>
        var map = new AMap.Map("map", { zoom: 12, center: [116.397428, 39.90923] });
        var riders = [
            {id: "rider1", lng: 116.40, lat: 39.91},
            {id: "rider2", lng: 116.38, lat: 39.92},
            {id: "rider3", lng: 116.42, lat: 39.90}
        ];
        riders.forEach(r => {
            new AMap.Marker({
                position: [r.lng, r.lat],
                map: map,
                title: r.id,
                icon: "https://webapi.amap.com/theme/v1.3/markers/n/mark_b.png"
            });
        });
        $("#btnDispatch").click(function () {
            var orderId = $("#orderId").val();
            var lng = $("#lng").val();
            var lat = $("#lat").val();
            $.post("/order/dispatch", {orderId: orderId, lng: lng, lat: lat}, function (res) {
                $("#result").removeClass("d-none").text(res);
                if(res.includes("騎手")) {
                    new AMap.Marker({
                        position: [lng, lat],
                        map: map,
                        title: "訂單 " + orderId,
                        icon: "https://webapi.amap.com/theme/v1.3/markers/n/mark_r.png"
                    });
                }
            });
        });
    </script>
</body>
</html>結(jié)論
外賣(mài)平臺(tái)的實(shí)時(shí)派單,本質(zhì)是一個(gè) 高頻寫(xiě)入 + 快速查詢(xún) + 高并發(fā) 的技術(shù)難題。傳統(tǒng)數(shù)據(jù)庫(kù)方案往往在查詢(xún)效率和并發(fā)控制上遇到瓶頸,而 SpringBoot + GeoHash + Redis 的組合恰好能在三方面實(shí)現(xiàn)突破:
- GeoHash 降維:空間查詢(xún)轉(zhuǎn)字符串匹配,效率提升十倍。
 - Redis 高并發(fā):原生 GEO 命令確保百萬(wàn)級(jí)騎手位置實(shí)時(shí)更新。
 - 邊界問(wèn)題解決:相鄰 GeoHash 查詢(xún)避免遺漏騎手。
 
這種方案不僅能保障外賣(mài)派單的實(shí)時(shí)性和準(zhǔn)確性,還具備 良好的可擴(kuò)展性,可支撐未來(lái)千萬(wàn)級(jí)訂單。對(duì)網(wǎng)約車(chē)調(diào)度、同城快遞分配等場(chǎng)景同樣適用。















 
 
 











 
 
 
 