PostgreSQL+GeoHash地图点位聚合
PG数据库安装扩展
需要用到pg数据库的空间扩展postgis,在进行操作之前需要在数据库中安装扩展。
CREATE EXTENSION postgis;
CREATE EXTENSION postgis_topology;
CREATE EXTENSION postgis_geohash;
GeoHash
GeoHash是一种地址编码方法。他能够把二维的空间经纬度数据编码成一个字符串。具体原理这里不再详细说明,GeoHash算法大体上分为三步:
- 将经纬度变成二进制
- 将经纬度的二进制合并
- 通过Base32对合并后的二进制进行编码
Geohash比直接用经纬度的高效很多,而且使用者可以发布地址编码,既能表明自己位于北海公园附近,又不至于暴露自己的精确坐标,有助于隐私保护。
- GeoHash用一个字符串表示经度和纬度两个坐标。在数据库中可以实现在一列上应用索引(某些情况下无法在两列上同时应用索引)
- GeoHash表示的并不是一个点,而是一个矩形区域
- GeoHash编码的前缀可以表示更大的区域。例如wx4g0ec1,它的前缀wx4g0e表示包含编码wx4g0ec1在内的更大范围。 这个特性可以用于附近地点搜索
- 编码越长,表示的范围越小,位置也越精确。因此我们就可以通过比较GeoHash匹配的位数来判断两个点之间的大概距离
建表
在创建数据库表时,表中除了经纬度字段以外,再创建两个字段:
① 经纬度对应的Geometry字段(类型:geometry)
② 经纬度对应的geoHash值字段(类型:varchar)
如:alter table 表名 add 字段名 geometry(point, 4326);
// 创建geometry字段
alter table 表名 add 字段名 varchar;
// 创建geoHash字段
JPA中定义
@Type(type="jts_geometry")
@Column(name="geometry",columnDefinition = "geometry(Point,4326)")
@JsonIgnore
private Geometry geometry; // 实体类的Geometry字段
根据经纬度计算 geometry 和 geoHash
Java生成geometry和geoHash
geometry字段 和 geoHash字段均可以在java代码中根据经纬度生成。
根据经纬度生成geometry
使用org.locationtech.jts.io
包下的WKTReader
类,可以根据经纬度生成Geometry对象。
String wkt = "POINT("+longitude+" "+latitude+")"; // longitude 经度,latitude纬度
WKTReader wktReader = new WKTReader();
Geometry geometry = wktReader.read(wkt); // Geometry对象
if(geometry!=null) {
geometry.setSRID(4326);
}
根据经纬度生成geoHash
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class GeoHashUtil {
public final double Max_Lat = 90;
public final double Min_Lat = -90;
public final double Max_Lng = 180;
public final double Min_Lng = -180;
private final String[] base32Lookup = {
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "b", "c", "d", "e", "f", "g", "h", "j", "k",
"m", "n", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"
};
/**
* 根据geoHash串获取中心点经纬度
* @param geoHashCode
* @return lng->x lat->y
*/
public double[] getSpaceCoordinate(String geoHashCode) {
if(StringUtils.isBlank(geoHashCode)){
return new double[2];
}
List<Integer> list = base32Decode(geoHashCode);
String str = convertToIndex(list);
GeoHashPoint geoHashPoint = splitLatAndLng(str);
double y = revert(Min_Lat, Max_Lat, geoHashPoint.getLatList());
double x = revert(Min_Lng, Max_Lng, geoHashPoint.getLngList());
return new double[]{x, y};
}
/**
* 根据精度获取GeoHash串
* @param lng 经度 x
* @param lat 纬度 y
* @param precise 精度
* @return
*/
public String getGeoHash( double lng, double lat, int precise) {
// 纬度二值串长度
int latLength;
// 经度二值串长度
int lngLength;
if (precise < 1 || precise > 12) {
precise = 12;
}
latLength = (precise * 5) / 2;
if (precise % 2 == 0) {
lngLength = latLength;
} else {
lngLength = latLength + 1;
}
return encode(lat, lng, latLength, lngLength);
}
/**
* 经纬度二值串合并:偶数位放经度,奇数位放纬度,把2串编码组合生成新串
*
*/
public String encode(double lat, double lng, int latLength, int lngLength) {
if (latLength < 1 || lngLength < 1) {
return StringUtils.EMPTY;
}
List<Character> latList = new ArrayList<>(latLength);
List<Character> lngList = new ArrayList<>(lngLength);
// 获取维度二值串
convert(Min_Lat, Max_Lat, lat, latLength, latList);
// 获取经度二值串
convert(Min_Lng, Max_Lng, lng, lngLength, lngList);
StringBuilder sb = new StringBuilder();
for (int index = 0; index < latList.size(); index++) {
sb.append(lngList.get(index)).append(latList.get(index));
}
// 如果二者长度不一样,说明要求的精度为奇数,经度长度比纬度长度大1
if (lngLength != latLength) {
sb.append(lngList.get(lngList.size() - 1));
}
return base32Encode(sb.toString());
}
/**
* 将合并的二值串转为base32串
*
* @param str 合并的二值串
* @return base32串
*/
private String base32Encode(final String str) {
String unit = "";
StringBuilder sb = new StringBuilder();
for (int start = 0; start < str.length(); start = start + 5) {
unit = str.substring(start, start + 5);
sb.append(base32Lookup[convertToIndex(unit)]);
}
return sb.toString();
}
/**
* 每五个一组将二进制转为十进制
*
* @param str 五个为一个unit
* @return 十进制数
*/
private int convertToIndex(String str) {
int length = str.length();
int result = 0;
for (int index = 0; index < length; index++) {
result += str.charAt(index) == '0' ? 0 : 1 << (length - 1 - index);
}
return result;
}
private void convert(double min, double max, double value, int count, List<Character> list) {
if (list.size() > (count - 1)) {
return;
}
double mid = (max + min) / 2;
if (value < mid) {
list.add('0');
convert(min, mid, value, count, list);
} else {
list.add('1');
convert(mid, max, value, count, list);
}
}
/**
* 将二值串转换为经纬度值
*
* @param min 区间最小值
* @param max 区间最大值
* @param list 二值串列表
*/
private double revert(double min, double max, List<String> list) {
double value = 0;
double mid;
if (list.size() <= 0) {
return (max + min) / 2.0;
}
for (String flag : list) {
mid = (max + min) / 2;
if ("0".equals(flag)) {
max = mid;
}
if ("1".equals(flag)) {
min = mid;
}
value = (max + min) / 2;
}
return Double.parseDouble(String.format("%.6f", value));
}
/**
* 分离经度与纬度串
*
* @param latAndLngStr 经纬度二值串
*/
private GeoHashPoint splitLatAndLng(String latAndLngStr) {
GeoHashPoint geoHashPoint = new GeoHashPoint();
// 纬度二值串
List<String> latList = new ArrayList<>();
// 经度二值串
List<String> lngList = new ArrayList<>();
for (int i = 0; i < latAndLngStr.length(); i++) {
// 奇数位,纬度
if (i % 2 == 1) {
latList.add(String.valueOf(latAndLngStr.charAt(i)));
} else {
// 偶数位,经度
lngList.add(String.valueOf(latAndLngStr.charAt(i)));
}
}
geoHashPoint.setLatList(latList);
geoHashPoint.setLngList(lngList);
return geoHashPoint;
}
/**
* 将十进制数转为五个二进制数
*
* @param nums 十进制数
* @return 五个二进制数
*/
private String convertToIndex(List<Integer> nums) {
StringBuilder str = new StringBuilder();
for (Integer num : nums) {
StringBuilder sb = new StringBuilder(Integer.toBinaryString(num));
int length = sb.length();
if (length < 5) {
for (int i = 0; i < 5 - length; i++) {
sb.insert(0, "0");
}
}
str.append(sb);
}
return str.toString();
}
/**
* 将base32串转为合并的二值串
*
* @param str base32串
* @return 合并的二值串
*/
private List<Integer> base32Decode(String str) {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < str.length(); i++) {
String ch = String.valueOf(str.charAt(i));
for (int j = 0; j < base32Lookup.length; j++) {
if (base32Lookup[j].equals(ch)) {
list.add(j);
}
}
}
return list;
}
public static class GeoHashPoint{
/**
* 纬度二值串
*/
private List<String> latList;
/**
* 经度二值串
*/
private List<String> lngList;
public List<String> getLatList() {
return latList;
}
public void setLatList(List<String> latList) {
this.latList = latList;
}
public List<String> getLngList() {
return lngList;
}
public void setLngList(List<String> lngList) {
this.lngList = lngList;
}
}
public static void main(String[] args) {
GeoHashUtil geoHashUtil = new GeoHashUtil();
// 根据精度获取GeoHash串
String geoHash = geoHashUtil.getGeoHash( 120.234133,30.402616, 12);
System.out.println(geoHash);
// 根据geoHash串获取中心点经纬度
double[] spaceCoordinate = geoHashUtil.getSpaceCoordinate(geoHash);
System.out.println(spaceCoordinate[0]+","+spaceCoordinate[1]);
}
}
数据库生成geometry和geoHash
当应用中对数据进行新增修改操作时,可以在代码中生成对应的geometry和geoHash字段的值。但有时候数据不在应用中录入,直接由数据工程师写入的话,就会出现:
① 经纬度新增了但是geometry和geoHash字段的值为空② 经纬度更新了但是没有更新geometry和geoHash字段的值
解决:
① 让数据工程师在写入经纬度的同时帮你存入或更新geometry和geoHash字段的值
② 自己手动执行sql语句,重新生成geometry和geoHash字段的值
③ 基于第2步,为表创建触发器,当对表进行insert或update(update更新经纬度字段)操作时,会自动存入或更新geometry和geoHash字段的值
两个相关函数
① ST_GeomFromText 函数
示例:ST_GeomFromText('POINT(120.1307732446746 30.2678227400894)', 4326)
说明:该函数返回经纬度对应的Geometry对象
② st_geohash 函数
示例:st_geohash(ST_GeomFromText('POINT(120.1307732446746 30.2678227400894)', 4326))
说明: 该函数返回经纬度对应的geoHash值
手动执行sql
手动执行sql, 查询所有经纬度不为空的数据,然后更新每条数据的geometry和geoHash字段的值
-- 1. 函数:更新每条数据的geometry和geoHash字段的值
create or replace function func_update_geodata() returns text
as $$
declare
rec record;
begin
-- 遍历所有经纬度不为空的数据
for rec in select * from 表名 where 经纬度 is not null and 经纬度 != ''
LOOP
update 表名 set pgis_geometry = st_geomfromtext('POINT('|| longitude ||' '|| latitude ||')', 4326),
pgis_geohash = st_geohash(st_geomfromtext('POINT('|| longitude ||' '|| latitude ||')', 4326))
where id = rec.id;
END LOOP;
return 'success';
end;
$$ language plpgsql;
-- 2. 调用
select func_update_geodata();
触发器生成geometry和geoHash
-- 1. 创建触发器函数
create or replace function func_generate_geodata_to_mytab() returns trigger as $body$
begin
update 表名 set pgis_geometry = st_geomfromtext('POINT('|| longitude ||' '|| latitude ||')', 4326),
pgis_geohash = st_geohash(st_geomfromtext('POINT('|| longitude ||' '|| latitude ||')', 4326))
where id = NEW.id;
RETURN NEW;
end;
$body$ language plpgsql;
-- 2. 创建触发器
create trigger trigger_generate_geodata_to_mytab
after insert or update of 经纬度 on 表名
for each row execute procedure func_generate_geodata_to_mytab();
聚合查询
使用JPA的原生sql查询,@Query(nativeQuery = true, value="sql语句")
查询聚合数据
-- 查询聚合数据
select t.geohash as geohash,
st_x(st_pointfromgeohash(t.geohash)) as longitude,
st_y(st_pointfromgeohash(t.geohash)) as latitude,
t.count as aggregationCount
from (
select left(pgis_geohash, ?2) as geohash, count(*) as count
from 表名
where pgis_geohash is not null
and pgis_geohash != ''
and case when ?1 != '' then st_contains(st_geometryfromtext(?1, 4326), pgis_geometry) else 1 = 1 end
group by geohash) t;
/*
1. 【?1】为页面传来的Wkt数据
2. 【?2】为从左边截取geohash的前几位
3. st_x(st_pointfromgeohash('geoHash的值')) 、st_y(st_pointfromgeohash('geoHash的值')) 根据geoHash的值获取聚合后的中心点坐标
*/
查询聚合详情
-- 查询聚合详情
select *
from 表名
where pgis_geohash is not null and pgis_geohash != ''
and left(pgis_geohash, ?2) in (?1);
/*
1. 【?1】为geohash值的集合
2. 【?2】为从左边截取geohash的前几位
*/
优化
geoHash目前聚合后发现在地图上展示效果不好,聚合点在地图上横竖规律排布,因此聚合后我们可以在java代码中进行融合优化处理。
思路:
- 将聚合后的每组聚合点里的点相加,然后除以聚合点的数量得出一个平均值(可以根据情况在这个平均数上乘以一个比例)
- 遍历聚合的list,将大于等于平均值的聚合点和小于平均值的聚合点拆开放在两个集合里(分别为A和B)
- 遍历小于平均值的聚合点集合(A),找到与当前点距离最近的高于平均数的一个聚合点b,把a融合至B
- 遍历B,重新计算并设置融合后的经纬度
/**
* @param list 聚合查询的结果
* @return 优化后的聚合结果
*/
public List optimizationAggregation(List list){
// 所有聚合点数量
long sum = list.stream().mapToLong(T::getCount).sum();
// 获取平均数
long average = sum / list.size();
// 大聚合
List bigList = new ArrayList<>();
List smallList = new ArrayList<>();
for (T item : list) {
if (item.getCount() < average) {
smallList.add(item);
} else {
bigList.add(item);
}
}
Map<T, List<T>> map = new HashMap<>();
for(T item : bigList){
map.put(item, new ArrayList<>());
}
for(T smallItem : smallList){
PGpoint smallPoint = smallItem.getGeoPoint();
int index = -1;
// 在bigList找出距离当前聚合点最近的点
double minDistance = Double.MAX_VALUE;
for(int i = 0; i < bigList.size(); i++){
T bigItem = bigList.get(i);
PGpoint bigPoint = bigItem.getGeoPoint();
double distance = GeometryUtil.getDistance(smallPoint.x, smallPoint.y, bigPoint.x, bigPoint.y);
if(distance >= minDistance){
continue;
}
minDistance = distance;
index = i;
}
T bigItem = bigList.get(index);
List<T> childList = map.get(bigItem);
if(null == childList){
childList = new ArrayList<>();
}
childList.add(smallItem);
map.put(bigItem, childList);
}
// 结果
List<T> result = new ArrayList<>();
map.forEach((key, value)->{
PGpoint parentPoint = key.getGeoPoint();
value = value.stream().sorted(Comparator.comparing(T::getCount, Comparator.reverseOrder())).collect(Collectors.toList());
for(T childItem : value){
PGpoint childPoint = childItem.getGeoPoint();
double difX = parentPoint.x-childPoint.x;
double difY = parentPoint.y-childPoint.y;
double x = parentPoint.x - (new BigDecimal(difX * childItem.getCount()).divide(new BigDecimal(key.getCount()), 15, RoundingMode.HALF_DOWN).doubleValue());
double y = parentPoint.y - (new BigDecimal(difY * childItem.getCount()).divide(new BigDecimal(key.getCount()), 15, RoundingMode.HALF_DOWN).doubleValue());
PGpoint pGpoint = new PGpoint(x, y);
key.setGeoPoint(pGpoint);
key.setLongitude(String.valueOf(x));
key.setLatitude(String.valueOf(y));
key.setCount(key.getCount() + childItem.getCount());
if(null == key.getGeohashSet()){
key.setGeohashSet(new HashSet<>());
}
key.getGeohashSet().add(childItem.getGeohash());
}
result.add(key);
});
return result;
}