部署在多个数据中心时,Riak并不是把集群节点拉到同一个局域网里硬组,而是通过Multi-Datacenter Replication(简称MDC)在两个或多个独立集群之间做异步复制。Node.js客户端在这种情况下需要面对一个很实际的问题:本地集群和远端集群都有同一份数据的副本,读写到底该打到哪边?如果配置不当,一个毫秒级请求可能被拖成跨地域的百毫秒甚至秒级响应。

Riak多数据中心复制的核心机制
Riak MDC 的全称是 Multi-Data Center Replication,最早由 Basho 作为企业功能推出,后来社区版逐步放开。它的设计不是像 MySQL 主从那样依赖 binlog 实时推送,也不是同步双写。每个数据中心有一套独立的 Riak 集群,拥有自己的 vnode 分布、自己的 gossip 协议和本地存储。集群之间通过一个叫做 replication 的组件监听本地写入操作,将变更打包后传输到对端集群。之所以说它是异步复制,是因为本地写入返回成功时,远端集群还不一定已经持久化这份数据。远端落后多少,取决于网络延迟、对端写入压力和复制队列长度。这个机制的好处是:一个数据中心完全宕机,另一个数据中心仍然能独立接受读写;坏处是会存在窗口期数据不一致,并且两个集群都有可能同时修改同一个 key,从而产生 siblings。
从版本控制的角度看,Riak 在每个对象上维护一个因果上下文,用 vector clock(向量时钟)或 dotted version vector 记录修改历史。跨数据中心复制时,这个上下文也会被一起传送。当两个数据中心在断开连接时各自修改了同一个 key,恢复连接后复制会检测到两个兄弟版本都无法从因果上判定谁更新,于是会把它们都保留下来。Node.js 客户端读取这种 key 时会拿到多个值,需要业务层自行合并。理解这一点是后续设置 bucket 属性和读写参数的前提。
与同步复制方案相比,Riak 的选择更偏向可用性和分区容忍性。同步复制要求本地写入必须同时被远端确认,任何一端网络抖动都会直接放大写入延迟,甚至导致本地服务不可用。Riak 的 MDC 允许本地数据中心完全独立写入,即使远端完全不响应也不影响本地可用性。这个差异决定了 Node.js 客户端不应该指望跨数据中心读,而是原则上每个机房只访问本机房的 Riak 集群,把数据同步交给后台复制进程。
Node.js客户端初始化与多集群选择
在 Node.js 中操作 Riak,官方客户端是 basho-riak-client,可以通过 npm 安装。它同时支持 protocol buffers 和 HTTP 接口,不过在跨机房部署时更推荐使用 protobuf,因为编码更紧凑,连接复用更好。初始化时每个集群需要单独创建一个客户端实例。不要把两个数据中心的节点列表混在一起传给一个客户端,那样客户端会把所有节点当成同一个集群的成员,vnode 路由会彻底错乱。正确做法是维护一个本地客户端和一个或多个远端客户端,根据当前服务所在机房决定调用哪一个。示例代码如下。
var Riak = require('basho-riak-client');
var localNodes = ['10.0.1.10:8087', '10.0.1.11:8087', '10.0.1.12:8087'];
var remoteNodes = ['10.0.2.10:8087', '10.0.2.11:8087'];
var localClient;
var remoteClient;
function initClient(nodes, callback) {
return new Riak.Client(nodes, function(err, client) {
if (err) {
return callback(err);
}
callback(null, client);
});
}
initClient(localNodes, function(err, client) {
if (err) throw err;
localClient = client;
});
initClient(remoteNodes, function(err, client) {
if (err) throw err;
remoteClient = client;
});
这里仅创建了本地和远端两个客户端。实际项目中可以把 localNodes 和 remoteNodes 放在配置中心,通过环境变量 DATACENTER 决定当前进程使用哪个集群。例如部署在机架 A 的服务就把 DATACENTER 设为 dc1,读取 dc1 对应的节点列表;灾备切换时修改配置指向 dc2。这样业务代码不需要感知集群差异,只需要调用一个经过封装的 getClient() 函数即可。
选定客户端后,下一步要设置 bucket 属性。Riak 的 bucket 属性决定对象如何存储、复制和冲突解决。跨数据中心场景下,通常建议把 n_val 设置为本集群的副本数,比如 3;把 r 和 w 设置成多数派,比如 n_val=3 时 r=2, w=2,这样在单个数据中心内部仍然有合理的强一致保障。更关键的是 pr 和 pw,它们分别表示主集群读取和写入所需的最小成功节点数。如果不设置这两个参数,默认情况下 pr 和 pw 等于 r 和 w,意味着本地操作必须满足同样数量的远端节点确认,这显然不符合 MDC 的设计目标。因此需要把 pr 和 pw 显式设置为 0,让读写操作只关心本地集群。
localClient.setBucketProperties({
bucketType: 'default',
bucket: 'orders',
nVal: 3,
r: 2,
w: 2,
pr: 0,
pw: 0,
allowMult: true,
lastWriteWins: false
}, function(err) {
if (err) {
console.error('set bucket properties failed: ' + err);
}
});
上面代码把 allowMult 设为 true,是为了允许多个冲突版本共存而不是被直接覆盖。lastWriteWins 设为 false 可以避免时间戳掩盖因果冲突。如果不开启 allowMult,Riak 会只保留一个版本,可能把跨数据中心复制过来的有效修改丢掉。开启后读取时就能拿到所有 siblings,由应用决定如何合并。
读写参数调优与一致性取舍
理解了 bucket 属性之后,再来看单次读写时的参数。Node.js 客户端在 storeValue 和 fetchValue 方法中都可以传入 r、w、pr、pw 这些选项,覆盖 bucket 默认值。对于强一致业务,比如订单状态流转,可以在单个数据中心内部强制执行 r=2, w=2,确保读到的不是过期数据。但要注意这个一致性只是相对本地集群的,不代表远端集群已经同步完成。因此如果你需要全局一致,要么放弃多数据中心部署,要么接受最终一致性。
在写操作中,pw=0 的作用是告诉 Riak 不要等待主集群之外的节点确认。这里的主集群指的是发起写请求的客户端所连接的集群。如果写了 pw=0,写请求只需本地集群中 w 个节点成功即可返回,即使远端复制还没完成也不影响响应。读取时 pr=0 同理,只从本地集群读取,不会因为远端集群不可达而超时。这两个参数的组合可以显著降低跨地域请求的延迟,但也意味着你读到的数据可能不是最新的全局状态。对于大多数用户资料、商品信息、文章内容等场景,这种取舍是可以接受的。示例:
localClient.storeValue({
bucketType: 'default',
bucket: 'orders',
key: 'order-20240501-001',
value: JSON.stringify({ status: 'paid', amount: 299 }),
content_type: 'application/json',
w: 2,
pw: 0,
returnBody: true
}, function(err, result) {
if (err) {
console.error(err);
return;
}
console.log('stored ok');
});
读取时如果返回了多个 siblings,可以遍历 result.values 检查每个版本。每个 sibling 带有自己的 metadata 和 value。通常可以根据业务字段中的时间戳或单调递增版本号选择一个最新值,也可以做字段级合并。下面的示例演示了最简单的按更新时间取新值的逻辑,实际生产环境需要更严谨的合并策略。
localClient.fetchValue({
bucketType: 'default',
bucket: 'orders',
key: 'order-20240501-001',
r: 2,
pr: 0
}, function(err, result) {
if (err) {
console.error(err);
return;
}
if (result.values.length !== 1) {
var newest = result.values[0];
var maxTime = 0;
for (var i = 0; i < result.values.length; i++) {
var item = result.values[i];
var updated = item.metadata.indexes ? item.metadata.indexes.updated : 0;
if (updated > maxTime) {
maxTime = updated;
newest = item;
}
}
console.log('conflict resolved: ' + newest.value);
} else {
console.log('single value: ' + result.values[0].value);
}
});
需要注意的是,上面代码里的比较逻辑使用了转义后的尖括号,这是为了在 HTML 中正确显示。实际运行环境中,Node.js 会正常执行这些比较操作。
冲突处理与运维监控
冲突处理不能只靠读时临时合并。如果没有统一的策略,同样的 siblings 会在不同请求中反复出现,甚至把合并后的值再次写回时又制造新的因果上下文。一个可维护的做法是把冲突解决封装到一个独立的模块中,读取到多个版本后,按照业务规则生成一个确定性的合并结果,再用 storeValue 写回。写回时同样要设置 pw=0,不要因为合并写而依赖远端确认。如果合并逻辑比较复杂,可以在对象中维护一个字段 updated_at 或一个 version 单调递增计数器,每次写入都基于当前已知版本递增。Riak 本身不提供原子自增,所以计数器的实现需要依赖读改写模式,或者使用 Riak 的 CRDT 数据类型。跨数据中心时,CRDT 会更加安全,因为它的合并逻辑是数学可交换的,不会因为复制顺序不同而产生不同结果。
监控方面,需要关注两个指标:本地集群的健康度和数据中心之间的复制延迟。Riak 提供了 riak-admin cluster status 命令查看节点状态,还可以通过 HTTP API 获取 stats。对于复制延迟,可以在远端集群写入一个带时间戳的探测对象,再从本地集群读取这个对象,计算时间差。Node.js 可以定时执行这个探测逻辑,把延迟数据上报到监控系统。另外还需要关注复制队列长度,如果网络长时间中断,恢复后队列会积压大量变更,远端集群可能在短时间内承受较高写入压力。此时可以考虑对关键业务做限流,或者临时放宽 w 参数保证写入可用性。
最后总结,Node.js 操作多数据中心部署的 Riak,核心不是寻找一个全局一致的魔法配置,而是明确每个请求应该落在哪个集群,并且根据业务一致性要求设置 r/w/pr/pw 参数。把复制交给后台,把冲突解决留在应用层,同时做好延迟监控和灾备切换演练,才能在跨地域部署中真正获得高可用。