PHP如何实现高性能地理位置附近搜索与距离排序功能

欢迎来到“代码巫师”地理定位高并发实战讲座

各位绅士、淑女,以及那些整天在屏幕前敲代码敲得手指抽筋的“极客”们,大家好!

我是你们的老朋友,一个既喜欢用PHP写逻辑,又喜欢用数学公式折磨自己的技术专家。

今天,我们要聊的话题非常硬核,也非常“接地气”。大家打开手机,打开外卖APP,或者打开地图APP,你会看到一个神奇的功能:“附近的人”“距离你5公里的咖啡店”“附近的加油站”

这背后究竟是什么魔法?难道是上帝拿着放大镜在给你找东西吗?当然不是!这是计算机科学的胜利,是数学的胜利,是数据库索引的胜利,当然,也是我们PHP程序员智慧的结晶。

但是,这里有一个巨大的坑。如果你的数据库里有几百万条数据,你要找离我1公里内的人,你怎么做?你难道要写个循环,把每一行数据都拿出来,用计算器算一遍距离,然后存到数组里,最后再排序?那样的话,你的服务器大概会在半小时后吐血而亡,而你的用户早就饿着肚子去隔壁买煎饼果子了。

所以,今天这场讲座,我们就来深度剖析:如何用PHP,玩转高性能地理位置搜索与距离排序

准备好了吗?让我们把地图铺开,开始这场“寻宝”之旅。


第一章:距离计算的“老黄牛”——Haversine 公式

首先,我们得聊聊那个让我们又爱又恨的数学公式。

如果你在面试中被问到“怎么算地球两点距离”,你肯定脱口而出:Haversine 公式。没错,这是标准答案。它考虑了地球是圆的(虽然是个扁球体,但我们为了生活简单点,通常当它是圆的)。

公式大概长这样(别怕,我不考你背诵):

$$a = sin^2(Delta phi / 2) + cos phi_1 cdot cos phi_2 cdot sin^2(Delta lambda / 2)$$
$$c = 2 cdot text{atan2}(sqrt{a}, sqrt{1 – a})$$
$$d = R cdot c$$

其中 $phi$ 是纬度,$lambda$ 是经度,$R$ 是地球半径(约6371公里)。

在PHP里,写个函数把它算出来,那是基本功。但这仅仅是基本功。因为数学计算是很昂贵的。

/**
 * 使用 Haversine 公式计算两点间距离 (米)
 * 
 * @param float $lat1 起点纬度
 * @param float $lon1 起点经度
 * @param float $lat2 终点纬度
 * @param float $lon2 终点经度
 * @return float 距离
 */
function calculateDistance($lat1, $lon1, $lat2, $lon2) {
    $earthRadius = 6371000; // 地球半径,单位:米

    $dLat = deg2rad($lat2 - $lat1);
    $dLon = deg2rad($lon2 - $lon1);

    $a = sin($dLat / 2) * sin($dLat / 2) +
         cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
         sin($dLon / 2) * sin($dLon / 2);

    $c = 2 * atan2(sqrt($a), sqrt(1 - $a));

    return $earthRadius * $c;
}

// 测试一下
$distance = calculateDistance(39.9042, 116.4074, 31.2304, 121.4737); // 北京到上海
echo "距离: " . round($distance, 2) . " 米";

这段代码写得漂不漂亮?漂亮!但它有个致命缺点:

如果你的数据库里有1万条数据,这1万次计算可能只需要0.1秒,感觉不到。但如果数据量到了100万,那就是100万次循环计算三角函数。这时候,PHP引擎都在角落里瑟瑟发抖了。

所以,直接在数据库里写 ORDER BY distance,那简直是“自杀式袭击”。


第二章:空间索引的“护目镜”——MySQL Spatial Index

我们不得不提一下数据库里的“空间索引”。这就像是给数据戴上了一副护目镜,让数据库能瞬间看清哪个点在哪个点的“隔壁”。

在MySQL 5.6及以上版本,或者PostgreSQL(带PostGIS插件),都支持这种功能。

原理是: 数据库把经纬度转换成一种特殊的几何形状(比如Point),然后建立一种“树状结构”的索引,让你能迅速锁定一个区域。

PHP怎么玩?

// 1. 创建表,使用 spatial 类型
// CREATE TABLE locations (
//     id INT AUTO_INCREMENT PRIMARY KEY,
//     name VARCHAR(100),
//     geo GEOMETRY NOT NULL SRID 4326
// );

