返回 筑基・数据元府藏真

地理空间数据处理

博主
大约 17 分钟

地理空间数据处理

一、问题引入:附近的人功能实现

1.1 真实案例:外卖配送范围计算

场景:外卖平台需要计算商家配送范围
需求:
- 用户下单时判断是否在配送范围内
- 搜索时只展示可配送的商家
- 配送费根据距离动态计算

传统方案的问题:
┌─────────────────────────────────────────────────────────────┐
│ 方案1:应用层计算                                             │
│ - 查询所有商家坐标                                            │
│ - 逐个计算与用户的距离                                        │
│ - 数据量大时性能极差                                          │
│ - 10000个商家需要计算10000次                                  │
├─────────────────────────────────────────────────────────────┤
│ 方案2:矩形范围过滤                                           │
│ - 根据距离估算经纬度范围                                      │
│ - 使用BETWEEN查询                                             │
│ - 不准确:地球是球形,矩形范围会漏掉或包含错误数据            │
├─────────────────────────────────────────────────────────────┤
│ 方案3:GeoHash编码                                            │
│ - 将坐标编码为字符串                                          │
│ - 查询相邻的GeoHash区域                                       │
│ - 需要额外维护GeoHash字段                                     │
├─────────────────────────────────────────────────────────────┤
│ 方案4:MySQL空间索引(推荐)                                  │
│ - 使用POINT类型存储坐标                                       │
│ - 建立SPATIAL INDEX                                           │
│ - 使用ST_Distance_Sphere计算球面距离                          │
│ - 精度高,性能好                                              │
└─────────────────────────────────────────────────────────────┘

1.2 地理空间数据应用场景

场景说明技术方案
附近的人/商家基于位置的服务(LBS)MySQL空间索引 + Redis Geo
配送范围判断多边形区域包含检测MySQL POLYGON + ST_Contains
轨迹存储分析车辆/人员轨迹记录PostGIS / 时序数据库
地理围栏电子围栏告警Redis GeoHash
路径规划最优路线计算图数据库 / 专用算法
区域统计热力图、分布分析空间聚合查询

二、MySQL空间数据类型详解

2.1 空间数据类型介绍

MySQL 5.7+ 支持空间数据类型,8.0进一步优化:

┌──────────────────────────────────────────────────────────────┐
│ 数据类型          │ 说明                    │ 示例           │
├───────────────────┼─────────────────────────┼────────────────┤
│ POINT             │ 点(经纬度坐标)        │ 商家位置       │
│ LINESTRING        │ 线(路径、轨迹)        │ 配送路线       │
│ POLYGON           │ 多边形(区域)          │ 配送范围       │
│ MULTIPOINT        │ 多点集合                │ 多个门店       │
│ MULTILINESTRING   │ 多线集合                │ 多条路线       │
│ MULTIPOLYGON      │ 多边形集合              │ 多个配送区     │
│ GEOMETRY          │ 通用几何类型            │ 混合存储       │
│ GEOMETRYCOLLECTION│ 几何集合                │ 复杂场景       │
└──────────────────────────────────────────────────────────────┘

坐标系(SRID):
- 0:默认平面坐标系
- 4326:WGS 84(GPS使用的经纬度坐标系)⭐ 推荐
- 3857:Web墨卡托投影(地图显示用)

2.2 基础DDL操作

