Riak是基于Amazon Dynamo论文实现的分布式键值数据库,它天生不提供JOIN、外键这类关系型数据库的关联能力。为了弥补这一点,早期版本的Riak引入了Links机制:在存储某个对象时,可以在HTTP头中附加一组指向其他对象的链接,之后客户端可以通过一次请求让Riak服务器端沿着这些链接逐级抓取目标对象,这个过程被称为link walking(链接遍历)。这个设计思路有点像在文档里内嵌了一张邻接表,配合Riak的mapreduce还能做更复杂的聚合。不过Links并不是图数据库那样的自由查询,它的遍历路径必须在请求时明确指定,这一点在动手之前一定要理解清楚。

Links的底层存储原理
在Riak中,链接并不是一个独立的索引结构,而是作为对象的元数据直接存放在对象本身的头部信息里。当你通过HTTP接口PUT一个对象时,只要带上X-Riak-Links请求头,Riak就会把这些链接序列化后与对象数据一起持久化到后端存储(比如Bitcask或LevelDB)中。
链接的格式遵循RFC 5988中定义的Link HTTP头规范,写法是依次罗列目标对象的地址并用riak_tag标识用途。一个典型的头部如下:
PUT /riak/customers/alice HTTP/1.1
X-Riak-Links: </riak/orders/order_1001>; riak_tag="placed",
</riak/orders/order_1002>; riak_tag="placed"
Content-Type: text/plain
Alice 的资料内容这里的关键点是:链接信息与对象数据是同生共死的。如果你删除了orders桶里的order_1001,customers桶里alice对象上的链接仍然存在,只是遍历到那一步时会拿到404。也就是说,Riak不会帮你做级联删除或引用完整性校验,这部分一致性责任完全落在应用层。理解了这一点,就明白为什么链接数量不宜过多——每次读取alice对象时,整个链接列表都会随元数据一起被加载和传输,链接膨胀会直接拖慢最普通的GET请求。
如何执行链接遍历查询
链接遍历通过在GET请求的URL后追加查询参数实现,语法是用下划线连接多个步骤,每个步骤的形式为bucket、tag、keep三段。bucket位置写下划线表示不限定桶,tag位置写下划线表示匹配任意标签,keep取1表示把该步骤的结果保留在最终返回值中,取0则只作为中间跳板不进入结果集。
举个例子,假设我们要找出alice下单的所有订单,请求可以写成下面这样,沿着placed标签一步直达订单对象:
GET /riak/customers/alice/_,placed,1
如果更进一步,想从顾客出发找到订单,再找到订单对应的商品,就需要两步遍历。第一步找到订单但不保留,第二步沿着item标签找到商品并保留:
GET /riak/customers/alice/orders,placed,0/products,item,1
最终响应是一个multipart消息,每个part对应一个命中的对象,客户端解析后即可拿到完整的结果集合。如果使用官方Python客户端库,写入链接的写法会更简洁一些:
import riak
client = riak.RiakClient()
bucket = client.bucket('customers')
alice = bucket.new('alice', data={'name': 'alice'})
# 添加指向订单的链接,标签为placed
order = client.bucket('orders').new('order_1001', data={'total': 99})
order.store()
alice.add_link(order, tag='placed')
alice.store()需要注意遍历结果的数量问题。每一步遍历得到的结果数量是乘法关系扩张的:如果一个顾客有20个订单,每个订单又链接了10个商品,两步遍历就会产生200个对象的读取请求。在数据规模较大的场景下,这个成本会迅速失控,这也是链接遍历不适合做深层广度查询的根本原因。
Links机制的局限与替代方案
链接遍历最大的问题是性能不可控。Riak集群中每个对象可能分布在不同的节点上,一次多步遍历意味着协调节点要发起大量跨节点的内部请求,而且整个操作是串行阻塞的,任何一步出现慢节点都会拖累整体响应时间。Riak官方在2.0版本之后已经明确将link walking标记为废弃特性,HTTP接口上的遍历语法在后续版本中被移除,只在元数据层面保留了链接的存储能力。
官方推荐的替代思路主要有三种。第一种是相邻文档模式:在对象A中直接用一个普通字段存储关联对象的键列表,读取时由客户端并发发起multi-get请求,遍历逻辑收敛到应用层,可控性和可调试性都更好。第二种是使用Riak Search(基于Solr构建的全文索引)建立二级索引,通过查询语法表达关联关系。第三种是引入Riak Data Types,比如利用集合类型在服务端维护关联数据。下面是用相邻文档模式改写前面例子的示意:
# 写入时把关联关系放进普通字段
customer = {
'name': 'alice',
'order_ids': ['order_1001', 'order_1002']
}
client.bucket('customers').new('alice', data=customer).store()
# 读取时用multiget并发拉取,不再依赖服务端遍历
orders = client.multiget([('orders', oid) for oid in customer['order_ids']])从架构角度看,Links的设计初衷是在KV存储之上低成本地补足轻量关联查询能力,这个思路在数据量小、遍历深度浅的场景下确实有效。但一旦图状关系变得复杂,它就会暴露出维护成本高、无法保证一致性、性能随深度指数恶化等一系列问题。如果你在维护存量系统,可以按上面的语法继续使用Links;如果是新项目,建议直接采用相邻文档加二级索引的组合方案,把关联查询交给更适合的组件去做,这样整个系统的扩展性会稳健得多。