一、需求从哪来
去年做一个连锁零售的数字化项目,产品提了这么几个需求:
- 员工进入门店范围,自动打卡签到
- 配送员到达客户楼下,自动触发"即将送达"通知
- 特定区域内的设备,推送专属活动信息
这三个需求,本质上是同一件事:当某个对象进入或离开一个地理区域时,触发一个事件。
这就是地理围栏(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万+ POI 和 600万 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 转换
└── 生产可用 ✅
八、还没解决的问题(留给评论区)
- 大规模场景:设备10万+、围栏10万+,R树单机撑不住,怎么分片?
- 历史轨迹回放:已经发生的轨迹,怎么判断经过了哪些围栏?
- 3D围栏:楼层级别的围栏(比如只对3楼的用户触发),高度维度怎么加入?
这三个坑我踩过两个,有兴趣的评论区聊。
LTS 的 POI 搜索和坐标转换能力可以免费注册试用:👉 lts.maiyun.net