// 2. 插入数据
// INSERT INTO locations (name, geo) VALUES ('北京', ST_GeomFromText('POINT(116.404 39.915)'));

// 3. PHP 查询
$stmt = $pdo->prepare("SELECT name, ST_Distance_Sphere(geo, ST_GeomFromText(:point)) as distance FROM locations WHERE ST_Distance_Sphere(geo, ST_GeomFromText(:point)) <= :radius ORDER BY distance ASC LIMIT 10");
$stmt->execute([
    'point' => 'POINT(116.404 39.915)',
    'radius' => 1000
]);
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);

老专家点评:
这种方法的优点是通用,你不需要换数据库,也不需要复杂的代码。但是,在数据量极大(比如千万级)的时候,空间索引的查询性能会下降,尤其是在没有针对地理查询进行特殊硬件优化的情况下,它依然可能像蜗牛一样慢。

而且,如果你的业务逻辑比较复杂,需要结合用户的其他属性(比如“年龄小于25且性别为女且距离小于5公里”)进行查询,纯SQL的空间查询会变得非常复杂,甚至不可维护。

所以,纯SQL方案,我们只能给个“良”的评价。


第三章:GeoHash 的“马赛克魔法”

现在,让我们进入主角环节。今天的高性能核心——GeoHash

你有没有注意过,地图上的定位框,有时候是一串字母和数字,比如 wx4erg。这就是 GeoHash

它的核心思想是:把二维的经纬度坐标,编码成一段字符串。

想象一下,你有一张巨大的世界地图。你把它切成一个个小格子,就像俄罗斯方块一样。每一个格子都有个名字,比如 wx4 代表中国北京附近的一个大区域,wx4e 代表北京的一个小区,wx4er 代表你家里。

为什么它快?
因为字符串有“前缀匹配”的特性。

  • wx4er... 包含在 wx4e... 里面。
  • 如果你找“附近的人”,你只需要找到一个包含你当前位置的网格,然后把这个网格周围8个格子里的数据都取出来,算一下距离就行了。

这比遍历整个数据库快一万倍!

3.1 原生PHP实现 GeoHash(不依赖库)

为了展示技术深度,我们手写一个简易版的GeoHash编码器。

GeoHash利用了二进制的“位编码”。经度用偶数位,纬度用奇数位,交叉排列。

class GeoHashEncoder {
    private $base32 = "0123456789bcdefghjkmnpqrstuvwxyz";

    /**
     * 编码
     */
    public function encode($lat, $lon, $precision = 12) {
        $latRange = [-90, 90];
        $lonRange = [-180, 180];
        $binary = "";
        $evenBit = true;

        for ($i = 0; $i < $precision; $i++) {
            if ($evenBit) {
                $mid = ($lonRange[0] + $lonRange[1]) / 2;
                if ($lon > $mid) {
                    $binary .= "1";
                    $lonRange[0] = $mid;
                } else {
                    $binary .= "0";
                    $lonRange[1] = $mid;
                }
            } else {
                $mid = ($latRange[0] + $latRange[1]) / 2;
                if ($lat > $mid) {
                    $binary .= "1";
                    $latRange[0] = $mid;
                } else {
                    $binary .= "0";
                    $latRange[1] = $mid;
                }
            }
            $evenBit = !$evenBit;
        }

        // 二进制转 Base32
        $base32 = "";
        for ($i = 0; $i < strlen($binary); $i += 5) {
            $chunk = substr($binary, $i, 5);
            $index = bindec($chunk);
            $base32 .= $this->base32[$index];
        }

        return $base32;
    }
}

$encoder = new GeoHashEncoder();
echo "北京坐标 GeoHash: " . $encoder->encode(39.9042, 116.4074, 8); // wx4ngn2v

3.2 GeoHash 的范围查询

光有编码还不够,我们得能根据一个GeoHash找范围。

假设你有个用户,他的GeoHash是 wx4ngn2v
你需要找他的邻居
邻居包括:

  • 前一个字符
  • 后一个字符
  • 字符交换(GeoHash有个特性,如果某一位是0,把它变成1,邻居就会变;如果是1,变0)

这就有点绕了。实际上,有一个更简单的办法:前缀匹配

如果你有个数据库,存储了所有的GeoHash。你可以建一个普通的B-Tree索引。

CREATE INDEX idx_geohash ON locations(geohash);

然后查询逻辑变成这样:

SELECT * FROM locations 
WHERE geohash LIKE 'wx4ngn2v%' 
   OR geohash LIKE 'wx4ngn2w%'
   OR geohash LIKE 'wx4ngn2x%'
   -- ... 需要枚举周围8个格子
   ORDER BY ... 

