2273 字
11 分钟
我的第一个边缘计算项目:用阿里云 ESA 搭建设备视监面板

前言#

第一次接触边缘计算,用阿里云 ESA 的边缘函数 + EdgeKV 做了一个设备监控面板。没有服务器,没有数据库,一个 JS 文件就是全部后端。这篇文章记录整个技术方案的实现细节。

网站在这里: Mint Now—视监面板 欢迎来玩!

视监面板截图

什么是边缘函数#

传统的后端服务跑在一台固定的服务器上,用户的请求需要绕到那台机器再返回。边缘函数不同——代码被部署到全球各地的 CDN 节点上,用户的请求由离他最近的节点直接处理,响应速度天然就快。

阿里云 ESA(Edge Security Acceleration)提供的边缘函数基于标准的 Web Worker API,写法和 Cloudflare Workers 类似:

export default {
async fetch(request, env, ctx) {
return handleRequest(request);
}
};

整个 monitor.js 就是一个标准的 fetch handler。接收 Request 对象,返回 Response 对象,中间没有 Express、没有 Koa,连 Node.js 的 http 模块都不存在——这是一个纯粹的 Serverless 环境。

路由分发也是手写的 if/else

async function handleRequest(request) {
const url = new URL(request.url);
const path = url.pathname;
if (path === "/api/monitor/report" && request.method === "POST") {
return handleReport(request);
}
if (path === "/api/monitor/devices" && request.method === "GET") {
return handleGetDevices();
}
// ...更多路由
return errorResponse("Not Found", 404);
}

没有框架抽象,逻辑一目了然。

EdgeKV:边缘节点上的键值存储#

边缘函数是无状态的,每次请求之间不共享内存。要持久化数据,ESA 提供了 EdgeKV——一个分布式键值存储,和边缘函数跑在同一层网络上,读写延迟极低。

使用方式很直接:

const kv = new EdgeKV({ namespace: "monitor-kv" });
// 写
await kv.put("device:my-pc", JSON.stringify(deviceData));
// 读
const value = await kv.get("device:my-pc");
// 删
await kv.delete("device:my-pc");

在这个项目里,KV 存储承担了所有的数据职责:

Key 模式用途
device:<id>单个设备的完整状态
device:list所有设备 ID 的数组
device:list:data所有设备数据的缓存快照
uptime:<id>:<date>按天聚合的设备在线时长(分钟)
viewer:<id>单个观看者的会话数据
viewer:list所有观看者 ID 的数组
viewer:list:data所有观看者数据的缓存快照

你会注意到 device:list:dataviewer:list:data 这两个”缓存列表”。这是被 EdgeKV 的调用次数限制逼出来的设计——如果每个设备存一条 KV,获取设备列表时要逐个 kv.get(),5 台设备就是 5 次 KV 调用,超了配额直接报错。所以在设备上报时,顺手把整个设备列表冗余地写一份到 device:list:data,读列表时一次 kv.get() 全部拿回来。

没有 TTL?手动管理过期#

这是踩的最大的坑。很多 KV 存储(比如 Cloudflare KV)支持设置 TTL,key 到期自动删除。但 ESA 的 EdgeKV 没有原生的 TTL 支持。如果你写入一条数据,不主动删,它就永远在那里。

对于设备数据这种 7 天过期的场景,不可能靠定时任务去扫——边缘函数没有 cron,只有请求来了才会执行。

最终方案是 包装层模拟 TTL。写入时,把实际数据和过期时间戳包在一起:

function makeExpirablePayload(value, ttlSeconds) {
return JSON.stringify({
value,
expireAt: Date.now() + ttlSeconds * 1000,
});
}

KV 里存的不是裸数据,而是 { value: <实际数据>, expireAt: <过期时间戳> } 这样的信封。

读取时,通过配套的 getExpirableValue 函数拆信封:

async function getExpirableValue(kv, key, { deleteIfExpired = true } = {}) {
const raw = await kv.get(key);
if (raw === undefined || raw === null) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.expireAt === "number"
&& Object.prototype.hasOwnProperty.call(parsed, "value")) {
if (Date.now() > parsed.expireAt) {
// 过期了,顺手删掉
if (deleteIfExpired) {
kv.delete(key).catch(() => {});
}
return null;
}
return parsed.value;
}
} catch (e) {
// 不是包装格式,返回原始值
}
return raw;
}

关键在于 deleteIfExpired——读到过期数据时,顺手异步删除。这是一种”惰性清理”策略:不专门花时间扫过期数据,而是在正常业务读取时发现过期就清。代价是过期后到下一次被读到之间,数据仍然占着空间;好处是零额外开销。

在拉取设备列表和观看者列表时也做了同样的处理:

for (const device of devices) {
if (device._expireAt && now > device._expireAt) {
await removeDeviceFromLists(kv, device.id).catch(() => {});
continue; // 跳过过期设备
}
// ...正常处理
}

每次读列表都是一次”顺便清扫”。

实时观看者计数#

这是整个项目里最有意思的部分。需求很简单:页面上显示”当前有 N 人正在观看”。

心跳机制#

浏览器端每隔 30 秒发一次心跳:

POST /api/monitor/viewers
{ "id": "viewer-abc123", "page": "home" }

