使用 MongoDB 的用户细分引擎

User Segmentation Engine using MongoDB

我有一个分析系统,以事件的形式跟踪客户及其属性以及他们的行为。它是使用 Node.js 和 MongoDB(使用 Mongoose)实现的。

现在我需要实现一个分段功能,允许根据特定条件将存储的用户分组。例如 purchases > 3 AND country = 'Netherlands'

在前端看起来像这样:

这里的一个重要要求是分段实时更新,而不仅仅是定期更新。这基本上意味着,每当用户的属性发生变化或触发新事件时,我得再查一下他到底属于哪个段

我目前的方法是将段的条件存储为 MongoDB 查询,然后我可以在用户集合上执行以确定哪些用户属于某个段。

例如,过滤掉所有使用 Gmail 的用户的部分如下所示:

{
    _id: '591638bf833f8c843e4fef24',
    name: 'Gmail Users',
    condition: {'email': { $regex : '.*gmail.*'}}
}

当用户符合条件时,我会直接在用户文档中存储他属于 'Gmail Users' 段:

{
    username: 'john.doe',
    email: 'john.doe@gmail.com',
    segments: ['591638bf833f8c843e4fef24']
}

但是,如果这样做,每次用户数据更改时,我都必须对所有段执行所有查询,因此我可以检查他是否属于该段。从性能的角度来看,这感觉有点复杂和繁琐。

你能想出任何替代方法来解决这个问题吗?也许使用规则引擎并在应用程序中而不是在数据库中进行处理?

很遗憾,我不知道更好的方法,但您可以稍微优化一下这个解决方案。

我也会这样做:

  • 将分段条件存储在一个集合中
  • 找到匹配的用户后,将细分 ID 存储在用户的文档中 (segments)

An important requirement here is that the segments get updated in realtime and not just periodically.

你别无选择,每次段变化时你都需要运行分段查询。

I would have to execute all queries for all segments every time a user's data changes

这是我要更改您的解决方案的地方,实际上只是稍微优化一下:

  • 您不需要 运行 对整个集合进行分段查询。如果您将用户 ID 放入带有 $and 的查询中,Mongodb 将首先获取用户,然后再检查其余的分段条件。您需要确保 Mongodb 使用用户的 _id 作为索引,为此您可以使用 .explain() to check it or .hint() 强制它。不幸的是,如果您有 N 个细分,则需要 运行 N+1 个查询(+1 用于用户更新)

  • 我会获取每个段并将它们存储在缓存 (redis) 中。如果有人更改了段,我也会更新缓存。 (或者只是使缓存无效,下一个查询将处理其余部分,具体取决于实现)。关键是我将在不获取数据库的情况下拥有每个段,如果用户更新了一条记录,我将使用 Node.js 遍历每个段并根据条件验证用户,然后我可以更新用户的 segments原始更新查询中的数组,因此不需要任何额外的数据库操作。 我知道实现这样的东西可能会很痛苦,但它不会使数据库过载...

更新

关于我的第二个建议,让我给你一些技术细节: (这只是一个伪代码!)

段缓存

module.exporst = function() {
  return new Promise(resolve) {
    Redis.get('cache:segments', function(err, segments) {
      // handle error

      // Segments are cached
      if(segments) {
        segments = JSON.parse(segments);
        return resolve(segments);
      }

      //fetch segments and save it to the cache 
      Segments.find().exec(function(err, segments) {
        // handle error

        segments = JSON.stringify(segments);

        // Save to the database but set 60 seconds as an expiration
        Redis.set('cache:segments', segments, 'EX', 60, function(err) {
            // handle error

            return resolve(segments);
        })
      });
    })

   }
}

用户更新

// ...    
let user = user.findOne(_id: ObjectId(req.body.userId));
// etc ...

// fetch segments from cache or from the database
let segments = yield segmentCache();

let userSegments = [];
segments.forEach(function(segment) {
  if(checkSegment(user, segment)) {
    userSegments.push(segment._id)
  }
});

// Override user's segments with userSegments

这就是奇迹发生的地方,您需要以某种方式定义条件,以便在 if 语句中使用它们。

提示:Lodash 具有以下功能:_.gt、_.gte、_.eq ...

检查细分

module.exports = function(user, segment) {
  let keys = Object.keys(segment.condition);
  keys.forEach(function(key) {                
    if(user[key] === segment.condition[key]) {
      return false;
    } 
  }) 

  return true;
}

您已经将整个段 "query" 存储在段集合中的文档中 - 为什么不在同一文档中包含一个字段,该字段将枚举用户文档中的哪些字段影响特定段中的成员资格。

由于更改用户数据的操作将知道正在更改哪些字段,因此它可以仅获取使用正在更改的字段计算的段,从而显着减少分段的大小"queries"您必须重新运行.

请注意,用户数据的更改可能会将他们添加到他们当前不属于的段中,因此仅检查当前存储在用户中的段是不够的。