前言
第一次接触边缘计算,用阿里云 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:data 和 viewer: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,跑在被监控的设备上,支持自动采集系统信息:
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、冗余缓存)解决实际问题。
