Выполнять действие, когда сущность соответствует пользовательскому запросу/фильтру/правилу

Обычно вы пишете запрос и получаете все записи (сущности), которые соответствуют ему. Мне нужно сделать обратный.

Скажем, у меня 1M клиентов с несколькими дюжинами денормализованных свойств:

public class Customer {
  public string Name {get;set;}
  public string Email {get;set;}
  public string Phone {get;set;}
  public DateTime Birthday {get;set;}
  public DateTime LastEmailed {get;set;}
  public DateTime LastCalled {get;set;}
  public int AgeInYears {get { return DateTime.UtcNow.Year - birthdate.Year;}}
  public int SalesTerritoryId {get;set;}
  // etc.

}

И у меня есть 10k пользователей, которые хотят настроить пользовательские фильтры и получать уведомления, когда любой новый клиент соответствует установленным им правилам.

Некоторые из этих правил оцениваются при создании/обновлении клиента (например.)

  • Клиенты с номером телефона И на моей торговой территории.
  • Клиенты с электронной почтой и LastEmailed являются NULL, а торговая территория IN (1, 7, 11)

Другие правила будут периодически выполняться (например.)

  • Клиенты с днем ​​рождения сегодня.

Ежедневно для клиентов будут сохраняться миллионы сохраненных и настраиваемых фильтров 5-10 тыс. для каждого нового/обновленного клиента.

Я понимаю, что я могу использовать Деревья выражений для пользовательских фильтров, но затем в итоге сделаю что-то вроде этого:

public class CustomerRule : IRule {

  public bool IsMatch() {
    // Expression Tree Stuff
  }

  public bool DoAction() {
    // Notification Stuff
  }
}

public class CustomerService {

  public void SaveOrUpdate {
    IList<IRule> rules = GetRules();

    // this isn't going to handle 1M save/updates * 10k rules very well
    foreach (var rule in rules){
      if(rule.IsMatch()) {
        rule.DoAction();
      }          
    }      
  }
}

Я знаю, что другие решили эту проблему, но мне сложно определить, что именно искать. Общее руководство ценится, конкретные шаблоны, код, инструменты и т.д. Еще лучше. В первую очередь мы используем С#, но при необходимости можем выйти за пределы .NET.

Ответ 1

Я бы упомянул другую точку, чем другие ответы. Вы заявляете в своем коде, что

// this isn't going to handle 1M save/updates * 10k rules very well

Но вы действительно это подтвердили? Рассмотрим этот код:

public class Program {
    static List<Func<Customer, bool>> _rules = new List<Func<Customer, bool>>();
    static void Main(string[] args) {
        foreach (var i in Enumerable.Range(0, 10000)) {
            // generate simple expression, but joined with OR conditions because 
            // in this case (on random data) it will have to check them all
            // c => c.Name == ".." || c.Email == Y || c.LastEmailed > Z || territories.Contains(c.TerritoryID)

            var customer = Expression.Parameter(typeof(Customer), "c");
            var name = Expression.Constant(RandomString(10));
            var email = Expression.Constant(RandomString(12));
            var lastEmailed = Expression.Constant(DateTime.Now.AddYears(-20));
            var salesTerritories = Expression.Constant(Enumerable.Range(0, 5).Select(c => random.Next()).ToArray());
            var exp = Expression.OrElse(Expression.OrElse(Expression.OrElse(
            Expression.Equal(Expression.PropertyOrField(customer, "Name"), name),
            Expression.Equal(Expression.PropertyOrField(customer, "Email"), email)),
            Expression.GreaterThan(Expression.PropertyOrField(customer, "LastEmailed"), lastEmailed)),
            Expression.Call(typeof(Enumerable), "Contains", new Type[] {typeof(int)}, salesTerritories, Expression.PropertyOrField(customer, "SalesTerritoryId")));
            // compile
            var l = Expression.Lambda<Func<Customer, bool>>(exp, customer).Compile();
            _rules.Add(l);
        }

        var customers = new List<Customer>();
        // generate 1M customers
        foreach (var i in Enumerable.Range(0, 1_000_000)) {
            var cust = new Customer();
            cust.Name = RandomString(10);
            cust.Email = RandomString(10);
            cust.Phone = RandomString(10);
            cust.Birthday = DateTime.Now.AddYears(random.Next(-70, -10));
            cust.LastEmailed = DateTime.Now.AddDays(random.Next(-70, -10));
            cust.LastCalled = DateTime.Now.AddYears(random.Next(-70, -10));
            cust.SalesTerritoryId = random.Next();
            customers.Add(cust);
        }
        Console.WriteLine($"Started. Customers {customers.Count}, rules: {_rules.Count}");
        int matches = 0;
        var w = Stopwatch.StartNew();
        // just loop
        Parallel.ForEach(customers, c => {
            foreach (var rule in _rules) {
                if (rule(c))
                    Interlocked.Increment(ref matches);
            }
        });
        w.Stop();
        Console.WriteLine($"matches {matches}, elapsed {w.ElapsedMilliseconds}ms");
        Console.ReadKey();
    }