-- 创建用户位置表
CREATE TABLE user_location (
    user_id BIGINT PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    -- 空间数据类型:POINT,SRID 4326表示WGS84坐标系
    location POINT NOT NULL SRID 4326,
    location_name VARCHAR(100) COMMENT '位置名称',
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    -- 空间索引(必须)
    SPATIAL INDEX idx_location (location)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户位置表';

-- 创建商家表(带配送范围)
CREATE TABLE merchant (
    merchant_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    merchant_name VARCHAR(100) NOT NULL,
    -- 商家位置
    location POINT NOT NULL SRID 4326,
    -- 配送范围(多边形)
    delivery_area POLYGON SRID 4326,
    delivery_radius INT COMMENT '配送半径(米)',
    min_delivery_amount DECIMAL(10,2) COMMENT '起送金额',
    status TINYINT DEFAULT 1,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    -- 索引
    SPATIAL INDEX idx_location (location),
    SPATIAL INDEX idx_delivery_area (delivery_area),
    INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 插入位置数据
INSERT INTO user_location (user_id, username, location, location_name) VALUES 
(1, '张三', ST_GeomFromText('POINT(116.4074 39.9042)', 4326), '北京市中心'),
(2, '李四', ST_GeomFromText('POINT(121.4737 31.2304)', 4326), '上海市中心'),
(3, '王五', ST_GeomFromText('POINT(113.2644 23.1291)', 4326), '广州市中心'),
(4, '赵六', ST_GeomFromText('POINT(114.0579 22.5431)', 4326), '深圳市中心');

-- 插入商家数据(带配送范围)
INSERT INTO merchant (merchant_name, location, delivery_area, delivery_radius, min_delivery_amount) VALUES 
('海底捞火锅(朝阳店)', 
 ST_GeomFromText('POINT(116.4474 39.9242)', 4326),
 ST_GeomFromText('POLYGON((116.4374 39.9142, 116.4574 39.9142, 116.4574 39.9342, 116.4374 39.9342, 116.4374 39.9142))', 4326),
 3000, 100.00);

2.3 空间查询操作

-- 1. 查询附近的人(5公里内)
SELECT 
    user_id,
    username,
    location_name,
    -- 计算球面距离(米)
    ST_Distance_Sphere(location, ST_GeomFromText('POINT(116.4074 39.9042)', 4326)) AS distance
FROM user_location
WHERE ST_Distance_Sphere(location, ST_GeomFromText('POINT(116.4074 39.9042)', 4326)) <= 5000
ORDER BY distance;

-- 2. 使用空间索引优化(MBR包含查询)
-- 先使用MBRIntersects快速过滤,再精确计算距离
SELECT 
    user_id,
    username,
    ST_Distance_Sphere(location, ST_GeomFromText('POINT(116.4074 39.9042)', 4326)) AS distance
FROM user_location
WHERE ST_Intersects(
    location,
    ST_Buffer(ST_GeomFromText('POINT(116.4074 39.9042)', 4326), 5000)
)
ORDER BY distance;

-- 3. 查询在配送范围内的商家
SELECT 
    merchant_id,
    merchant_name,
    ST_Distance_Sphere(location, ST_GeomFromText('POINT(116.4474 39.9242)', 4326)) AS distance
FROM merchant
WHERE ST_Contains(
    delivery_area,
    ST_GeomFromText('POINT(116.4474 39.9242)', 4326)
);

-- 4. 查询某个点是否在多边形内
SELECT merchant_name
FROM merchant
WHERE ST_Contains(
    delivery_area,
    ST_GeomFromText('POINT(116.4450 39.9200)', 4326)
);

-- 5. 查询两个商家的距离
SELECT 
    m1.merchant_name AS merchant1,
    m2.merchant_name AS merchant2,
    ST_Distance_Sphere(m1.location, m2.location) AS distance
FROM merchant m1, merchant m2
WHERE m1.merchant_id = 1 AND m2.merchant_id = 2;

-- 6. 查询商家的配送范围面积
SELECT 
    merchant_name,
    ST_Area(delivery_area) AS area,
    ST_Area(delivery_area, 'kilometer') AS area_km2
FROM merchant;

-- 7. 查询轨迹长度(LINESTRING)
SELECT 
    route_id,
    ST_Length_Spheroid(route, 'SPHEROID[\"WGS 84\",6378137,298.257223563]') AS length_m
FROM delivery_route;

三、GeoHash方案详解

3.1 GeoHash原理

GeoHash原理:
- 将二维经纬度编码为一维字符串
- 基于Z-order曲线填充空间
- 字符串越长,精度越高

精度对照表:
┌──────────┬─────────────┬──────────────┐
│ GeoHash长度 │ 精度(km)   │ 适用场景      │
├──────────┼─────────────┼──────────────┤
│ 1        │ 5000        │ 国家级别      │
│ 2        │ 630         │ 省份级别      │
│ 3        │ 78          │ 城市级别      │
│ 4        │ 20          │ 区县级别      │
│ 5        │ 2.4         │ 附近搜索      │
│ 6        │ 0.6         │ 精确位置      │
│ 7        │ 0.074       │ 建筑级别      │
│ 8        │ 0.019       │ 精确到米      │
└──────────┴─────────────┴──────────────┘

相邻区域特性:
- 前缀相同的GeoHash表示相邻区域
- 但相邻区域的GeoHash前缀不一定相同
- 需要查询中心点+周围8个区域

3.2 Java GeoHash实现

/**
 * GeoHash服务
 */
@Service
@Slf4j
public class GeoHashService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * 编码经纬度为GeoHash
     */
    public String encode(double lat, double lon, int precision) {
        return GeoHashUtils.encode(lat, lon, precision);
    }
    
    /**
     * 解码GeoHash为经纬度
     */
    public double[] decode(String geohash) {
        return GeoHashUtils.decode(geohash);
    }
    
    /**
     * 获取周围8个区域的GeoHash
     */
    public List<String> getNeighbors(String geohash) {
        return GeoHashUtils.getNeighbors(geohash);
    }
    
    /**
     * 获取指定距离内的所有GeoHash
     */
    public List<String> getGeoHashesInRadius(double lat, double lon, 
                                              double radiusKm, int precision) {
        String center = encode(lat, lon, precision);
        List<String> neighbors = getNeighbors(center);
        neighbors.add(center);
        return neighbors;
    }
    
    /**
     * 使用Redis存储位置
     */
    public void addLocation(String key, Long id, double lat, double lon) {
        redisTemplate.opsForGeo().add(key, new Point(lon, lat), id.toString());
    }
    
    /**
     * 查询附近的位置(Redis GeoRadius)
     */
    public List<NearbyLocation> findNearby(String key, double lat, double lon, 
                                           double radiusKm, int limit) {
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = 
            redisTemplate.opsForGeo().radius(key,
                new Circle(
                    new Point(lon, lat), 
                    new Distance(radiusKm, Metrics.KILOMETERS)
                ),
                RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
                    .includeDistance()
                    .includeCoordinates()
                    .sortAscending()
                    .limit(limit)
            );
        
        List<NearbyLocation> locations = new ArrayList<>();
        if (results != null) {
            for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : results) {
                NearbyLocation loc = new NearbyLocation();
                loc.setId(Long.valueOf(result.getContent().getName()));
                loc.setLon(result.getContent().getPoint().getX());
                loc.setLat(result.getContent().getPoint().getY());
                loc.setDistance(result.getDistance().getValue());
                locations.add(loc);
            }
        }
        return locations;
    }
    
    /**
     * 计算两点距离
     */
    public double calculateDistance(String key, Long id1, Long id2) {
        Distance distance = redisTemplate.opsForGeo()
            .distance(key, id1.toString(), id2.toString(), Metrics.KILOMETERS);
        return distance != null ? distance.getValue() : -1;
    }
}

