地理围栏从0到1:我是怎么把轮询接口从每分钟2000次干到0次的

一、需求从哪来

去年做一个连锁零售的数字化项目,产品提了这么几个需求:

  • 员工进入门店范围,自动打卡签到
  • 配送员到达客户楼下,自动触发"即将送达"通知
  • 特定区域内的设备,推送专属活动信息

这三个需求,本质上是同一件事:当某个对象进入或离开一个地理区域时,触发一个事件。

这就是地理围栏(Geofence)


二、第一版:能跑就行(暴力轮询)

时间紧,第一版用了最直接的做法:

每60秒轮询一次所有在线设备的位置,判断是否在围栏内。

javascript

javascript 复制代码
// 第一版:定时轮询
const POLL_INTERVAL = 60 * 1000; // 60秒

async function pollAllDevices() {
  const devices = await db.getOnlineDevices(); // 拉所有在线设备
  const fences = await db.getAllFences();       // 拉所有围栏
  
  for (const device of devices) {
    const position = await getDevicePosition(device.id);
    
    for (const fence of fences) {
      const inside = isInsideFence(position, fence);
      const wasInside = await cache.get(`fence:${fence.id}:${device.id}`);
      
      // 状态变化才触发事件
      if (inside && !wasInside) {
        await triggerEvent('ENTER', device, fence);
        await cache.set(`fence:${fence.id}:${device.id}`, true);
      } else if (!inside && wasInside) {
        await triggerEvent('EXIT', device, fence);
        await cache.set(`fence:${fence.id}:${device.id}`, false);
      }
    }
  }
}

setInterval(pollAllDevices, POLL_INTERVAL);

isInsideFence 最初用的是简单的圆形围栏判断:

javascript

arduino 复制代码
function isInsideFence(position, fence) {
  if (fence.type === 'circle') {
    const distance = haversineDistance(
      position.lat, position.lng,
      fence.center.lat, fence.center.lng
    );
    return distance <= fence.radius;
  }
}

// Haversine公式:计算球面两点距离(单位:米)
function haversineDistance(lat1, lng1, lat2, lng2) {
  const R = 6371000; // 地球半径(米)
  const φ1 = lat1 * Math.PI / 180;
  const φ2 = lat2 * Math.PI / 180;
  const Δφ = (lat2 - lat1) * Math.PI / 180;
  const Δλ = (lng2 - lng1) * Math.PI / 180;

  const a = Math.sin(Δφ/2) ** 2 +
            Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ/2) ** 2;
  
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}

跑了两周,问题来了:

  • 在线设备峰值800台,围栏200个
  • 每次轮询:800 × 200 = 16万次判断
  • 每分钟请求设备位置:800次接口调用
  • 服务器日志被轮询任务刷满,真实报错被淹没
  • 60秒延迟:员工已经进店一分钟了,打卡才触发,产品说"这不行"

三、问题拆解

轮询方案的问题本质上有三个:

markdown 复制代码
问题1:时间复杂度 O(devices × fences)
       设备数和围栏数一多,每轮计算量爆炸

问题2:无效计算太多
       大部分设备根本没在移动,每次还是要算

问题3:延迟不可控
       轮询间隔就是最大延迟,缩短间隔=更多计算

四、第二版:空间索引 + 事件驱动

4.1 把轮询改成推送

根本思路要反过来:

arduino 复制代码
❌ 服务端主动问设备:"你在哪?"(轮询)
✅ 设备位置变化时主动上报(事件驱动)

设备端每隔一段时间上报位置(或位移超过阈值时上报),服务端被动接收,只在有新数据时触发判断。

这一步直接把"每分钟800次主动查询"变成了按需接收

javascript

ini 复制代码
// 设备端(伪代码):位移超过20米才上报
let lastPosition = null;

locationWatcher.on('update', async (newPos) => {
  if (!lastPosition || 
      haversineDistance(lastPosition, newPos) > 20) {
    await reportPosition(newPos);
    lastPosition = newPos;
  }
});