边缘函数收到后,写入一条带 35 秒 TTL 的观看者数据:

const viewerData = {
id: body.id,
page: body.page || "",
source: body.source || "",
lastSeen: now,
_expireAt: now + CONFIG.VIEWER_TTL_SECONDS * 1000, // 35秒后过期
};

TTL 设为 35 秒(比心跳间隔 30 秒多 5 秒容错)。如果用户关闭页面、断网、切走,心跳停止,35 秒后这条数据自动被视为过期。

查询时过滤#

拉取观看人数时,从缓存列表里读出所有观看者,过滤掉已过期的

for (const viewer of viewers) {
if (viewer._expireAt && now > viewer._expireAt) {
continue; // 跳过过期的观看者
}
validViewers.push(viewer);
if (!page || viewer.page === page) {
activeViewers.push(viewer);
}
}

如果发现有过期条目被清除了,顺手把清理后的列表写回 KV,保持缓存干净:

if (validViewers.length !== viewers.length) {
await kv.put(CONFIG.KV_VIEWER_DATA_LIST_KEY, JSON.stringify(validViewers));
}

同时在心跳写入时也会清理过期条目,避免缓存列表无限膨胀影响实时性:

async function updateViewerDataList(kv, viewerData) {
let viewerList = listJson ? JSON.parse(listJson) : [];
// 清理已过期的观看者,保证实时性
const now = Date.now();
viewerList = viewerList.filter(
(item) => !(item._expireAt && now > item._expireAt)
);
// 然后更新当前观看者...
}

按页面过滤#

支持查询特定页面的观看人数:

GET /api/monitor/viewers?page=home

返回结构:

{
"success": true,
"data": {
"count": 3,
"viewers": [...]
}
}

整个方案没有 WebSocket,没有长连接,纯粹靠轮询心跳 + 短 TTL 模拟出了实时效果。在 Serverless 环境下这是最务实的方案——你没有长驻进程来维持 WebSocket 连接。

设备状态的三态判定#

设备也是靠心跳判断在线状态的,但需要比观看者更细的粒度。定义了三种状态:

const CONFIG = {
OFFLINE_THRESHOLD: 30 * 60 * 1000, // 30分钟无心跳 = 离线
AWAY_THRESHOLD: 10 * 60 * 1000, // 10分钟无心跳 = 离开
};
function determineStatus(lastSeen) {
const diff = Date.now() - lastSeen;
if (diff > CONFIG.OFFLINE_THRESHOLD) return "offline";
if (diff > CONFIG.AWAY_THRESHOLD) return "away";
return "online";
}
  • online:10 分钟内有心跳
  • away:10~30 分钟无心跳(可能睡眠或暂时离开)
  • offline:超过 30 分钟无心跳

状态不是存死的,而是每次查询时根据 lastSeen 实时计算。这样即使没有新的上报,查询接口也能反映最新状态。

上报客户端#

配套写了一个 Python 客户端 report_client.py,跑在被监控的设备上,支持自动采集系统信息:

Terminal window
python report_client.py --id my-pc --interval 60

它会每 60 秒采集一次 CPU、内存、磁盘、电量等信息,通过 psutil 获取,然后 POST 到边缘函数。还能自动检测前台运行的应用窗口(Linux 下通过 xdotool),方便在面板上展示”这台机器正在干什么”。

Serverless 的优势#

做完这个项目,对 Serverless 的体感比之前清晰很多:

零运维。没有服务器要管,不用装 Nginx,不用配 systemd,不用半夜爬起来重启进程。代码推上去就跑。

按需付费。没有请求就不产生费用。对于个人项目这种流量极低的场景,成本几乎为零。传统方案怎么也得一台 VPS,哪怕空跑也按月收费。

天然全球加速。代码部署在 CDN 边缘节点上,不管用户从哪里访问,响应都是就近的。不需要额外买 CDN、配回源、调缓存策略。

自动扩缩。突然来了 1000 个并发?平台自动处理。不需要提前预估容量、配负载均衡。

当然也有代价:

  • 调试困难。没有本地运行环境(本项目靠 mock 的 EdgeKV 做本地测试)
  • 存储限制。EdgeKV 只是简单的键值对,复杂查询做不了
  • 没有原生 TTL。这篇文章花了大量篇幅讲手动过期管理,就是因为平台不提供
  • 冷启动。虽然边缘函数的冷启动比传统 FaaS 快得多,但偶尔还是能感知到

小结#

一个 731 行的 JS 文件,一个 Python 上报脚本,零服务器,实现了:

  • 多设备状态监控(在线/离开/离线三态)
  • 实时观看者计数(心跳 + 短 TTL 过期)
  • 7 天在线时长统计
  • 设备前台应用检测
  • 完整的 CRUD API

作为第一次接触边缘函数的项目,整体体验是积极的。最大的收获不是代码本身,而是理解了在”没有服务器”的限制下,如何用最朴素的方式(轮询、包装 TTL、冗余缓存)解决实际问题。

我的第一个边缘计算项目:用阿里云 ESA 搭建设备视监面板
https://www.mintlab.top/posts/tries/monitor-kv-serverless/
作者
Mint
发布于
2026-04-08
许可协议
CC BY-NC-SA 4.0