    private static readonly Random random = new Random();
    public static string RandomString(int length)
    {
        const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        return new string(Enumerable.Repeat(chars, length)
          .Select(s => s[random.Next(s.Length)]).ToArray());
    }
}

public class Customer {
    public string Name { get; set; }
    public string Email { get; set; }
    public string Phone { get; set; }
    public DateTime Birthday { get; set; }
    public DateTime LastEmailed { get; set; }
    public DateTime LastCalled { get; set; }

    public int AgeInYears
    {
        get { return DateTime.UtcNow.Year - Birthday.Year; }
    }

    public int SalesTerritoryId { get; set; }
}

Здесь я генерирую правила 10K в форме выражений. Они простые, но не тривиальные - 4 условия, соединенные с OR, со строками, датами, Содержит. Затем я генерирую 1M клиентские обновления (количество клиентов в вашей базе данных не имеет значения - мы работаем только с обновлениями) и просто запускаем цикл. Угадай, сколько времени занимает мой обычный (не сервер) ПК? 4 минуты.

Итак, все ваши правила для всех обновлений клиента за весь день можно проверить всего за 4 минуты (на правильном сервере он должен быть как минимум на x2 быстрее, чем, вероятно, больше). Проверка одного обновления по правилам 10K занимает несколько миллисекунд. Учитывая это - вы, скорее всего, будете иметь узкие места в любом другом месте, а не здесь. Вы можете применить несколько тривиальных оптимизаций, если хотите:

  • Свернуть одинаковые правила. Нет необходимости проверять правило "есть день рождения сегодня" для каждого пользователя.

  • Сохранять свойства, которые используются в правиле, а также отметить, какие столбцы были обновлены в Customer. Не запускайте правила, которые не используют столбцы, обновленные в Customer.

Но на самом деле это может даже замедлить вас, а не ускоряться, поэтому все должно быть измерено.

Не отправлять уведомления из того же кода, который проверяет правильность. Поместите их в очередь и разрешите другим процессам\потокам их обрабатывать. Правила проверки - это строго работа с привязкой к ЦП, а отправка уведомлений (я предполагаю, в вашем случае) привязана к IO, поэтому вы действительно можете сделать это на одной машине в одном процессе. Вы также не хотите спамить данного пользователя уведомлениями с такой скоростью, вы, скорее всего, отправляете их в партии, не более одной партии в минуту, я думаю, так что это будет не слишком дорого.

Что касается самих обновлений клиента, вы можете сохранить их в некоторой очереди (например, rabbitMQ), использовать уведомления по базам данных (например, postgresql pg_notify) или только базу данных опроса каждую минуту, чтобы получить все обновления за этот период. Опять же, необходимо измерить различные подходы.

В дополнение к этому задача такого рода легко распараллеливается на нескольких машинах, поэтому, если вы когда-либо ударяете клиентов 100M - никаких проблем, вы можете просто добавить еще один сервер (или, может быть, все будет хорошо).

Ответ 2

Существенный вопрос:

Как вы определяете и сохраняете свои настраиваемые фильтры (правила)?

Вы упомянули "5-10k настраиваемых фильтров для проверки". Если число настолько велико, вы, вероятно, имеете некоторую гибкую структуру для правила, например

<field> <operator> <value> (e.g. <LastEmailed> <is> <NULL>)

со всем многообразием, лежащим в значениях для <field>, <operator> и <value>.

Если это так, то для нового/обновленного клиента вы можете выбрать все правила, которые удовлетворяют его данным. Это может быть сделано либо с помощью одного запроса, либо с помощью хранимой процедуры с некоторой степенью сложности. Это действительно зависит от дизайна вашей базы данных.

Мое главное: если ваши правила хранятся в вашей базе данных, вы можете проверить с помощью чистого SQL, соответствуют ли некоторые данные правилу.

Такая проверка против правил ~ 10k не должна стоить слишком многого с точки зрения производительности. Опять же, это действительно зависит от структуры вашей БД и размера таблиц, которые должны быть объединены, чтобы "скомпилировать" и проверить правило.

Конечно, может случиться так, что у вас есть ограниченный набор правил, которые достаточно сложны для проверки только из .NET-кода. Это нормально, если у вас есть цикл foreach для них, так как количество таких правил не должно быть большим.