4.2 引入空间索引:R树

设备上报位置后,服务端要判断它在哪些围栏里。

暴力做法:遍历所有围栏,逐个判断------O(n),围栏多了很慢。

正确做法:用**R树(R-Tree)**做空间索引。

R树是专门为空间数据设计的索引结构,可以快速找到"与某个点相交的所有区域",复杂度从O(n)降到O(log n)。

javascript

php 复制代码
import RBush from 'rbush'; // npm install rbush

// 初始化空间索引
const fenceIndex = new RBush();

// 把所有围栏的包围盒加入索引
const fences = await db.getAllFences();
fenceIndex.load(fences.map(fence => ({
  minX: fence.bbox.west,
  minY: fence.bbox.south,
  maxX: fence.bbox.east,
  maxY: fence.bbox.north,
  fence: fence  // 原始围栏对象
})));

// 设备上报位置时:只查可能相交的围栏(极快)
function getCandidateFences(position) {
  return fenceIndex.search({
    minX: position.lng,
    minY: position.lat,
    maxX: position.lng,
    maxY: position.lat
  }).map(item => item.fence);
}

// 再对候选围栏做精确判断(候选集通常很小)
async function onPositionUpdate(deviceId, position) {
  const candidates = getCandidateFences(position); // 快速筛选
  
  for (const fence of candidates) {
    const inside = preciseCheck(position, fence); // 精确判断
    await handleStateChange(deviceId, fence, inside);
  }
}

效果: 200个围栏,平均每次位置上报只需要精确判断3-5个候选围栏,而不是200个。

4.3 多边形围栏:射线法

真实业务里,围栏不都是圆形。商场楼层、园区边界、行政区划------都是不规则多边形。

判断点是否在多边形内,用射线法(Ray Casting)

javascript

ini 复制代码
function isPointInPolygon(point, polygon) {
  const { lat, lng } = point;
  const vertices = polygon.coordinates;
  let inside = false;
  
  for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) {
    const xi = vertices[i].lng, yi = vertices[i].lat;
    const xj = vertices[j].lng, yj = vertices[j].lat;
    
    // 从点向右发射一条射线,计算与多边形边的交叉次数
    const intersect = ((yi > lat) !== (yj > lat)) &&
                      (lng < (xj - xi) * (lat - yi) / (yj - yi) + xi);
    
    if (intersect) inside = !inside;
  }
  
  return inside; // 奇数次交叉=在内部,偶数次=在外部
}

五、第三版:解决"抖动"问题

第二版上线后,新问题出现了:

配送员在围栏边界附近走动,频繁触发 ENTER 和 EXIT 事件。

系统在5分钟内给同一个客户发了4条"即将送达"通知,客户投诉了。

这就是**边界抖动(Boundary Oscillation)**问题。

解决方案:缓冲区 + 状态机

css 复制代码
核心思路:
进入围栏需要满足条件A(更严格)
离开围栏需要满足条件B(比A更宽松)

用两个不同半径的围栏制造"缓冲带":
- 外圈(触发ENTER):原始围栏
- 内圈(触发EXIT):比外圈小10%~20%

在缓冲带内游走,不触发任何事件

javascript

javascript 复制代码
const BUFFER_RATIO = 0.85; // 内圈是外圈的85%

async function handleStateChange(deviceId, fence, currentPosition) {
  const state = await cache.get(`state:${deviceId}:${fence.id}`) || 'outside';
  
  if (state === 'outside') {
    // 当前在外面,判断是否进入外圈
    if (isInsideFence(currentPosition, fence, 1.0)) {
      await cache.set(`state:${deviceId}:${fence.id}`, 'inside');
      await triggerEvent('ENTER', deviceId, fence);
    }
  } else if (state === 'inside') {
    // 当前在里面,判断是否离开内圈(内圈 = 外圈 × BUFFER_RATIO)
    if (!isInsideFence(currentPosition, fence, BUFFER_RATIO)) {
      await cache.set(`state:${deviceId}:${fence.id}`, 'outside');
      await triggerEvent('EXIT', deviceId, fence);
    }
    // 在两圈之间的缓冲带:什么都不做
  }
}

