ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Lark:开源实时数据库实现Firebase兼容与自托管部署

Lark:开源实时数据库实现Firebase兼容与自托管部署 在实际项目中实时数据同步往往是“看起来简单做起来很复杂”的一环。Lark 是一个开源实时数据库项目核心卖点不在于重新发明一套实时存储协议而在于它对外提供了与 Firebase SDK 兼容的接入方式。也就是说你原本使用 Firebase Realtime Database 的 Web、Android、iOS 或 Node.js 代码只需要修改配置信息就可以把数据层切换到自托管的 Lark 服务上。这篇文章会从实时数据库的基本问题讲起分析 Lark 这种兼容层的设计思路然后用一个最小可运行案例完成部署、接入、读写和实时监听最后给出迁移到自托管方案时最常遇到的坑和排查方法。理解 Lark 之前先要明确一个背景Firebase Realtime Database 在很多项目里承担的是“客户端直连数据库”的角色。客户端通过 SDK 直接读写云端 JSON 树服务器甚至可以不参与数据持久化。这种模式的优势是开发效率高、离线可用、实时推送天然支持但代价是数据存储和访问规则被绑定在特定云服务上。Lark 要做的就是用开源实现复刻这套交互协议和 SDK 兼容层让同样一套客户端代码可以跑在自有服务器上。对团队来说这意味着可以保留 Firebase SDK 带来的开发体验同时获得数据自主权、私有化部署能力以及更可控的运维成本。1. 先理解实时数据库为什么需要兼容层而不是重写 SDK1.1 Firebase Realtime Database 的核心模型JSON 树与监听器Firebase 实时数据库本质上是一个云端托管的 JSON 树。所有数据都挂在根节点下客户端通过路径来引用节点例如users/1001/name。写入数据时不是发送 SQL 语句而是把 JSON 片段更新到指定路径读取数据时也不是执行查询而是订阅路径并等待数据快照。这种模型最大的特点是监听优先。客户端对某个路径执行on()监听后服务端会在该路径数据发生变化时主动推送更新客户端的回调函数会被触发。正因为整个通信模型是推送式的Firebase SDK 能实现毫秒级数据同步而这一层能力在后端实现时通常依赖 WebSocket 或 SSE 长连接。Lark 如果要对客户端做到“drop-in compatible”就必须复刻这套语义客户端初始化时通过配置对象找到服务端地址。SDK 内部使用 WebSocket 等长连接与服务器保持通信。客户端对路径的读写操作被转换成特定协议消息。服务端在数据变更后广播给所有监听该路径的客户端。所以兼容层不是简单做一个 REST API 让你调而是要确保原来的ref().set()、ref().on(value, callback)这些代码在换了服务端地址之后仍然能按原有行为工作。1.2 兼容 Firebase SDK 到底兼容哪些东西一个严格的 Firebase SDK 兼容实现至少要覆盖以下部分初始化配置支持databaseURL指向自建服务而不是 Firebase 官方域名。数据操作 APIset、update、push、remove、transaction、once、on、off。查询能力orderByChild、orderByKey、limitToFirst、limitToLast、startAt、endAt、equalTo。离线行为本地缓存、断线重连、待写入队列。安全规则服务端能校验客户端读写请求返回权限拒绝错误。错误码与异常结构客户端 catch 到的错误对象需要与 Firebase 的PERMISSION_DENIED、DISCONNECT等类型保持一致。这也是为什么很多自托管实时数据库项目宁可实现一个兼容层也不建议用户重新引入另一套 SDK。重写 SDK 意味着所有客户端代码、状态管理、错误处理逻辑都要改这会抵消自托管带来的收益。Lark 的定位就是把“换服务端”这件事压缩到最小成本只改配置不改业务代码。1.3 Lark 这类开源实时数据库解决的实际问题一个项目在生产环境选择自托管实时数据库通常出于三类原因数据主权和合规业务数据不能存放在第三方云或者有明确的数据出境限制需要把数据存储和访问日志放在自己可控的基础设施内。成本结构实时数据库在大量客户端直连场景下连接数和带宽费用会随用户规模线性增长。自托管后可以复用已有的服务器和带宽资源成本更可控。离线与私有网络部分应用运行在内网环境例如工厂产线、医院内网、边缘计算节点外部云服务无法接入。Lark 的价值在于当团队面临这些需求时不需要放弃已经写好的 Firebase SDK 代码也不需要重新设计实时数据同步方案。直接把服务端替换成 Lark客户端继续沿用firebasenpm 包或 Firebase Android SDK 的接口即可。注意这里的“drop-in compatible”更多是指 API 层面的兼容。实际项目中如果使用了 Firebase Auth、Firestore 等其他产品还需要确认 Lark 是否只覆盖 Realtime Database还是同时实现了认证和托管规则。部署前要把范围看清楚避免只迁移了数据库却在登录环节卡住。2. Lark 的架构思路协议、存储与服务端推送怎么配合2.1 一个可运行的 Lark 服务端应该包含哪些模块从工程实现角度看实时数据库服务端至少要有四个模块接入网关处理客户端 WebSocket 连接、HTTP 长轮询或 SSE 请求负责协议解析。数据存储引擎保存整棵 JSON 树支持按路径定位节点、写入、删除和事务。通常使用内存索引配合持久化引擎例如嵌入式数据库或日志文件。规则引擎在每次读写请求时执行安全规则判断当前客户端是否有权限。广播分发器当某个路径的数据变更后找出所有监听该路径或祖先路径的连接把变更事件推送出去。用表格可以更清楚看到每个模块的职责模块主要职责如果缺失会发生什么接入网关解析客户端指令维护长连接SDK 无法与服务端通信数据存储引擎持久化 JSON 树提供事务支持重启后数据丢失或并发写错乱规则引擎校验读写权限执行数据校验客户端可越权读写任意数据广播分发器管理路径订阅关系推送变更数据变了但客户端收不到实时更新Lark 的兼容层就建立在这四类模块之上。客户端 SDK 发送的协议包会被网关解析成内部操作数据落到存储引擎后广播分发器再通知订阅者。整个过程对客户端完全透明。2.2 为什么 WebSocket 是实时数据库的主要传输层实时数据库对延迟敏感。传统的 HTTP 请求-响应模式做不到“服务端主动推数据”只能靠客户端轮询。轮询的缺点是浪费带宽、延迟较高而且无法精确感知断线。WebSocket 可以在一个长连接上实现双向通信服务端可以随时把数据变更推送到客户端因此成为实时数据库最常见的传输层。在兼容 Firebase SDK 时WebSocket 还能解决一个关键问题Firebase SDK 内置了自动重连和离线状态恢复机制。当网络波动导致连接断开时SDK 会进入监听状态一旦连接恢复就重新订阅并把离线期间遗漏的数据补齐。Lark 的接入网关必须正确处理这种重连流程否则会出现“在线状态已经恢复但数据一直不更新”的问题。2.3 数据同步粒度整棵子树快照与增量更新Firebase Realtime Database 的一个特点是监听某个路径时回调里拿到的是该路径下完整的数据快照而不仅仅是变化的部分。例如监听users/1001即使只修改了nickname字段回调里的快照也是整个users/1001节点的最新状态。这种设计简化了客户端逻辑你不用自己处理字段级合并只需要把快照整体绑定到界面即可。代价是当节点数据很大时每次变更都会传输整棵子树流量消耗较高。Lark 作为兼容实现也遵循同样的语义。在服务端设计上它需要能高效地序列化子树快照并在广播时根据客户端订阅的路径精确裁剪数据。如果服务端把整棵数据库都发给客户端虽然也能工作但会造成严重的带宽浪费和内存开销。3. 本地环境准备与最小部署先让一个 Lark 服务跑起来3.1 环境要求与准备清单由于 Lark 是开源项目实际部署方式可能随版本变化。这里给出一个通用的自托管实时数据库部署思路适用于大多数基于 Node.js 或 Go 实现的服务端。你在动手前需要先确认以下信息Lark 官方推荐的安装方式是 Docker 镜像、二进制文件还是源码运行。项目要求的 Node.js 版本或 Go 版本。是否依赖 Redis、PostgreSQL 等外部存储。服务端默认监听端口和管理控制台端口。是否带内置认证系统还是需要对接现有用户体系。为了不干扰现有环境建议使用 Docker 方式启动。下面是一个示例docker-compose.yml用来说明自托管实时数据库的常见配置结构version: 3.8 services: lark: image: larkdb/lark:latest container_name: lark-realtime-db restart: unless-stopped ports: - 8080:8080 environment: LARK_DATA_DIR: /var/lib/lark LARK_PUBLIC_URL: http://localhost:8080 LARK_ADMIN_TOKEN: change-me volumes: - lark-data:/var/lib/lark volumes: lark-data:配置项说明LARK_DATA_DIR数据持久化目录建议挂载到宿主机卷避免容器重建后数据丢失。LARK_PUBLIC_URL客户端连接时使用的公网地址在内网测试时填http://localhost:8080即可。LARK_ADMIN_TOKEN管理端Token用于访问控制台或执行管理 API生产环境必须替换。启动命令docker-compose up -d启动后先确认端口监听状态和健康检查接口curl http://localhost:8080/healthz如果返回正常状态码说明服务已经可用。如果命令一直超时优先检查 Docker 容器日志docker logs -f lark-realtime-db3.2 确认 SDK 指向自建服务初始化配置是唯一改动点Lark 兼容 Firebase SDK 的直观体现是客户端仍然使用官方firebase包但初始化时把databaseURL改成 Lark 服务地址。下面以 JavaScript Web SDK v9 模块化写法为例import { initializeApp } from firebase/app; import { getDatabase, ref, set, onValue } from firebase/database; const firebaseConfig { // 注意这里只有 databaseURL 指向自建 Lark 服务 // 其他配置项可以保持占位因为 Lark 不依赖 Firebase 的认证服务 databaseURL: http://localhost:8080, }; const app initializeApp(firebaseConfig); const db getDatabase(app);这里的核心变化是databaseURL。原来指向https://your-project-default-rtdb.firebaseio.com现在变成 Lark 服务的地址。如果 Lark 实现了 Firebase Auth 兼容接口那么apiKey、authDomain等配置也可以沿用如果只做数据库兼容那么认证部分需要单独处理。在 Node.js 服务端代码几乎一样const admin require(firebase-admin); const serviceAccount require(./service-account.json); // 有些兼容实现允许传入自定义 databaseURL // serviceAccount 仅用于模拟管理端身份不一定是真实 Firebase 凭证 const app admin.initializeApp({ credential: admin.credential.cert(serviceAccount), databaseURL: http://localhost:8080, }); const db app.database();这里有一个容易误解的地方使用firebase-admin连接 Lark 时service-account.json本身可能只是一个身份占位文件Lark 服务端并不真正向 Google 验证它。具体是否需要、如何生成要以 Lark 的文档为准。如果项目没有提供 admin 兼容建议直接使用普通客户端 SDK 加上服务端密钥或静默登录来完成服务端访问。3.3 用最小示例写入并读取一条数据跑通服务后写一个最小示例验证链路。先创建index.html引入 Firebase SDK 并连接本地 Lark!DOCTYPE html html body h1Lark Realtime Database Test/h1 input idmessageInput placeholder输入内容 / button idsendBtn发送/button div idmessages/div script typemodule import { initializeApp } from https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js; import { getDatabase, ref, set, push, onChildAdded, } from https://www.gstatic.com/firebasejs/10.7.1/firebase-database.js; const app initializeApp({ databaseURL: http://localhost:8080, }); const db getDatabase(app); const messagesRef ref(db, messages); document.getElementById(sendBtn).onclick () { const value document.getElementById(messageInput).value; push(messagesRef, { text: value, time: Date.now() }); }; onChildAdded(messagesRef, (snap) { const msg snap.val(); const div document.createElement(div); div.textContent ${new Date(msg.time).toLocaleTimeString()} - ${msg.text}; document.getElementById(messages).appendChild(div); }); /script /body /html这段代码做了三件事初始化 Firebase AppdatabaseURL指向本地 Lark。使用push往messages节点下追加一条带随机 key 的数据。监听messages节点的child_added事件新消息出现时自动渲染到页面上。在浏览器打开这个页面打开两个标签页在其中一个输入内容并点击发送另一个标签页应该能立即看到消息出现不需要刷新页面。如果这个行为正常说明 Lark 已经完整支持实时数据推送。4. 深入核心用法数据操作、查询和离线能力4.1 读写数据set、update、push、transaction 的使用边界set是整节点覆盖写入。假设要保存用户资料import { getDatabase, ref, set } from firebase/database; const db getDatabase(); const userRef ref(db, users/1001); await set(userRef, { nickname: codeknight, level: 7, tags: [java, editor], });执行后users/1001下的旧数据会被完全替换。如果只想更新部分字段应该使用updateawait update(userRef, { level: 8, });这里要注意update是浅层合并子对象内部不会递归合并。如果旧数据里有profile: { city: Shanghai }使用update写入{ profile: { age: 30 } }最后profile节点会变成只有agecity会丢失。push用于生成带唯一 key 的列表数据适合消息、日志、通知等追加场景import { push } from firebase/database; const newRef await push(ref(db, notifications), { title: 新任务, read: false, }); console.log(newRef.key); // 自动生成的 keytransaction用于原子操作适合计数器、库存、余额等场景。例如把点赞数加一import { ref, runTransaction } from firebase/database; const likeRef ref(db, posts/1001/likes); try { await runTransaction(likeRef, (currentValue) { const value currentValue || 0; return value 1; }); } catch (e) { console.error(事务失败, e); }transaction的底层是乐观并发控制。当多个客户端同时修改同一个节点时服务端会检测冲突并重试回调函数。如果你没有写事务处理的正确回调可能会导致部分加号丢失这是计数器场景最常见的坑。4.2 离线缓存与断线重连在兼容实现里容易踩坑Firebase SDK 自带离线持久化能力它会把监听过的数据缓存在本地。Lark 要兼容就需要处理离线事件和待写入队列。客户端需要判断是否在线可以监听.info/connected节点import { ref, onValue } from firebase/database; const connectedRef ref(db, .info/connected); onValue(connectedRef, (snap) { if (snap.val() true) { console.log(已连接到 Lark); } else { console.log(连接已断开进入离线模式); } });.info/connected是一个特殊节点不存储在数据库中只反映当前客户端与服务器的连接状态。离线时set、update等写操作会进入本地队列等连接恢复后按顺序发送。这里有一个实际项目中很容易踩的坑如果离线状态下执行了多次set恢复后可能只会保留最后一次结果而不是全部执行。这是 Firebase SDK 的行为Lark 作为兼容层也会遵循。所以不要把需要严格顺序生效的写入逻辑依赖在离线队列上。另外一个坑是服务端重启后如果客户端没有正确处理value事件的重新订阅可能一直看到旧缓存数据。排查时先确认服务端是否正常、客户端是否重新握手再看数据是否确实写入到了 Lark 的存储目录。4.3 查询能力orderBy 系列与限制条件实时数据库没有 SQL查询能力靠orderBy配合范围条件实现。例如按年龄查询用户import { query, orderByChild, limitToLast, ref, onValue } from firebase/database; const usersRef ref(db, users); const topUsersQuery query(usersRef, orderByChild(level), limitToLast(10)); onValue(topUsersQuery, (snap) { const data snap.val(); console.log(data); });orderByChild表示按子字段排序limitToLast(10)表示取排序后最后 10 条。这类查询有一个重要限制数据必须按查询字段的索引组织而 Firebase Realtime Database 天然不支持多字段联合查询。如果你在原有系统里用过where(age 18).where(city Shanghai)这类条件组合在实时数据库里做不到只能把其中一个字段拆到复合 key 上或者采用冗余节点。Lark 兼容层如果不完整实现查询下推可能会出现两种错误一是直接返回全量数据由客户端内存过滤数据量大时会卡顿二是查询条件包含未建立索引的字段服务端报错。遇到查询性能问题时先看官方是否提供了索引配置或者在rules里声明.indexOn。5. 安全规则与数据模型设计生产可用必须越过的一关5.1 安全规则是服务端校验不是客户端约束实时数据库的安全规则写在服务端所有客户端读写请求都会经过规则引擎校验。即使客户端代码里写了某个路径也不代表它有权限访问该路径。规则通过布尔表达式控制read和write。一个最小规则示例{ rules: { .read: false, .write: false, messages: { .read: true, .write: true } } }这个规则允许所有人读取和写入messages节点适合 demo不能在生产环境使用。更合理的规则是结合认证信息只允许登录用户写入只允许写入时包含特定字段{ rules: { users: { $uid: { .read: auth.uid $uid, .write: auth.uid $uid, .validate: newData.hasChildren([nickname, level]) } } } }其中$uid是通配参数auth.uid来自认证身份。如果 Lark 还没实现完整的 Auth 兼容那么服务端可能会为每个请求附带一个测试身份或者需要你在服务端配置一个可信客户端标识。部署前一定要弄清楚认证字段的行为否则规则写了也不生效或者所有请求都被拒绝。5.2 规则引擎缺失或降级时怎么用服务端代理做兜底如果 Lark 当前版本只支持数据同步没有把安全规则完整实现生产环境就不要让客户端直接连数据库。推荐做法是增加一个轻量 API 服务作为代理客户端通过自己的账号系统登录拿到短期 token。客户端把读写请求发送到 API 服务。API 服务校验 token 和业务权限后使用服务端 SDK 访问 Lark。把数据变更结果返回客户端同时通过云消息或 SDK 实时监听同步给其他客户端。这种模式虽然多了一层但能保证权限可控。实时数据库直接暴露给客户端时一旦规则配错数据就是裸奔状态。代理模式可以先把风险兜住等之后彻底熟悉规则语法再把高频读取路径逐步开放。5.3 数据结构设计避免深层嵌套和热点节点实时数据库的数据结构设计会影响性能、流量和规则复杂度。三条经验可以直接套用把列表拆成独立节点不要在数组里嵌套大对象。例如评论列表用comments/{postId}/{commentId}而不是posts/{postId}/comments数组。避免深层路径路径越深订阅时的数据裁剪和权限校验越复杂。推荐的路径深度控制在三层以内。用冗余字段换查询能力实时数据库不像关系型数据库那样有 join需要按展示需求提前反规范化。比如posts/{postId}/authorName可以直接冗余存储避免每次都读用户表。用表格对比一下错误与推荐设计场景错误设计推荐设计消息列表chat/messages数组chat/messages/{autoId}节点集合用户资料users/1001/profile/city深层嵌套users/1001/profile扁平化对象热门文章articles单节点多个客户端竞争articles/{articleId}分节点配合transactions排行榜每个用户更新一次整个榜单用leaderboard/{level}分段存储6. 常见问题排查从连接失败到数据不一致6.1 客户端一直连不上 Lark 服务先确认三步服务端是否启动端口是否监听ss -lntp | grep 8080。客户端databaseURL是否写对了协议和地址http://localhost:8080和https://localhost:8080不是一回事证书错误会导致连接失败。浏览器是否拦截了跨域或混合内容如果在 HTTPS 页面里请求http://localhost:8080浏览器可能直接拦截。常见连带问题是 Docker 端口映射。容器内部监听 8080但宿主机映射到 18080那么客户端databaseURL必须写http://localhost:18080而不是 8080。6.2 数据写入成功但另一个客户端收不到实时更新这个现象通常指向广播订阅问题。可能性有两个客户端没有监听同一路径。服务端采用单机模式但客户端连接走了负载均衡后的不同实例事件没有跨节点同步。客户端使用了once()而不是on()只接收一次数据后续更新自然收不到。监听路径写错了例如一个监听messages另一个写入messages/{autoId}如果服务端只处理精确路径订阅可能不会触发父节点回调。检查方式是同时打开浏览器 Network 面板观察 WebSocket 帧。正常情况写入操作后应该能看到服务端推送的data帧里面包含路径和快照。如果没有任何推送优先确认服务端日志中是否有订阅记录。6.3 权限报错PERMISSION_DENIED 但规则看起来正确出现这类问题时不要只看规则代码按顺序排查是否所有路径都被默认拒绝。Firebase 规则的默认值是falseLark 如果实现这一语义任何未显式允许的路径都会拒绝。auth对象是否为空。规则里写了auth.uid但 SDK 初始化时没有完成登录auth就是null。规则是否写错了节点层级。users/$uid/.read只对users/{uid}子路径生效不覆盖users根节点的读取。服务端是否加载了最新规则。修改规则后可能没有热更新需要重启或调用管理接口刷新。6.4 数据量大之后开启页面明显卡顿原因大多是客户端监听了过大路径例如直接监听根节点ref(db, /)。每次任意子节点变化整个根节点数据快照都会传输到客户端。解决思路缩小监听粒度只监听列表头部或当前页面需要的节点。使用limitToLast限制单次拉取数量。对高频更新和大对象做拆分减少单个路径数据体积。如果服务端支持查询参数下推确认客户端确实使用了查询而不是 SDK 本地过滤。6.5 服务端重启后客户端数据恢复异常实时数据库通常有持久化机制但客户端本地缓存可能存在旧数据。解决思路服务端重启前先确认数据目录有落盘避免数据全部丢失。客户端启动后监听.info/connected和关键数据路径连接恢复后强制刷新一次数据。在服务端管理接口查看当前节点数据确认数据是否真的写入了。常见错误现象整理如下问题现象常见原因检查方式处理建议连接失败端口映射错误或协议不匹配curl、Docker 日志修正databaseURL写入后其他端不更新订阅路径不一致或监听事件错误浏览器 Network 看 WebSocket 帧统一路径改用on权限拒绝auth为空或规则覆盖层级不对查看规则文件和管理端日志补充认证或调整规则页面卡顿监听根节点或数据量过大抓包看数据传输大小缩小监听范围重启后数据丢失未持久化或 volume 未挂载检查数据目录和容器 volume配置持久化卷7. 从 Firebase 迁移到 Lark 的最佳实践与上线检查清单7.1 迁移不能只改 databaseURL要分阶段验证虽然 Lark 的卖点是兼容 Firebase SDK但真实项目里还是存在很多隐藏依赖。比如你原来的客户端可能使用了 Firebase Auth、Cloud Functions、Storage、Firestore这些产品不在一个兼容范围内。迁移时要按数据库读写、实时监听、安全规则、离线缓存、事务、查询六个维度分别做自动化测试。推荐的最小验证用例普通写入与整节点覆盖set后读取值一致。部分更新update合并字段。列表追加push生成唯一 key。实时性两个客户端同时在线A 写入时 B 在 1 秒内收到value回调。查询orderByChild加limitToLast返回正确数据。权限未登录客户端写入受保护路径时返回PERMISSION_DENIED。事务并发自增时最终值正确。离线断网后写入恢复后数据最终一致。7.2 上线前检查清单以下清单可以直接用于内部评审服务端数据目录是否挂载持久卷容器重建后数据是否还在。databaseURL是否使用可配置环境变量而不是硬编码在客户端代码里。安全规则是否开启默认根节点是否设置为拒绝。是否配置了管理端 Token并限制管理端口只对可信网段开放。是否启用了日志和监控服务端有异常时是否能第一时间发现。是否压测过单实例连接数和每秒写入量确认可以支撑当前业务规模。是否准备好回滚方案例如保存旧 Firebase 配置切换失败时能快速恢复。数据模型是否做了扁平化有没有监听根节点或过大路径。客户端是否处理了离线状态和错误类型而不是直接崩溃。7.3 值得继续探索的扩展方向Lark 这类开源实时数据库的价值不只是自托管。你可以继续实践部署一个多实例集群验证服务端是否支持节点间数据一致性以及客户端重连后是否会自动切换到其他实例。结合WebSocket 网关在边缘节点部署 Lark把实时数据推送到用户最近的节点。用Admin SDK 编写数据迁移脚本把 Firebase 线上数据批量导出并导入 Lark。研究实时数据库与流式计算的结合把数据变更事件接入消息队列驱动后续分析任务。在离线优先的移动应用中利用 SDK 的本地缓存能力让弱网环境也能正常操作。实际项目里最该记住的一点是兼容层解决的是“换服务端”的成本但解决不了“数据结构设计错误”和“权限规划不周”的问题。把 Lark 或者任何自托管实时数据库投入使用之前先用最小用例把读、写、监听、权限、事务全部验证一遍。只有这些基础能力稳定了才能真正享受到开源实时数据库带来的数据自主权和成本优势。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进