Я согласен с Federico Dipuma в том, что асинхронная обработка является опцией. Однако это должен быть ваш второй выбор, если вышеприведенный подход не работает. Скорее всего, вы выбираете асинхронный подход для выполнения действий по согласованным правилам, поскольку такие операции обычно очень трудоемки (например, отправка электронной почты или другое уведомление, INSERT или UPDATE в базе данных и т.д.).

Ответ 3

С 1M-обновлениями и правилами 10k вам нужно уменьшить количество проверяемых правил. Поскольку у вас есть только несколько десятков свойств, это должны быть ваши критерии выбора, которые должны выполняться. Сначала отфильтруйте правила для проверки на основе того, какие свойства присутствуют в правиле, и сравните, с каким обновлением свойств.

  • Добавьте поле SearchParameters в правило и дайте ему значение 010405, если правило содержит только параметр 01 (имя), 04 (день рождения) и 05 (lastemailed).
  • Храните поисковые параметры (и ссылку на правило) в отдельной таблице, упорядоченной в порядке возрастания.
  • Когда пользователь обновляет свою запись, получайте параметры, которые обновляются по номеру 02, 06 и 07, если эти параметры будут обновлены.
  • Чем в списке SearchParameters найдите все значения (и соответствующую ссылку на правило), содержащую SearchParameters обновления. Поскольку это упорядоченный список, это можно сделать очень эффективно.
  • Теперь у вас есть сводный список правил с правилами, содержащими хотя бы один из измененных параметров. Этот сокращенный список правил, которые вам нужно проверить в каждом цикле.

Я надеюсь, что идея понятна, здесь есть разные/лучшие варианты реализации.

Я думаю, что более эффективная реализация может быть выполнена с помощью двумерного булевого массива, где каждая строка является правилом, а каждый столбец является параметром. Так что-то вроде этого:

rules  | param1 | param2 | param3 | ...
rule1  |   0    |   1    |   0    | ...
rule2  |   1    |   0    |   1    | ...
rule3  |   1    |   1    |   1    | ...

Затем, после обновления, просто получите столбец соответствующего параметра и получите все правила, в которых параметр равен 1.

Еще один вариант (, который считается лучшим и быстрым) основывает его полностью на SQL. Основная идея остается относительно одинаковой, за исключением того, что правила должны храниться как SQL в таблице правил, поэтому вы получаете следующую таблицу:

rule_table
ruleNr  | param1 | param2 | param3 | rule
   1    |   0    |   1    |   0    | SELECT recordID FROM Customer WHERE name LIKE 'Will%' AND location = US; 
   2    |   1    |   0    |   1    | SELECT recordID FROM Customer WHERE name = 'West' AND ...;
   3    |   1    |   1    |   1    | SELECT recordID FROM Customer WHERE ...;

При обновлении или создании клиента выполняется следующий запрос, который выбирает все правила, содержащие один из обновленных параметров. Где все обновленные параметры должны быть в запросе.

  SELECT rule FROM rule_table WHERE param1 = 1 OR param4 = 1 OR ....

Этот запрос содержит список применимых правил SQL, которые должны быть отформатированы корректно. Проходите через каждый запрос SQL и обрабатывайте результаты. Результаты SQL-запроса, хранящиеся в таблице, представляют собой список идентификаторов записи, указывающих на эту конкретную запись клиента.

Надеюсь, это немного поможет.

Ответ 4

Выполнение всех фильтров в порядке, каждый раз, когда пользователь делает запрос, будет трудно, если не невозможно сделать почти сразу.

Как настраивать очереди сообщений, а затем разбивать фильтры на разные задачи выполнения, которые вы добавляете, когда пользователи сохраняют их?

Вы можете настроить несколько очередей для разных типов фильтров (дни рождения/местоположение/промышленность/и т.д.), а затем у разных работников наблюдать за очередями изменений. Выполняйте сообщения в очереди на день рождения один раз в день, постоянно выполняйте создания и обновления пользователей и т.д. И ставьте больше рабочих против более тяжелых, чтобы быстрее обрабатывать сообщения. Вы можете включить больше рабочих в часы пик и немного отключиться во время простоя.

Вы можете разделить работников на определенные количества фильтров/результаты. Так что у разных сотрудников есть разные фильтры или более длинные фильтры и объединяйте результаты (или добавьте/удалите результаты по мере завершения фильтрации). Они могут запускаться параллельно по мере запуска задач, одновременно обрабатывая разные фильтры.

Сохраняйте результаты в базе данных документа или кешируйте их на сервере Redis и извлекайте результаты из этого.