虽然这种方法比全表扫描快,但你需要枚举邻居。如果精度很高(比如12位),邻居的数量是可控的(通常几十个)。

但是,PHP的循环处理这几十个邻居,再结合数据库查询,依然存在网络IO的开销。


第四章:Redis Geo —— 地球上的“特种部队”

各位,如果要让性能提升到极致,我们得把数据库甩到脑后,或者至少把最热的数据搬到内存里。

Redis 2.8版本以后,专门推出了 Geo 模块。这简直是地理搜索界的“核武器”。

Redis把经纬度存在一个ZSet(有序集合)里,Score是GeoHash的数值(二进制数值,不是字符串)。

为什么它快?

  1. 内存存储:数据都在内存里,没有磁盘IO。
  2. 二进制比较:ZSet是有序的,范围查询极快。
  3. 内置算法:Redis底层用到了极高效的算法,直接告诉你哪个点最近。

4.1 PHP 操作 Redis Geo

我们需要安装 predis/predis 库。在PHP里,连接Redis就像喝水一样简单。

require 'vendor/autoload.php';
use PredisClient;

// 连接 Redis (默认端口 6379)
$redis = new Client([
    'scheme' => 'tcp',
    'host'   => '127.0.0.1',
    'port'   => 6379,
]);

/**
 * 模拟添加大量用户数据
 */
function seedData($redis, $count = 100000) {
    $redis->del('users:locations'); // 清空数据

    for ($i = 0; $i < $count; $i++) {
        // 随机生成经纬度 (比如在某个区域内)
        $lat = rand(116, 117) + rand(0, 9999) / 10000;
        $lon = rand(39, 40) + rand(0, 9999) / 10000;
        $username = "user_" . $i;

        // GEOADD key longitude latitude member [longitude latitude member ...]
        $redis->geoadd('users:locations', $lon, $lat, $username);
    }
    echo "已添加 {$count} 条数据n";
}

// 执行一下
// seedData($redis, 50000);

4.2 核心查询:距离排序

现在,我要找离用户 user_0 最近的人。

/**
 * 查找附近的人
 * 
 * @param Redis $redis
 * @param string $member 当前用户
 * @param float $radius 搜索半径 (米)
 * @param string $unit 单位 (m, km, ft, mi)
 * @param int $count 返回数量
 */
function findNearbyUsers($redis, $member, $radius = 1000, $unit = 'm', $count = 10) {
    // GEOSEARCH key longitude latitude BYRADIUS radius unit [WITHCOORD] [WITHDIST] [COUNT count] [ASC|DESC] [ANY]
    // 或者旧版本的 GEOPOS, GEOHASH, GEOSEARCH

    $results = $redis->georadius(
        'users:locations', 
        $lon, 
        $lat, 
        $radius, 
        $unit,
        [
            'WITHDIST' => true, // 包含距离
            'WITHCOORD' => true, // 包含坐标
            'COUNT' => $count,
            'ORDER' => 'ASC' // 按距离升序排列
        ]
    );

    return $results;
}

老专家点评:
注意那个 WITHDIST 参数。Redis会自动帮你算好距离,并且排好序!这行代码返回的数据结构是:

[
    ['user_1', 500.5, ['39.9', '116.4']], // [member, distance, [lat, lon]]
    ['user_2', 800.1, ['39.91', '116.41']],
]

这就是你要的“高性能附近搜索”。

4.3 性能对比测试

让我们写个简单的基准测试来感受一下Redis的速度。

function benchmark($redis) {
    $start = microtime(true);

    // 查找附近 5000 米,返回 100 个人
    $res = $redis->georadius('users:locations', 116.4, 39.9, 5000, 'm', [
        'WITHDIST' => true,
        'COUNT' => 100
    ]);

    $end = microtime(true);

    echo "查询耗时: " . round(($end - $start) * 1000, 2) . " 毫秒n";
    echo "找到 " . count($res) . " 个结果n";

    // 打印前3个结果
    foreach (array_slice($res, 0, 3) as $item) {
        echo "距离: {$item[1]} 米, 用户: {$item[0]}n";
    }
}

// benchmark($redis); // 在有数据的情况下运行

如果你的Redis在本地,这个查询通常在 1ms 到 5ms 之间完成。无论是50条数据还是50万条数据,速度基本一致。

这就是内存索引的威力!


第五章:终极优化——网格索引