/**
 * 基于GeoHash的附近搜索
 */
@Component
public class GeoHashSearchService {
    
    @Autowired
    private MerchantRepository merchantRepository;
    
    /**
     * GeoHash附近搜索
     * 适用于:数据量不大,无需精确距离排序的场景
     */
    public List<Merchant> searchNearbyByGeoHash(double lat, double lon, 
                                                 double radiusKm) {
        // 根据半径确定精度
        int precision = getPrecisionByRadius(radiusKm);
        
        // 获取中心点和周围8个区域的GeoHash
        String center = GeoHashUtils.encode(lat, lon, precision);
        List<String> neighbors = GeoHashUtils.getNeighbors(center);
        neighbors.add(center);
        
        // 查询这些GeoHash前缀匹配的记录
        // 注意:这只是近似查询,需要在应用层进一步过滤
        List<Merchant> candidates = merchantRepository
            .findByGeoHashPrefixIn(neighbors.stream()
                .map(h -> h.substring(0, precision))
                .collect(Collectors.toList()));
        
        // 精确计算距离并过滤
        return candidates.stream()
            .filter(m -> calculateDistance(lat, lon, m.getLat(), m.getLon()) <= radiusKm)
            .sorted(Comparator.comparingDouble(m -> 
                calculateDistance(lat, lon, m.getLat(), m.getLon())))
            .collect(Collectors.toList());
    }
    
    private int getPrecisionByRadius(double radiusKm) {
        if (radiusKm >= 500) return 3;
        if (radiusKm >= 100) return 4;
        if (radiusKm >= 10) return 5;
        if (radiusKm >= 1) return 6;
        return 7;
    }
    
    private double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
        // Haversine公式计算球面距离
        final int R = 6371; // 地球半径(km)
        double latDistance = Math.toRadians(lat2 - lat1);
        double lonDistance = Math.toRadians(lon2 - lon1);
        double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2)
                + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
                * Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2);
        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
        return R * c;
    }
}

四、Java空间数据处理

4.1 JPA空间数据映射

/**
 * 商家实体(带空间数据)
 */
