Otimizando uma pesquisa de localização de loja baseada em proximidade em um host compartilhado?

11

Eu tenho um projeto em que preciso construir um localizador de lojas para um cliente.

Estou usando um tipo de postagem personalizado " restaurant-location" e escrevi o código para geocodificar os endereços armazenados no postmeta usando a API de geocodificação do Google (aqui está o link que codifica geograficamente a Casa Branca dos EUA em JSON e a latitude e a longitude de volta para campos personalizados.

Eu escrevi uma get_posts_by_geo_distance()função que retorna uma lista de postagens na ordem daquelas mais próximas geograficamente, usando a fórmula que encontrei na apresentação de slides deste post . Você pode chamar minha função assim (estou começando com uma "fonte" fixa lat / long):

include "wp-load.php";

$source_lat = 30.3935337;
$source_long = -86.4957833;

$results = get_posts_by_geo_distance(
    'restaurant-location',
    'geo_latitude',
    'geo_longitude',
    $source_lat,
    $source_long);

echo '<ul>';
foreach($results as $post) {
    $edit_url = get_edit_url($post->ID);
    echo "<li>{$post->distance}: <a href=\"{$edit_url}\" target=\"_blank\">{$post->location}</a></li>";
}
echo '</ul>';
return;

Aqui está a get_posts_by_geo_distance()própria função :

function get_posts_by_geo_distance($post_type,$lat_key,$lng_key,$source_lat,$source_lng) {
    global $wpdb;
    $sql =<<<SQL
SELECT
    rl.ID,
    rl.post_title AS location,
    ROUND(3956*2*ASIN(SQRT(POWER(SIN(({$source_lat}-abs(lat.lat))*pi()/180/2),2)+
    COS({$source_lat}*pi()/180)*COS(abs(lat.lat)*pi()/180)*
    POWER(SIN(({$source_lng}-lng.lng)*pi()/180/2),2))),3) AS distance
FROM
    wp_posts rl
    INNER JOIN (SELECT post_id,CAST(meta_value AS DECIMAL(11,7)) AS lat FROM wp_postmeta lat WHERE lat.meta_key='{$lat_key}') lat ON lat.post_id = rl.ID
    INNER JOIN (SELECT post_id,CAST(meta_value AS DECIMAL(11,7)) AS lng FROM wp_postmeta lng WHERE lng.meta_key='{$lng_key}') lng ON lng.post_id = rl.ID
WHERE
    rl.post_type='{$post_type}' AND rl.post_name<>'auto-draft'
ORDER BY
    distance
SQL;
    $sql = $wpdb->prepare($sql,$source_lat,$source_lat,$source_lng);
    return $wpdb->get_results($sql);
}

Minha preocupação é que o SQL seja o mais otimizado possível. O MySQL não pode ordenar por nenhum índice disponível, pois a localização geográfica da fonte é mutável e não há um conjunto finito de áreas geográficas de origem a serem armazenadas em cache. Atualmente, estou perplexo quanto às maneiras de otimizá-lo.

Levando em consideração o que já fiz, a pergunta é: como você otimizaria esse caso de uso?

Não é importante manter tudo o que fiz se uma solução melhor me permitir jogar fora. Estou aberto a considerar quase qualquer solução exceto uma que exige algo como instalar um servidor Sphinx ou qualquer coisa que exija uma configuração personalizada do MySQL. Basicamente, a solução precisa funcionar em qualquer instalação simples do WordPress. (Dito isto, seria ótimo se alguém quisesse listar soluções alternativas para outras pessoas que possam ser mais avançadas e posteriores.)

Recursos Encontrados

Para sua informação, eu pesquisei um pouco sobre isso, em vez de você fazer a pesquisa novamente ou em vez de postar qualquer um desses links como resposta. Vou adiante e incluí-los.

Em relação à pesquisa Sphinx

MikeSchinkel
fonte

Respostas:

6

Qual precisão você precisa? se for uma pesquisa em nível estadual / nacional, talvez você possa fazer uma pesquisa lat-lon para zip e ter uma distância pré-computada da área zip para a área zip do restaurante. Se você precisar de distâncias precisas, isso não será uma boa opção.

Você deve procurar uma solução Geohash , no artigo da Wikipedia há um link para uma biblioteca PHP para codificar a decodificação em lat muito tempo para geohashs.

Aqui você encontra um bom artigo explicando por que e como eles o usam no Google App Engine (código Python, mas fácil de seguir.) Devido à necessidade de usar o geohash no GAE, você pode encontrar boas bibliotecas e exemplos de python.

Como este post do blog explica, a vantagem de usar geohashes é que você pode criar um índice na tabela MySQL nesse campo.

MikeSchinkel
fonte
Obrigado pela sugestão no GeoHash! Definitivamente vou dar uma olhada, mas partindo para o WordCamp Savannah em uma hora, então não posso agora. É um localizador de restaurante para turistas que visitam uma cidade, portanto, 0,1 km provavelmente seria a precisão mínima. Idealmente, seria melhor que isso. Vou editar seus links!
precisa saber é o seguinte
Se você deseja exibir os resultados em um mapa do Google, use a API deles para fazer o código de
Como essa é a resposta mais interessante, vou aceitá-la, mesmo que não tenha tido tempo de pesquisar e experimentar.
MikeSchinkel
9

Pode ser tarde demais para você, mas vou responder de qualquer maneira, com uma resposta semelhante à que dei a essa pergunta relacionada , para que futuros visitantes possam consultar as duas perguntas.

Eu não armazenaria esses valores na tabela de metadados de postagem, ou pelo menos não apenas lá. Você quer uma tabela com post_id, lat, loncolunas, então você pode colocar um índice delat, lon e consulta sobre isso. Não deve ser muito difícil manter-se atualizado com um gancho após salvar e atualizar.

Ao consultar o banco de dados, você define uma caixa delimitadora em torno do ponto inicial, para poder fazer uma consulta eficiente para todos os lat, lonpares entre as bordas norte-sul e leste-oeste da caixa.

Depois de obter esse resultado reduzido, é possível fazer um cálculo de distância mais avançado (direções de direção circulares ou reais) para filtrar os locais que estão nos cantos da caixa delimitadora e, portanto, mais longe do que você deseja.

Aqui você encontra um exemplo de código simples que funciona na área de administração. Você precisa criar a tabela extra de banco de dados. O código é ordenado do mais para o menos interessante.

<?php
/*
Plugin Name: Monkeyman geo test
Plugin URI: http://www.monkeyman.be
Description: Geolocation test
Version: 1.0
Author: Jan Fabry
*/

class Monkeyman_Geo
{
    public function __construct()
    {
        add_action('init', array(&$this, 'registerPostType'));
        add_action('save_post', array(&$this, 'saveLatLon'), 10, 2);

        add_action('admin_menu', array(&$this, 'addAdminPages'));
    }

    /**
     * On post save, save the metadata in our special table
     * (post_id INT, lat DECIMAL(10,5), lon DECIMAL (10,5))
     * Index on lat, lon
     */
    public function saveLatLon($post_id, $post)
    {
        if ($post->post_type != 'monkeyman_geo') {
            return;
        }
        $lat = floatval(get_post_meta($post_id, 'lat', true));
        $lon = floatval(get_post_meta($post_id, 'lon', true));

        global $wpdb;
        $result = $wpdb->replace(
            $wpdb->prefix . 'monkeyman_geo',
            array(
                'post_id' => $post_id,
                'lat' => $lat,
                'lon' => $lon,
            ),
            array('%s', '%F', '%F')
        );
    }

    public function addAdminPages()
    {
        add_management_page( 'Quick location generator', 'Quick generator', 'edit_posts', __FILE__  . 'generator', array($this, 'doGeneratorPage'));
        add_management_page( 'Location test', 'Location test', 'edit_posts', __FILE__ . 'test', array($this, 'doTestPage'));

    }

    /**
     * Simple test page with a location and a distance
     */
    public function doTestPage()
    {
        if (!array_key_exists('search', $_REQUEST)) {
            $default_lat = ini_get('date.default_latitude');
            $default_lon = ini_get('date.default_longitude');

            echo <<<EOF
<form action="" method="post">
    <p>Center latitude: <input size="10" name="center_lat" value="{$default_lat}"/>
        <br/>Center longitude: <input size="10" name="center_lon" value="{$default_lon}"/>
        <br/>Max distance (km): <input size="5" name="max_distance" value="100"/></p>
    <p><input type="submit" name="search" value="Search!"/></p>
</form>
EOF;
            return;
        }
        $center_lon = floatval($_REQUEST['center_lon']);
        $center_lat = floatval($_REQUEST['center_lat']);
        $max_distance = floatval($_REQUEST['max_distance']);

        var_dump(self::getPostsUntilDistanceKm($center_lon, $center_lat, $max_distance));
    }

    /**
     * Get all posts that are closer than the given distance to the given location
     */
    public static function getPostsUntilDistanceKm($center_lon, $center_lat, $max_distance)
    {
        list($north_lat, $east_lon, $south_lat, $west_lon) = self::getBoundingBox($center_lat, $center_lon, $max_distance);

        $geo_posts = self::getPostsInBoundingBox($north_lat, $east_lon, $south_lat, $west_lon);

        $close_posts = array();
        foreach ($geo_posts as $geo_post) {
            $post_lat = floatval($geo_post->lat);
            $post_lon = floatval($geo_post->lon);
            $post_distance = self::calculateDistanceKm($center_lat, $center_lon, $post_lat, $post_lon);
            if ($post_distance < $max_distance) {
                $close_posts[$geo_post->post_id] = $post_distance;
            }
        }
        return $close_posts;
    }

    /**
     * Select all posts ids in a given bounding box
     */
    public static function getPostsInBoundingBox($north_lat, $east_lon, $south_lat, $west_lon)
    {
        global $wpdb;
        $sql = $wpdb->prepare('SELECT post_id, lat, lon FROM ' . $wpdb->prefix . 'monkeyman_geo WHERE lat < %F AND lat > %F AND lon < %F AND lon > %F', array($north_lat, $south_lat, $west_lon, $east_lon));
        return $wpdb->get_results($sql, OBJECT_K);
    }

    /* Geographical calculations: distance and bounding box */

    /**
     * Calculate the distance between two coordinates
     * http://stackoverflow.com/questions/365826/calculate-distance-between-2-gps-coordinates/1416950#1416950
     */
    public static function calculateDistanceKm($a_lat, $a_lon, $b_lat, $b_lon)
    {
        $d_lon = deg2rad($b_lon - $a_lon);
        $d_lat = deg2rad($b_lat - $a_lat);
        $a = pow(sin($d_lat/2.0), 2) + cos(deg2rad($a_lat)) * cos(deg2rad($b_lat)) * pow(sin($d_lon/2.0), 2);
        $c = 2 * atan2(sqrt($a), sqrt(1-$a));
        $d = 6367 * $c;

        return $d;
    }

    /**
     * Create a box around a given point that extends a certain distance in each direction
     * http://www.colorado.edu/geography/gcraft/warmup/aquifer/html/distance.html
     *
     * @todo: Mind the gap at 180 degrees!
     */
    public static function getBoundingBox($center_lat, $center_lon, $distance_km)
    {
        $one_lat_deg_in_km = 111.321543; // Fixed
        $one_lon_deg_in_km = cos(deg2rad($center_lat)) * 111.321543; // Depends on latitude

        $north_lat = $center_lat + ($distance_km / $one_lat_deg_in_km);
        $south_lat = $center_lat - ($distance_km / $one_lat_deg_in_km);

        $east_lon = $center_lon - ($distance_km / $one_lon_deg_in_km);
        $west_lon = $center_lon + ($distance_km / $one_lon_deg_in_km);

        return array($north_lat, $east_lon, $south_lat, $west_lon);
    }

    /* Below this it's not interesting anymore */

    /**
     * Generate some test data
     */
    public function doGeneratorPage()
    {
        if (!array_key_exists('generate', $_REQUEST)) {
            $default_lat = ini_get('date.default_latitude');
            $default_lon = ini_get('date.default_longitude');

            echo <<<EOF
<form action="" method="post">
    <p>Number of posts: <input size="5" name="post_count" value="10"/></p>
    <p>Center latitude: <input size="10" name="center_lat" value="{$default_lat}"/>
        <br/>Center longitude: <input size="10" name="center_lon" value="{$default_lon}"/>
        <br/>Max distance (km): <input size="5" name="max_distance" value="100"/></p>
    <p><input type="submit" name="generate" value="Generate!"/></p>
</form>
EOF;
            return;
        }
        $post_count = intval($_REQUEST['post_count']);
        $center_lon = floatval($_REQUEST['center_lon']);
        $center_lat = floatval($_REQUEST['center_lat']);
        $max_distance = floatval($_REQUEST['max_distance']);

        list($north_lat, $east_lon, $south_lat, $west_lon) = self::getBoundingBox($center_lat, $center_lon, $max_distance);


        add_action('save_post', array(&$this, 'setPostLatLon'), 5);
        $precision = 100000;
        for ($p = 0; $p < $post_count; $p++) {
            self::$currentRandomLat = mt_rand($south_lat * $precision, $north_lat * $precision) / $precision;
            self::$currentRandomLon = mt_rand($west_lon * $precision, $east_lon * $precision) / $precision;

            $location = sprintf('(%F, %F)', self::$currentRandomLat, self::$currentRandomLon);

            $post_data = array(
                'post_status' => 'publish',
                'post_type' => 'monkeyman_geo',
                'post_content' => 'Point at ' . $location,
                'post_title' => 'Point at ' . $location,
            );

            var_dump(wp_insert_post($post_data));
        }
    }

    public static $currentRandomLat = null;
    public static $currentRandomLon = null;

    /**
     * Because I didn't know how to save meta data with wp_insert_post,
     * I do it here
     */
    public function setPostLatLon($post_id)
    {
        add_post_meta($post_id, 'lat', self::$currentRandomLat);
        add_post_meta($post_id, 'lon', self::$currentRandomLon);
    }

    /**
     * Register a simple post type for us
     */
    public function registerPostType()
    {
        register_post_type(
            'monkeyman_geo',
            array(
                'label' => 'Geo Location',
                'labels' => array(
                    'name' => 'Geo Locations',
                    'singular_name' => 'Geo Location',
                    'add_new' => 'Add new',
                    'add_new_item' => 'Add new location',
                    'edit_item' => 'Edit location',
                    'new_item' => 'New location',
                    'view_item' => 'View location',
                    'search_items' => 'Search locations',
                    'not_found' => 'No locations found',
                    'not_found_in_trash' => 'No locations found in trash',
                    'parent_item_colon' => null,
                ),
                'description' => 'Geographical locations',
                'public' => true,
                'exclude_from_search' => false,
                'publicly_queryable' => true,
                'show_ui' => true,
                'menu_position' => null,
                'menu_icon' => null,
                'capability_type' => 'post',
                'capabilities' => array(),
                'hierarchical' => false,
                'supports' => array(
                    'title',
                    'editor',
                    'custom-fields',
                ),
                'register_meta_box_cb' => null,
                'taxonomies' => array(),
                'permalink_epmask' => EP_PERMALINK,
                'rewrite' => array(
                    'slug' => 'locations',
                ),
                'query_var' => true,
                'can_export' => true,
                'show_in_nav_menus' => true,
            )
        );
    }
}

$monkeyman_Geo_instance = new Monkeyman_Geo();
Jan Fabry
fonte
@ Jan : Obrigado pela resposta. Você acha que pode fornecer algum código real mostrando esses implementados?
8118 MikeSchinkel
@ Mike: Foi um desafio interessante, mas aqui está um código que deve funcionar.
Jan Fabry
@ Faban Jan: Legal! Vou dar uma olhada quando voltar ao projeto.
MikeSchinkel 9/10/10
1

Estou atrasado para a festa, mas olhando para trás, esse get_post_metaé realmente o problema aqui, em vez da consulta SQL que você está usando.

Recentemente, tive que fazer uma pesquisa geográfica semelhante em um site que eu corro e, em vez de usar a tabela meta para armazenar lat e lon (o que exige, no máximo, duas junções para procurar e, se você estiver usando get_post_meta, dois bancos de dados adicionais consultas por local), criei uma nova tabela com um tipo de dados POINT de geometria indexada espacialmente.

Minha consulta se parecia muito com a sua, com o MySQL fazendo muito trabalho pesado (deixei de fora as funções trigonométricas e simplifiquei tudo para o espaço bidimensional, porque estava próximo o suficiente para meus propósitos):

function nearby_property_listings( $number = 5 ) {
    global $client_location, $wpdb;

    //sanitize public inputs
    $lat = (float)$client_location['lat'];  
    $lon = (float)$client_location['lon']; 

    $sql = $wpdb->prepare( "SELECT *, ROUND( SQRT( ( ( ( Y(geolocation) - $lat) * 
                                                       ( Y(geolocation) - $lat) ) *
                                                         69.1 * 69.1) +
                                                  ( ( X(geolocation) - $lon ) * 
                                                       ( X(geolocation) - $lon ) * 
                                                         53 * 53 ) ) ) as distance
                            FROM {$wpdb->properties}
                            ORDER BY distance LIMIT %d", $number );

    return $wpdb->get_results( $sql );
}