虽然Redis很快,但它不是万能的。如果数据量到了几千万甚至上亿,Redis的内存占用也会爆炸。而且,如果你需要做复杂的业务关联(比如:查出附近的人,然后查他们的订单,再查他们的等级,最后过滤),Redis的数据结构(String或ZSet)存不下那么多关联信息。

这时候,我们需要一个数据库层面的优化方案,也就是网格索引

原理:
我们不存储GeoHash字符串,而是计算一个 网格ID
假设我们取经纬度的前5位小数作为精度。
比如 39.90420 -> 39904。这代表一个很小的格子。

class GridIndexer {
    // 假设我们用经纬度各取4位,组合成一个整数ID
    // 格子大小约 11米 x 11米 (因为 0.0001度 约等于 11米)
    private static $precision = 4; 

    public static function getGridId($lat, $lon) {
        // 将浮点数转换为整数
        $latGrid = (int)($lat * pow(10, self::$precision));
        $lonGrid = (int)($lon * pow(10, self::$precision));

        // 组合成一个大整数,或者用字符串拼接
        // 为了性能,通常用整数,但要注意溢出
        return $latGrid * 1000000 + $lonGrid;
    }
}

查询逻辑:

  1. 计算当前坐标的 grid_id
  2. 在数据库中查询 grid_id 相同,或者 grid_id +/- 1 的所有记录。
    • grid_id + 1 (例如:39904 + 1 = 39905,代表下一个格子)
    • grid_id - 1 (例如:39904 – 1 = 39903)
    • grid_id + 1000000 (代表水平方向相邻)
    • grid_id - 1000000 (代表水平方向相邻)
    • grid_id + 999999 (斜向)
    • grid_id + 1000001 (斜向)
    • grid_id - 999999 (斜向)
    • grid_id - 1000001 (斜向)

这就产生了邻居查询

PHP 实现:

function searchByGrid($pdo, $lat, $lon, $radiusMeters = 1000) {
    // 1. 计算当前网格
    $currentGrid = GridIndexer::getGridId($lat, $lon);

    // 2. 计算需要跨越多少个格子 (半径 / 格子大小)
    // 假设精度4位,格子大小约11米
    $gridSize = 11; 
    $span = ceil($radiusMeters / $gridSize);

    // 3. 构建SQL查询
    $sql = "SELECT *, 
            (6371000 * acos(cos(radians(:lat)) * cos(radians(lat)) * cos(radians(lon) - radians(:lon)) + sin(radians(:lat)) * sin(radians(lat)))) AS distance
            FROM users 
            WHERE grid_id IN (";

    $params = [':lat' => $lat, ':lon' => $lon];
    $placeholders = [];

    for ($i = -1; $i <= 1; $i++) {
        for ($j = -1; $j <= 1; $j++) {
            // 这里的计算逻辑取决于grid_id是如何组合的
            // 简单起见,我们假设grid_id = lat_grid * 1000000 + lon_grid
            $targetGrid = $currentGrid + ($i * 1000000) + $j;
            $placeholders[] = $targetGrid;
        }
    }

    $sql .= implode(',', $placeholders) . ") 
            HAVING distance <= :radius 
            ORDER BY distance ASC";

    $params[':radius'] = $radiusMeters;

    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll();
}

老专家点评:
这种方法(通常称为“桶索引” Bucket Index 或 Grid Index)是处理超大规模数据(如地图点、滴滴司机、外卖骑手)的最佳实践。
它结合了数据库的强大关联能力和索引的快速查找能力。

优缺点总结:

  • 优点:内存占用可控,支持复杂的SQL关联查询(Join),通用性极强。
  • 缺点:查询时需要计算一定数量的网格(通常是几十到几百个),然后过滤掉在半径外的点。这在PHP中计算几百次距离是很快的,但在数据库里,这个SQL可能有点长。

第六章:实战综合案例——构建“超级外卖”API

好了,理论讲得够多了,口水也干了。让我们来写一个完整的PHP类,模拟一个外卖APP的API。

这个API将具备以下能力:

  1. 注册骑手(插入数据)。
  2. 订单派送(找到最近的骑手)。
  3. 用户下单(找到附近的商家)。

我们将使用 MySQL + 空间索引 来存数据,使用 PHP 业务逻辑 来做查询。为什么不用Redis?因为演示代码中部署Redis环境太麻烦,而且MySQL的空间索引配合正确的索引,对于中小型并发(每秒几十个请求)已经绰绰有余了。我们追求的是“高性能”,而不是“极致的每秒10万次请求”。

6.1 数据库准备

CREATE TABLE `riders` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) DEFAULT NULL,
  `latitude` double DEFAULT NULL COMMENT '纬度',
  `longitude` double DEFAULT NULL COMMENT '经度',
  `geo` point NOT NULL COMMENT '空间点',
  PRIMARY KEY (`id`),
  SPATIAL KEY `idx_geo` (`geo`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 插入几条测试数据
-- 注意:MySQL空间点格式 'POINT(lng lat)'
INSERT INTO `riders` (`name`, `latitude`, `longitude`, `geo`) VALUES 
('张三', 39.9042, 116.4074, ST_GeomFromText('POINT(116.4074 39.9042)')),
('李四', 39.9150, 116.4040, ST_GeomFromText('POINT(116.4040 39.9150)')),
('王五', 39.9200, 116.4100, ST_GeomFromText('POINT(116.4100 39.9200)')),
('赵六', 39.8900, 116.3800, ST_GeomFromText('POINT(116.3800 39.8900)'));

6.2 PHP 服务类实现

class RiderService {
    private $pdo;

    public function __construct($dsn, $username, $password) {
        $this->pdo = new PDO($dsn, $username, $password);
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    }

    /**
     * 派单:找到距离用户最近的骑手
     * @param float $userLat 用户纬度
     * @param float $userLng 用户经度
     * @param float $radius 半径(米)
     * @param int $limit 限制数量
     * @return array
     */
    public function findNearestRider($userLat, $userLng, $radius = 2000, $limit = 1) {
        $sql = "SELECT 
                    id, 
                    name,
                    ST_Distance_Sphere(geo, ST_GeomFromText(:geo)) AS distance,
                    ST_X(geo) AS lng,
                    ST_Y(geo) AS lat
                FROM riders 
                WHERE ST_Distance_Sphere(geo, ST_GeomFromText(:geo)) <= :radius
                ORDER BY distance ASC 
                LIMIT :limit";

        // 注意:ST_X 和 ST_Y 用于提取点的X(经度)和Y(纬度)

        $stmt = $this->pdo->prepare($sql);

        // ST_GeomFromText 需要格式 'POINT(lng lat)'
        $geoText = "POINT({$userLng} {$userLat})";

        $stmt->bindParam(':geo', $geoText);
        $stmt->bindValue(':radius', $radius, PDO::PARAM_INT);
        $stmt->bindParam(':limit', $limit, PDO::PARAM_INT);

        $stmt->execute();

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * 附近商家搜索
     * @param float $lat
     * @param float $lng
     * @param float $radius
     * @return array
     */
    public function findNearbyShops($lat, $lng, $radius) {
        $sql = "SELECT 
                    id, 
                    name, 
                    address,
                    ST_Distance_Sphere(geo, ST_GeomFromText(:geo)) AS distance
                FROM shops 
                WHERE ST_Distance_Sphere(geo, ST_GeomFromText(:geo)) <= :radius
                ORDER BY distance ASC 
                LIMIT 20";

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([
            ':geo' => "POINT({$lng} {$lat})",
            ':radius' => $radius
        ]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * 高级:结合业务逻辑筛选
     * 找到距离500米内,且评分大于4.5,且状态为“空闲”的骑手
     */
    public function findOptimalRider($lat, $lng, $radius = 1000) {
        $sql = "SELECT 
                    r.id, 
                    r.name,
                    r.rating,
                    ST_Distance_Sphere(r.geo, ST_GeomFromText(:geo)) AS distance
                FROM riders r
                WHERE ST_Distance_Sphere(r.geo, ST_GeomFromText(:geo)) <= :radius
                AND r.status = 'free'
                AND r.rating > 4.5
                ORDER BY distance ASC, r.rating DESC
                LIMIT 1";

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([
            ':geo' => "POINT({$lng} {$lat})",
            ':radius' => $radius
        ]);

        return $stmt->fetch(PDO::FETCH_ASSOC);
    }
}

// --- 使用示例 ---
try {
    $service = new RiderService("mysql:host=localhost;dbname=test", "root", "password");

    // 模拟用户位置:天安门
    $userLat = 39.9042;
    $userLng = 116.4074;

    // 1. 找最近的骑手
    $riders = $service->findNearestRider($userLat, $userLng, 3000, 5);

    echo ">>> 找到最近的5个骑手:n";
    foreach ($riders as $r) {
        echo "- {$r['name']} (距离: " . round($r['distance'], 2) . "米)n";
    }

    echo "n>>> 找到最优骑手(评分高+距离近):n";
    $optimal = $service->findOptimalRider($userLat, $userLng, 3000);
    if ($optimal) {
        echo "- {$optimal['name']} (距离: " . round($optimal['distance'], 2) . "米, 评分: {$optimal['rating']})n";
    } else {
        echo "没有找到合适的骑手。n";
    }

} catch (PDOException $e) {
    echo "Error: " . $e->getMessage();
}

运行结果分析:
这段代码利用了MySQL的空间索引。你会看到查询时间通常在 10ms – 50ms 之间。对于一次HTTP请求来说,这个延迟是完全可以接受的。而且,你可以随意修改SQL,加入 AND status = 1 或者 ORDER BY rating DESC,不需要像GeoHash那样去写复杂的边界计算代码。


第七章:避坑指南与性能陷阱

作为一名资深专家,我必须告诉你们,地理搜索里全是坑。如果你掉进去了,代码写得再漂亮也没用。

1. 经度与纬度的顺序

这是初学者最容易犯的错误。在数据库里存 lat, lon,但在传给 POINT() 函数时,必须是 lon, lat

  • SQL: POINT(经度 纬度)
  • 别搞反了,不然你的点会出现在地球的另一端。

2. 球体与平面

Haversine公式是基于球体的。如果你搜索的范围非常巨大(比如跨越半个地球),或者你非常极其在意精度,球体公式没问题。
但是,如果你的搜索范围很小(比如只在一个小区内),我们可以使用平面近似公式,速度会快一丢丢。
公式:$d = sqrt{(x2-x1)^2 + (y2-y1)^2}$ (乘上缩放比例即可)。

3. 零点问题

经度在北极点是 0,在北极点附近,经度没有意义(所有经度都汇聚一点)。
如果你的搜索点在北极点附近,或者跨越了180度经线(国际日期变更线),直接计算距离可能会出Bug。MySQL的 ST_Distance_Sphere 处理得比较好,但要心里有数。

4. 索引失效

如果你没有给 geo 字段加上 SPATIAL KEY,上面的查询就会退化为全表扫描。
记得检查 SHOW INDEX FROM table;

5. 数据一致性问题

如果骑手正在移动,怎么处理?
在Redis方案中,你可以设置一个TTL,或者实时更新ZSet。
在MySQL方案中,通常通过应用层逻辑,比如骑手每5分钟上报一次位置(或者实时上报到Redis),然后定时同步到MySQL(异步)。千万别在每次移动都直接UPDATE数据库,那是性能杀手。


第八章:Redis vs MySQL —— 到底选谁?

这是最后的决策时刻。

维度 MySQL (Spatial Index) Redis (Geo Module)
数据量 适合百万级及以下(磁盘IO是瓶颈) 适合千万级以上(内存无敌)
数据关联 极强(可以直接Join订单表、用户表) 弱(需要将关联数据放在Redis Hash或另建库)
查询速度 10ms – 50ms 1ms – 5ms
实现复杂度 低(标准SQL) 低(Redis命令)
资源占用 中(需要磁盘存储) 高(内存昂贵)

我的建议:

  1. 起步阶段/中型项目:直接上 MySQL Spatial Index。配合上面的PHP类,代码简单,维护容易,性能足够。
  2. 大型项目/超实时双写策略。Redis存热点数据(最近移动的),MySQL存全量数据。
    • 客户端请求 -> 查Redis -> 如果有结果直接返回 -> 如果没有,查MySQL -> 查到后写Redis -> 返回。

结语:做一个高效的“探险家”

各位,今天我们从Haversine公式讲到了Redis的ZSet,从简单的循环讲到了空间索引。

高性能地理搜索的秘诀不是在于你会不会那个复杂的数学公式,而在于你懂不懂数据结构,懂不懂空间划分

记住:

  • 数据量小,用 GeoHashMySQL Spatial
  • 数据量大,用 Redis Geo
  • 数据量爆炸且需复杂关联,用 网格索引

写代码就像探险。不要拿着手电筒(简单循环)漫无目的地乱走,要准备好地图(索引)和望远镜(优化算法)。

希望今天的讲座能帮你在下次写“附近的人”功能时,不再写出那个会让服务器CPU飙红的“超慢”代码。

如果你在实战中遇到了什么坑,或者你有更骚的优化思路,欢迎在评论区留言。我是你们的老朋友,我们下次再见!记得给代码加注释,给服务器降降温。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注