@Entity
@Table(name = "merchant")
@Data
public class Merchant {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long merchantId;
    
    private String merchantName;
    
    // 空间数据需要使用自定义类型处理器
    @Column(columnDefinition = "POINT SRID 4326")
    @Convert(converter = PointConverter.class)
    private Point location;
    
    @Column(columnDefinition = "POLYGON SRID 4326")
    @Convert(converter = PolygonConverter.class)
    private Polygon deliveryArea;
    
    private Integer deliveryRadius;
    private BigDecimal minDeliveryAmount;
    private Integer status;
    
    @Transient
    private Double distance; // 查询时动态计算的距离
}

/**
 * Point类型转换器
 */
@Converter
public class PointConverter implements AttributeConverter<Point, byte[]> {
    
    private static final WKBWriter wkbWriter = new WKBWriter();
    private static final WKBReader wkbReader = new WKBReader();
    private static final WKTWriter wktWriter = new WKTWriter();
    private static final WKTReader wktReader = new WKTReader();
    
    @Override
    public byte[] convertToDatabaseColumn(Point attribute) {
        if (attribute == null) return null;
        return wkbWriter.write(attribute);
    }
    
    @Override
    public Point convertToEntityAttribute(byte[] dbData) {
        if (dbData == null) return null;
        try {
            return (Point) wkbReader.read(dbData);
        } catch (ParseException e) {
            throw new RuntimeException("解析空间数据失败", e);
        }
    }
    
    /**
     * 从经纬度创建Point
     */
    public static Point fromLatLon(double lat, double lon) {
        GeometryFactory factory = new GeometryFactory();
        Point point = factory.createPoint(new Coordinate(lon, lat));
        point.setSRID(4326);
        return point;
    }
}

4.2 MyBatis空间数据操作

/**
 * MyBatis空间数据类型处理器
 */
@Component
public class PointTypeHandler extends BaseTypeHandler<Point> {
    
    private static final WKBWriter wkbWriter = new WKBWriter();
    private static final WKBReader wkbReader = new WKBReader();
    
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, 
            Point parameter, JdbcType jdbcType) throws SQLException {
        // 使用ST_GeomFromWKB函数
        ps.setBytes(i, wkbWriter.write(parameter));
    }
    
    @Override
    public Point getNullableResult(ResultSet rs, String columnName) throws SQLException {
        byte[] bytes = rs.getBytes(columnName);
        return parseWKB(bytes);
    }
    
    @Override
    public Point getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        byte[] bytes = rs.getBytes(columnIndex);
        return parseWKB(bytes);
    }
    
    @Override
    public Point getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        byte[] bytes = cs.getBytes(columnIndex);
        return parseWKB(bytes);
    }
    
    private Point parseWKB(byte[] bytes) {
        if (bytes == null) return null;
        try {
            return (Point) wkbReader.read(bytes);
        } catch (ParseException e) {
            throw new RuntimeException("解析WKB失败", e);
        }
    }
}

/**
 * Mapper接口
 */
@Mapper
public interface MerchantMapper {
    
    /**
     * 查询附近商家
     */
    @Select("SELECT " +
            "  merchant_id, merchant_name, location, delivery_radius, " +
            "  ST_Distance_Sphere(location, ST_GeomFromText(#{pointWkt}, 4326)) AS distance " +
            "FROM merchant " +
            "WHERE ST_Distance_Sphere(location, ST_GeomFromText(#{pointWkt}, 4326)) <= #{radius} " +
            "ORDER BY distance " +
            "LIMIT #{limit}")
    @Results({
        @Result(property = "merchantId", column = "merchant_id"),
        @Result(property = "merchantName", column = "merchant_name"),
        @Result(property = "location", column = "location", typeHandler = PointTypeHandler.class),
        @Result(property = "distance", column = "distance")
    })
    List<Merchant> findNearby(@Param("pointWkt") String pointWkt, 
                               @Param("radius") double radius,
                               @Param("limit") int limit);
    
    /**
     * 查询在配送范围内的商家
     */
    @Select("SELECT * FROM merchant " +
            "WHERE ST_Contains(delivery_area, ST_GeomFromText(#{pointWkt}, 4326)) " +
            "AND status = 1")
    List<Merchant> findByDeliveryArea(@Param("pointWkt") String pointWkt);
}

4.3 配送服务实现

/**
 * 配送服务
 */
@Service
@Slf4j
public class DeliveryService {
    