六、围栏的地理数据从哪来

上面聊的都是判断逻辑,但还有一个工程问题经常被忽视:

围栏的多边形坐标怎么来?

如果是让运营人员在地图上手画,需要一个围栏编辑器,成本不低。

更常见的场景是:围栏对应一个真实地点------某个商场、某个小区、某个园区。

这时候可以直接用 AOI(Area of Interest,兴趣面)数据POI 搜索 来生成围栏。

迈云位置服务(LTS)的 POI 搜索为例,覆盖全国 8000万+ POI600万 AOI

javascript

php 复制代码
// 用POI搜索找到目标地点,直接拿坐标作为围栏中心
const response = await fetch('https://lts.maiyun.net/api/service/placesearch', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    keyword: '北京国贸商城',
    city: '北京',
    limit: 1
  })
});

const result = await response.json();
const poi = result.pois[0];

// 用返回的坐标和AOI边界直接创建围栏
const fence = {
  id: generateId(),
  name: poi.name,
  center: { lat: poi.lat, lng: poi.lng },
  radius: 200,           // 或者用poi.aoi_polygon做多边形围栏
  polygon: poi.aoi_polygon  // 如果有AOI数据,直接用精确轮廓
};

await db.saveFence(fence);
fenceIndex.insert(toBbox(fence)); // 加入空间索引

这样运营只需要搜索地点名称,系统自动生成围栏,不需要手动画点。


七、架构演进总结

scss 复制代码
第一版(暴力轮询)
├── 服务端主动轮询所有设备
├── 遍历所有围栏逐个判断
└── 问题:计算量O(n²),延迟高,日志刷屏

         ↓ 优化

第二版(事件驱动 + 空间索引)
├── 设备主动上报位置变化
├── R树快速筛选候选围栏 O(log n)
└── 问题:边界抖动,重复触发事件

         ↓ 优化

第三版(缓冲区状态机)
├── 双圈缓冲带消除边界抖动
├── 状态机管理 INSIDE/OUTSIDE 转换
└── 生产可用 ✅

八、还没解决的问题(留给评论区)

  1. 大规模场景:设备10万+、围栏10万+,R树单机撑不住,怎么分片?
  2. 历史轨迹回放:已经发生的轨迹,怎么判断经过了哪些围栏?
  3. 3D围栏:楼层级别的围栏(比如只对3楼的用户触发),高度维度怎么加入?

这三个坑我踩过两个,有兴趣的评论区聊。


LTS 的 POI 搜索和坐标转换能力可以免费注册试用:👉 lts.maiyun.net

相关推荐
渐儿9 小时前
第 05 章 · SQL 写法
后端
invicinble9 小时前
对于spring的bean应该有哪些领域的认识
java·后端·spring
Amazing53079 小时前
docker compose 漏一个参数全失效
后端·代码规范
ZengLiangYi9 小时前
从零实现 Embedding 服务:文本转向量
人工智能·后端
星栈9 小时前
订单状态机别写散:我在 Rust CRM 里把 6 个状态收进领域模型
后端·rust·全栈
韩小兔修媛史9 小时前
SpringBoot面试八股文(持续更新)
spring boot·后端·面试
神奇小汤圆10 小时前
搞懂数据库索引:它到底帮了什么忙,又埋了什么坑?
后端
浮游本尊10 小时前
Java学习第38天 - 企业级 REST API 设计、OpenAPI 契约与接口可靠性
后端
苍何10 小时前
分享最近高频用 Agent 提效的 4 大场景
后端