在构建高可用分布式存储时,计数类需求几乎无法回避:页面访问量、库存扣减、用户积分都离不开一个能安全累加的数字。Riak作为一款面向可用性的AP系统,提供了名为counter的专用数据类型,它属于CRDT(无冲突复制数据类型)家族,专门解决多副本并发更新计数器时的合并难题。与传统数据库里直接执行UPDATE table SET count=count+1不同,Riak counter从设计上就放弃了强一致,转而保证任何情况下副本融合后结果确定且正确。

counter的底层结构与PN-Counter原理
Riak的counter实现了CRDT中的PN-Counter(Positive-Negative Counter)。它的核心思想是把一个逻辑计数器拆成两个映射:一个记录各节点发生的递增总量,另一个记录各节点发生的递减总量。每个节点在本地只修改属于自己的那一对分量,例如节点A自增时仅更新P[A],节点B自增时仅更新P[B],彼此不触碰对方的数据。这样并发写操作在物理上就被隔离到不同字段,自然不会产生写写冲突。
当系统需要读取当前计数值时,会把所有节点的P分量求和得到总增加量,再把所有节点的N分量求和得到总减少量,两者相减即为对外展示的数值。因为合并操作只是对各节点分量取最大值(即保留每个节点见过的最大累积值)后再相加,所以满足交换律、结合律与幂等性。即便同一条更新消息被送达多次,或者两个副本以不同顺序收到更新,最终合并出的P和N向量完全一致,计数结果也就必然相同。
下面用一段伪代码展示PN-Counter的本地模型与合并函数,帮助理解Riak在后台大致如何处理。注意代码里仅用普通映射表达,不涉及真实网络调用。
// 每个副本维护的PN-Counter结构
// counter = { p: {nodeA: 3, nodeB: 1}, n: {nodeA: 0, nodeB: 0} }
function local_increment(counter, nodeId, delta) {
if (!counter.p[nodeId]) counter.p[nodeId] = 0;
counter.p[nodeId] += delta;
}
function local_decrement(counter, nodeId, delta) {
if (!counter.n[nodeId]) counter.n[nodeId] = 0;
counter.n[nodeId] += delta;
}
function merge_counter(c1, c2) {
var result = { p: {}, n: {} };
var nodes = new Set([...Object.keys(c1.p), ...Object.keys(c2.p)]);
nodes.forEach(function(id) {
result.p[id] = Math.max(c1.p[id] || 0, c2.p[id] || 0);
});
var nodesN = new Set([...Object.keys(c1.n), ...Object.keys(c2.n)]);
nodesN.forEach(function(id) {
result.n[id] = Math.max(c1.n[id] || 0, c2.n[id] || 0);
});
return result;
}
function value(counter) {
var sumP = 0, sumN = 0;
Object.keys(counter.p).forEach(function(k) { sumP += counter.p[k]; });
Object.keys(counter.n).forEach(function(k) { sumN += counter.n[k]; });
return sumP - sumN;
}
从上述逻辑可以看出,Riak counter并不需要中心化的锁服务来协调自增请求。每个节点独立推进自己的分量,网络恢复后通过无冲突合并得出全局视图。这也是它能在分区期间依然允许写入、且事后不丢数的根本原因。不过代价是,若某个节点分量一直未被其他节点合并,读取时可能暂时看到偏小的数值,这正是最终一致性的直观体现。
Riak中counter的读写接口与使用示例
在Riak KV里,counter被归类为bucket type中的一种数据类型,通常需要在创建bucket时指定数据类型为counter。客户端可以通过专属的fetch与update指令来操作,而不用像操作普通对象那样自己序列化JSON。以官方Erlang客户端为例,更新一个计数器只需要指明增量,Riak会自动路由到负责该对象的vnode并修改对应节点分量。
下面的代码片段展示了如何使用Erlang对名为user_123_score的counter执行加五操作,并读取结果。实际项目中会把这些调用封装到服务层,避免业务代码直接依赖客户端细节。
% 连接Riak并选中counter类型的bucket
{ok, Pid} = riakc_pb_socket:start_link("127.0.0.1", 8087).
Bucket = {<<"counters">>, <<"user_123_score">>}.
% 执行递增五
{ok, C1} = riakc_pb_socket:fetch_type(Pid, Bucket).
{ok, C2} = riakc_counter:increment(C1, 5).
ok = riakc_pb_socket:update_type(Pid, Bucket, riakc_counter:to_op(C2)).
% 重新读取最新值
{ok, C3} = riakc_pb_socket:fetch_type(Pid, Bucket).
Score = riakc_counter:value(C3).
读流程上,Riak默认会向多个副本发起读请求并合并返回。因为counter的合并是确定性的,所以即使部分副本暂时落后,协调者节点也会把各副本的PN向量取最大后算出一个值返回。若业务要求更严格的读后写一致,可以调高读仲裁参数,让读操作等待更多副本响应,但会牺牲部分延迟与可用性。对于纯计数展示类场景,采用默认配置往往就能满足需求。
另外需要提醒,Riak counter只支持整数增减,不支持浮点。若业务里需要统计金额类小数,应改用其他CRDT如rw-set配合外部汇总,或者把单位放大成整数处理。同时counter无法回退到某个历史快照,因为它的设计目标是永远向前累加,历史分量会被持续合并进向量,不会保留时间线。
与传统计数方案及替代CRDT的对比
最常见的传统方案是关系数据库的自增列或带条件的UPDATE count=count+1。在单主架构下这能工作,但主库宕机切换时往往需暂停写入;在多主或分库分表环境,如果没有全局事务,就容易出现本文开头提到的更新丢失。另一种做法是把计数放到Redis并用INCR命令,但Redis默认异步复制,主节点挂掉且未同步的数据会永久消失,且集群跨机房脑裂时两边各增各的,恢复后无法自动和解。
Riak counter通过CRDT属性弥补了上述缺陷:它不依赖单点协调,分区期间两侧都能写,合并算法保证不丢增量。下表简要对比三者在并发写、分区容忍与合并正确性上的差异。
| 方案 | 并发写冲突 | 网络分区表现 | 恢复后计数 |
|---|---|---|---|
| 关系库单主自增 | 由主库串行化 | 从库不可写 | 不丢但停写 |
| Redis INCR | 单线程无冲突 | 脑裂两边独立 | 可能丢失或重复 |
| Riak counter | 分量隔离无冲突 | 两边均可写 | 自动合并准确 |
当然Riak counter并非银弹。它要求部署Riak集群本身带来运维复杂度,且只能表达一个可加减的整数,不能附带业务上下文。如果系统已经建立在PostgreSQL上且写入压力不大,引入Riak仅为计数并不划算;此时可考虑PostgreSQL里的触发器加乐观锁,或采用应用层定期校准。只有当系统本就使用Riak、或面临真正的高并发跨机房写计数时,counter数据类型的价值才最明显。
总结来看,Riak datatype counter计数器用PN-Counter模型把并发加法转化为各节点独立分量,再利用CRDT合并规则实现最终一致。它适合对可用性要求极高、能接受短暂读取偏差的计数场景。理解其原理与限制,才能在分布式架构中正确选用,而不是盲目替换现有方案。