Ответ 6

Вы определенно не хотите откладывать сохранение записи в базе данных для запуска правил. Любая ошибка, возникающая в IsMatch() или DoAction(), может потенциально прервать сохраненные данные. Я бы принял предупреждение о том, что это кто-то, что день рождения не так важен, как фактически добавление человека в базу данных.

Я бы подумал добавить событие добавления/обновления в систему очередей. Теперь не думайте о системе очередей, как о том, где вещи складываются и ждут длительные периоды времени! ОС Windows представляет собой систему очередей, она использует очереди сообщений для почти всего. Таким образом, метод CustomerService.SaveOrUpdate, который вы отправили, отправил событие (или сообщение, которое было бы проще подумать об этом как событие) в вашу очередь "Обновленный пользователь". В этой очереди будет один или несколько слушателей, ожидающих появления событий. Затем они будут принимать это событие и находить какие-либо правила, соответствующие этим данным, и выполнять соответствующие действия.

Красота использования системы очередей заключается в том, что вы можете разгрузить обработку на выделенный компьютер, а не заставлять систему, которая отвечает за получение данных, хранящихся в ваших хранилищах данных. Слушатель очереди, отвечающий за обработку правил, может загружать правила в память, что позволит ему находить, какие правила применяются намного быстрее, чем ежедневная загрузка их из базы данных для каждого из десяти тысяч обновлений. Я бы рискнул сказать, что GetRules() - довольно интенсивный процесс, так как он, вероятно, прочитает сгенерированные правила из базы данных и преобразует их каждый в объекты дерева выражений. Наличие специального механизма правил, который слушает очередь для того, чтобы применить его правила против, будет быстрее!

Одна из лучших особенностей подхода к очереди/слушателя заключается в том, что он очень расширяемый. Если очередь когда-либо начинает резервное копирование, и ваш механизм правил просто не может идти в ногу, у вас есть варианты! Самый быстрый/облегчающий способ сохранить очередь ниже... запустите еще один движок правил, который слушает одну и ту же очередь! Правильно, вы можете иметь несколько слушателей в очереди, и в зависимости от того, как вы настроите настройки, вы можете убедиться, что сообщение отправлено одним и только одним слушателем.

Еще один бонус, когда вам нужно обновить механизм правил, вы можете удалить существующий из службы, заменить его и запустить новую базу кода. Вы не боитесь потерять что-либо, очередь будет продолжать ожидание событий, и когда вы запустите новый код, он начнет обработку этих событий.

Очередь/слушатели довольно легко настраиваются при тестировании. Я использовал MSMQ для нескольких своих решений для стеков Microsoft. Я также использовал activeMQ для решения на основе Java.

Итак, вы комбинируете это с тем, что Evk говорит... ваше решение с деревьями выражений не медленное, по крайней мере, когда правила находятся в памяти. Хотя по этому вопросу вы хотели бы периодически обновлять эти "правила в памяти". У вас может быть заданный период, как каждые 15 минут, или вы могли бы пойти более подробно и иметь огонь событий, когда вызывается метод SaveOrUpdate. Я мог бы выбрать запуск мероприятия, но все это будет зависеть от потребностей бизнеса.

Вы также можете пропустить метод очереди и просто создать службу, скажем, WCF, которая будет принимать данные клиента и обрабатывать правила. Если ваши предупреждения говорят об увольнении в клиенте, который сохраняет данные, они могут ждать ответа, или вы можете использовать дуплексную услугу, где служба может нажимать на клиента оповещения. Единственным недостатком этого подхода является то, что будет использоваться только одна конкретная услуга, поэтому вы не сможете удвоить пропускную способность, просто включив вторую услугу. Вы можете добавить возможность отправлять уведомления клиентам из очереди/слушателя, это всего лишь немного больше.

В любом случае, длинный рассказ короткий - слишком поздно! Существуют варианты, которые сделают вашу текущую реализацию дерева выражений очень жизнеспособной. Я лично считаю, что вы на правильном пути. У меня создается впечатление, что ваши потребности состоят в том, чтобы конечные пользователи создавали и поддерживали эти правила, и поэтому они не могут быть слишком жесткими, и поэтому создание любого типа группировки/бинарного решения для быстрого отклонения больших групп правил не будет вариантом, Вы в конечном итоге будете работать над управлением группами правил намного дольше, чем сэкономить время.

Я думаю, у меня есть много, чтобы сказать об этом и на самом деле нет примеров кодирования, поскольку вам нужно будет выбрать технологию очереди и, возможно, просто пройтись по своим "начинающим" документам.

Удачи.