    @Autowired
    private MerchantMapper merchantMapper;
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * 检查地址是否在配送范围内
     */
    public DeliveryCheckResult checkDeliveryRange(Long merchantId, double lat, double lon) {
        Merchant merchant = merchantMapper.selectById(merchantId);
        if (merchant == null) {
            return DeliveryCheckResult.fail("商家不存在");
        }
        
        // 计算距离
        double distance = calculateDistance(
            merchant.getLocation().getY(), merchant.getLocation().getX(),
            lat, lon
        );
        
        // 检查是否在配送半径内
        if (distance > merchant.getDeliveryRadius()) {
            return DeliveryCheckResult.fail(
                String.format("超出配送范围,距离%.1fkm,最大配送%.1fkm", 
                    distance / 1000, merchant.getDeliveryRadius() / 1000)
            );
        }
        
        // 检查是否在多边形配送区域内(如果有)
        if (merchant.getDeliveryArea() != null) {
            GeometryFactory factory = new GeometryFactory();
            Point userPoint = factory.createPoint(new Coordinate(lon, lat));
            userPoint.setSRID(4326);
            
            if (!merchant.getDeliveryArea().contains(userPoint)) {
                return DeliveryCheckResult.fail("该地址不在配送区域内");
            }
        }
        
        // 计算配送费
        double deliveryFee = calculateDeliveryFee(distance);
        
        return DeliveryCheckResult.success(distance, deliveryFee);
    }
    
    /**
     * 搜索附近可配送的商家
     */
    public List<MerchantVO> searchNearbyMerchants(double lat, double lon, 
                                                   double radiusKm, String keyword) {
        // 构建WKT格式点
        String pointWkt = String.format("POINT(%f %f)", lon, lat);
        
        // 查询附近商家
        List<Merchant> merchants = merchantMapper.findNearby(
            pointWkt, radiusKm * 1000, 50);
        
        // 转换为VO
        return merchants.stream()
            .filter(m -> keyword == null || m.getMerchantName().contains(keyword))
            .map(m -> {
                MerchantVO vo = new MerchantVO();
                BeanUtils.copyProperties(m, vo);
                vo.setDistance(m.getDistance() / 1000); // 转换为km
                vo.setDeliveryFee(calculateDeliveryFee(m.getDistance()));
                return vo;
            })
            .collect(Collectors.toList());
    }
    
    /**
     * 计算配送费
     */
    private double calculateDeliveryFee(double distanceMeters) {
        double distanceKm = distanceMeters / 1000;
        
        if (distanceKm <= 3) {
            return 0; // 3公里内免配送费
        } else if (distanceKm <= 5) {
            return 5; // 3-5公里5元
        } else if (distanceKm <= 10) {
            return 10; // 5-10公里10元
        } else {
            return 10 + (distanceKm - 10) * 2; // 超过10公里每公里2元
        }
    }
    
    /**
     * 计算球面距离(米)
     */
    private double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
        // 使用Haversine公式
        final double R = 6371000; // 地球半径(米)
        
        double latDistance = Math.toRadians(lat2 - lat1);
        double lonDistance = Math.toRadians(lon2 - lon1);
        
        double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2)
                + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
                * Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2);
        
        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
        
        return R * c;
    }
}

五、方案对比与选型

5.1 技术方案对比

┌─────────────────────────────────────────────────────────────────────┐
│                    地理空间处理方案对比                              │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  方案              │ 精度    │ 性能    │ 复杂度 │ 适用场景         │
│  ──────────────────┼─────────┼─────────┼────────┼──────────────────│
│  MySQL空间索引     │ 高      │ 中      │ 低     │ 中小规模数据     │
│  Redis Geo         │ 中      │ 高      │ 低     │ 附近搜索、缓存   │
│  GeoHash           │ 中      │ 高      │ 中     │ 分布式系统       │
│  PostGIS           │ 高      │ 高      │ 中     │ 专业GIS应用      │
│  Elasticsearch Geo │ 高      │ 高      │ 中     │ 全文+空间搜索    │
│                                                                     │
│  选型建议:                                                         │
│  - 已有MySQL,数据量<1000万 → MySQL空间索引                       │
│  - 纯附近搜索,高并发 → Redis Geo                                  │
│  - 需要复杂空间分析 → PostGIS                                      │
│  - 需要全文+空间联合搜索 → Elasticsearch                           │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

5.2 混合架构方案

/**
 * 混合空间数据服务
 * MySQL存主数据,Redis做缓存,ES做搜索
 */