em que $ client_location é um valor retornado por um serviço público de pesquisa geográfica por IP (usei geoio.com, mas existem vários similares).

Pode parecer complicado, mas ao testá-lo, ele sempre retornou os 5 locais mais próximos de uma tabela de 80.000 linhas em menos de 0,4 segundos.

Até o MySQL lançar a função DISTANCE que está sendo proposta, esta parece ser a melhor maneira que eu encontrei para implementar pesquisas de localização.

Edição: Adicionando a estrutura da tabela para esta tabela específica. É um conjunto de listagens de propriedades, portanto pode ou não ser semelhante a qualquer outro caso de uso.

CREATE TABLE IF NOT EXISTS `rh_properties` (
  `listingId` int(10) unsigned NOT NULL,
  `listingType` varchar(60) collate utf8_unicode_ci NOT NULL,
  `propertyType` varchar(60) collate utf8_unicode_ci NOT NULL,
  `status` varchar(20) collate utf8_unicode_ci NOT NULL,
  `street` varchar(64) collate utf8_unicode_ci NOT NULL,
  `city` varchar(24) collate utf8_unicode_ci NOT NULL,
  `state` varchar(5) collate utf8_unicode_ci NOT NULL,
  `zip` decimal(5,0) unsigned zerofill NOT NULL,
  `geolocation` point NOT NULL,
  `county` varchar(64) collate utf8_unicode_ci NOT NULL,
  `bedrooms` decimal(3,2) unsigned NOT NULL,
  `bathrooms` decimal(3,2) unsigned NOT NULL,
  `price` mediumint(8) unsigned NOT NULL,
  `image_url` varchar(255) collate utf8_unicode_ci NOT NULL,
  `description` mediumtext collate utf8_unicode_ci NOT NULL,
  `link` varchar(255) collate utf8_unicode_ci NOT NULL,
  PRIMARY KEY  (`listingId`),
  KEY `geolocation` (`geolocation`(25))
)

A geolocationcoluna é a única coisa relevante para os propósitos aqui; consiste nas coordenadas x (lon), y (lat) que apenas procuro pelo endereço ao importar novos valores no banco de dados.

maçãs douradas
fonte
Obrigado pelo seguimento. Eu realmente tentei evitar adicionar uma tabela, mas acabei adicionando uma tabela também, embora tentei torná-la mais genérica que o caso de uso específico. Além disso, eu não usei o tipo de dados POINT porque queria ficar com os tipos de dados padrão mais conhecidos; As extensões geográficas do MySQL exigem um bom aprendizado para se sentir confortável. Dito isto, você pode atualizar sua resposta com o DDL da sua tabela que você usou? Eu acho que seria instrutivo para outras pessoas lerem isso no futuro.
MikeSchinkel
0

Apenas pré-calcule as distâncias entre todas as entidades. Eu armazenaria isso em uma tabela de banco de dados por conta própria, com a capacidade de indexar valores.

hakre
fonte
Esse é um número praticamente infinito de discos ...
MikeSchinkel
Infinte? Vejo apenas n ^ 2 aqui, isso não é infinito. Especialmente com mais e mais entradas, o pré-cálculo deve ser cada vez mais considerado.
hakre
Praticamente infinito. Dado Lat / Long com uma precisão de 7 casas decimais que daria 6.41977E + 17 registros. Sim, não temos muitos, mas teríamos muito mais do que seria razoável.
precisa saber é o seguinte
Infinito é um termo bem definido, e adicionar adjetivos a ele não muda muito. Mas eu sei o que você quer dizer, você acha que isso é demais para calcular. Se você não está adicionando fluentemente uma quantidade enorme de novos locais ao longo do tempo, esse pré-cálculo pode ser feito passo a passo por um trabalho que é separado da sua aplicação em segundo plano. A precisão não altera o número de cálculos. O número de locais faz. Mas talvez eu tenha interpretado mal essa parte do seu comentário. Por exemplo, 64 locais resultarão em 4 096 (ou 4 032 para n * (n-1)) cálculos e, portanto, registros.
hakre