@Service
@Slf4j
public class HybridGeoService {
    
    @Autowired
    private MerchantRepository merchantRepository;
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private ElasticsearchRestTemplate elasticsearchTemplate;
    
    /**
     * 初始化商家位置到Redis
     */
    @PostConstruct
    public void initMerchantLocations() {
        String key = "merchant:location";
        
        // 清空旧数据
        redisTemplate.delete(key);
        
        // 加载所有商家位置
        List<Merchant> merchants = merchantRepository.findAll();
        for (Merchant m : merchants) {
            redisTemplate.opsForGeo().add(key,
                new Point(m.getLocation().getX(), m.getLocation().getY()),
                m.getMerchantId().toString());
        }
        
        log.info("Initialized {} merchant locations to Redis", merchants.size());
    }
    
    /**
     * 附近搜索(Redis + MySQL)
     */
    public List<MerchantVO> searchNearby(double lat, double lon, double radiusKm) {
        // 1. 从Redis获取附近商家ID
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = 
            redisTemplate.opsForGeo().radius("merchant:location",
                new Circle(new Point(lon, lat), new Distance(radiusKm, Metrics.KILOMETERS)),
                RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
                    .includeDistance()
                    .sortAscending()
                    .limit(50)
            );
        
        if (results == null || results.getContent().isEmpty()) {
            return Collections.emptyList();
        }
        
        // 2. 获取商家ID列表
        List<Long> merchantIds = results.getContent().stream()
            .map(r -> Long.valueOf(r.getContent().getName()))
            .collect(Collectors.toList());
        
        // 3. 从MySQL查询商家详情
        List<Merchant> merchants = merchantRepository.findAllById(merchantIds);
        Map<Long, Merchant> merchantMap = merchants.stream()
            .collect(Collectors.toMap(Merchant::getMerchantId, m -> m));
        
        // 4. 组装结果(保持Redis返回的顺序)
        List<MerchantVO> vos = new ArrayList<>();
        for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : results) {
            Long id = Long.valueOf(result.getContent().getName());
            Merchant m = merchantMap.get(id);
            if (m != null) {
                MerchantVO vo = new MerchantVO();
                BeanUtils.copyProperties(m, vo);
                vo.setDistance(result.getDistance().getValue());
                vos.add(vo);
            }
        }
        
        return vos;
    }
}

六、最佳实践与注意事项

6.1 最佳实践清单

┌─────────────────────────────────────────────────────────────────────┐
│                    地理空间数据处理最佳实践                          │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  【数据存储】                                                       │
│  □ 1. 始终使用SRID 4326(WGS84)存储经纬度                         │
│  □ 2. 经度在前,纬度在后(X, Y)                                   │
│  □ 3. 建立SPATIAL INDEX索引                                      │
│  □ 4. 定期验证坐标数据有效性                                       │
│                                                                     │
│  【查询优化】                                                       │
│  □ 1. 先使用MBR过滤,再精确计算距离                                │
│  □ 2. 限制返回结果数量,避免大数据量传输                           │
│  □ 3. 缓存热门区域查询结果                                         │
│  □ 4. 分页查询时避免使用OFFSET,使用上次最大距离                   │
│                                                                     │
│  【精度处理】                                                       │
│  □ 1. 距离计算使用ST_Distance_Sphere(球面距离)                   │
│  □ 2. 注意浮点数精度问题,比较时使用容差                           │
│  □ 3. 边界情况处理(跨越180度经线)                                │
│                                                                     │
│  【性能监控】                                                       │
│  □ 1. 监控空间查询响应时间                                         │
│  □ 2. 定期分析空间索引使用情况                                     │
│  □ 3. 大数据量考虑分区或分表                                       │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

6.2 常见错误

错误问题解决方案
坐标顺序错误经纬度顺序颠倒记住:经度(lon)是X,纬度(lat)是Y
缺少SRID空间函数计算错误始终指定SRID 4326
无空间索引查询性能极差为空间列建立SPATIAL INDEX
平面距离计算长距离误差大使用ST_Distance_Sphere
忽略边界跨越180度经线计算错误使用空间函数处理

系列上一篇JSON与文档存储的高级应用

系列下一篇时序数据处理与分析

知识点测试

读完文章了?来测试一下你对知识点的掌握程度吧!

评论区

使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。

如果评论系统无法加载,请确保:

  • 您的网络可以访问 GitHub
  • giscus GitHub App 已安装到仓库
  • 仓库已启用 Discussions 功能