Оптимизация запроса и / или модели данных для приложения календаря - PullRequest
3 голосов
/ 01 августа 2010

Наше приложение календаря представляет домен назначения в виде:

Назначение

  • ID (PK)
  • StartDateTime
  • EndDateTime
  • ...

AppointmentRole

  • AppointmentID (FK)
  • PersonOrGroupID (FK)/ * присоединяется к человеку / группе, выходящим за рамки этого вопроса * /
  • Роль
  • ...

Встреча имеет отношение 1 ко многим сAppointmentRoles.Каждое AppointmentRole представляет человека или группу в определенной роли (например, отстранение от участия, получение, посещение, ...).

Отношения служат двум целям:

  1. он определяет список контроля доступа - аутентифицированный принципал может просматривать встречу, только если его список контроля доступа соответствует ассоциированному лицу или группе
  2. , он документирует, кто посещает встречу и в каких ролях.

Существует также третья таблица для отслеживания заметок / комментариев, связанных с назначением.Он находится на многих сторонах отношения «1 ко многим» с Appointment:

AppointmentNote

  • AppointmentID (FK)
  • ...

Для отображения календаря встреч в настоящее время мы используем что-то вроде ...

List<IAppointment> GetAppointments(IAccess acl, DateTime start, DateTime end, ...
{
  // Retrieve distinct appointments that are visible to the acl

  var visible = (from appt in dc.Appointments
                 where !(appt.StartDateTime >= end || appt.EndDateTime <= start)
                 join role in
                   (from r in dc.Roles
                    where acl.ToIds().Contains(r.PersonOrGroupID)
                    select new { r.AppointmentID })
                 on appt.ID equals role.AppointmentID
                 select new
                 {
                   ...
                 }).Distinct();

  ...

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

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

  ...

  // Join/into to get all appointment roles and notes

  var q = from appt in visible
          orderby appt.StartDateTime, ...
          join r in dc.Roles
          on appt.ID equals r.AppointmentID
          into roles
          join note in dc.AppointmentNotes
          on appt.ID equals note.AppointmentID
          into notes
          select new { Appointment = appt, Roles = roles, Notes = notes };

Наконец, мы перечислим запрос, надеясь, что Linq-To-Sql сгенерирует один невероятно оптимизированный запрос (не такая удача, как обсуждается позже) ...

  // Marshal the anonymous type into an IAppointment
  // IAppointment has a Roles and Notes collection

  var result = new List<IAppointment>();
  foreach (var record in q)
  {
    IAppointment a = new Appointment();
    a.StartDateTime = record.StartDateTime;
    ...
    a.Roles = Marshal(record.Roles);
    a.Notes = Marshal(record.Notes);

    result.Add(a);
  }

Запрос, созданный Linq-to-Sql, очень разговорчив.Он генерирует один запрос для определения видимых встреч.Но затем он генерирует три запроса на каждую итерацию: один для выбора полей назначения, второй для выбора ролей и третий для сбора заметок.Предложение where всегда является видимым идентификатором встречи.

Итак, мы проводим рефакторинг GetAppointments и думаем, что сможем извлечь пользу из опыта сообщества SO.

Мы ожидаем перенести все в T-SQLсохраненный процесс, поэтому у нас больше контроля.Можете ли вы поделиться своими мыслями о том, как вы будете решать эту проблему?Изменения в модели данных, модификации T-SQL и Linq-to-SQL - все это честная игра.Мы также хотели бы совет по индексам.Мы используем MS-SqlServer 2008 и .NET 4.0.

Ответы [ 3 ]

3 голосов
/ 02 августа 2010

Я бы сказал, корень всех зол начинается здесь:

where acl.ToIds().Contains(r.PersonOrGroupID) 

acl.ToIds().Contains(...) - это выражение, которое не может быть разрешено на стороне сервера, поэтому запрос visible должен быть разрешен (очень неэффективно) на стороне клиента, и, что еще хуже, результат должен быть возвращен клиенту и затем, когда он повторяется, необходимо отправлять на сервер отдельные запросы для каждого видимого назначения (поля назначений, роли и заметки). Если бы у меня все было по-другому, я бы создал хранимую процедуру, которая принимает список ACL как Табличный параметр и выполняет все соединения / фильтрацию на стороне сервера.

Я начну с этой схемы:

create table Appointments (
    AppointmentID int not null identity(1,1),
    Start DateTime not null,
    [End] DateTime not null,
    Location varchar(100),
    constraint PKAppointments
        primary key nonclustered (AppointmentID));

create table AppointmentRoles (
    AppointmentID int not null,
    PersonOrGroupID int not null,
    Role int not null,
    constraint PKAppointmentRoles
        primary key (PersonOrGroupID, AppointmentID), 
    constraint FKAppointmentRolesAppointmentID
        foreign key (AppointmentID)
        references Appointments(AppointmentID));

create table AppointmentNotes (
    AppointmentID int not null,
    NoteId int not null,
    Note varchar(max),

    constraint PKAppointmentNotes
        primary key (AppointmentID, NoteId),
    constraint FKAppointmentNotesAppointmentID
        foreign key (AppointmentID)
        references Appointments(AppointmentID));
go

create clustered index cdxAppointmentStart on Appointments (Start, [End]);
go

И получить назначения для произвольного ACL следующим образом:

create type AccessControlList as table 
    (PersonOrGroupID int not null);
go

create procedure usp_getAppointmentsForACL
 @acl AccessControlList readonly,
 @start datetime,
 @end datetime
as
begin
    set nocount on;
    select a.AppointmentID
        , a.Location
        , r.Role
        , n.NoteID
        , n.Note
    from @acl l 
    join AppointmentRoles r on l.PersonOrGroupID = r.PersonOrGroupID
    join Appointments a on r.AppointmentID = a.AppointmentID
    join AppointmentNotes n on n.AppointmentID = a.AppointMentID
    where a.Start >= @start
    and a.[End] <= @end;    
end
go

Давайте попробуем это на 1M встречах. Сначала заполните таблицы (это займет около 4-5 минут):

set nocount on;
declare @i int = 0;
begin transaction;
while @i < 1000000
begin
    declare @start datetime, @end datetime;
    set @start = dateadd(hour, rand()*10000-5000, getdate());
    set @end = dateadd(hour, rand()*100, @start)
    insert into Appointments (Start, [End], Location)
    values (@start, @end, replicate('X', rand()*100));

    declare @appointmentID int = scope_identity();
    declare @atendees int = rand() * 10.00 + 1.00;
    while @atendees > 0
    begin
        insert into AppointmentRoles (AppointmentID, PersonOrGroupID, Role)
        values (@appointmentID, @atendees*100 + rand()*100, rand()*10);
        set @atendees -= 1;
    end

    declare @notes int = rand()*3.00;
    while @notes > 0
    begin
        insert into AppointmentNotes (AppointmentID, NoteID, Note)
        values (@appointmentID, @notes, replicate ('Y', rand()*1000));
        set @notes -= 1;
    end

    set @i += 1;
    if @i % 10000 = 0
    begin
        commit;
        raiserror (N'Added %i appointments...', 0, 1, @i);
        begin transaction;
    end
end
commit;
go

Итак, давайте посмотрим сегодня назначения на несколько человек:

set statistics time on;
set statistics io on;

declare @acl AccessControlList;
insert into @acl (PersonOrGroupID) values (102),(111),(131);
exec usp_getAppointmentsForACL @acl, '20100730', '20100731';

Table 'AppointmentNotes'. Scan count 8, logical reads 39, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Appointments'. Scan count 1, logical reads 9829, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'AppointmentRoles'. Scan count 3, logical reads 96, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table '#25869641'. Scan count 1, logical reads 1, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 63 ms,  elapsed time = 1294 ms.

 SQL Server Execution Times:
   CPU time = 63 ms,  elapsed time = 1294 ms.

1,2 секунды (в холодном кеше, в теплом кеше - 224 мс). Хм, это не очень хорошо. Проблема в том, что 9829 страниц попали в таблицу встреч. Чтобы улучшить это, мы хотели бы иметь оба критерия фильтрации (acl и date) одновременно. Возможно индексированное представление?

create view vwAppointmentAndRoles 
with schemabinding
as
select r.PersonOrGroupID, a.AppointmentID, a.Start, a.[End]
from dbo.AppointmentRoles r
join dbo.Appointments a on r.AppointmentID = a.AppointmentID;
go

create unique clustered index cdxVwAppointmentAndRoles on vwAppointmentAndRoles (PersonOrGroupID, Start, [End]);
go

alter procedure usp_getAppointmentsForACL
 @acl AccessControlList readonly,
 @start datetime,
 @end datetime
as
begin
    set nocount on;
    select ar.AppointmentID
        , a.Location
        , r.Role
        , n.NoteID
        , n.Note
    from @acl l 
    join vwAppointmentAndRoles ar with (noexpand) on l.PersonOrGroupID = ar.PersonOrGroupID
    join AppointmentNotes n on n.AppointmentID = ar.AppointMentID
    join Appointments a on ar.AppointmentID = a.AppointmentID
    join AppointmentRoles r 
        on ar.AppointmentID = r.AppointmentID
        and ar.PersonOrGroupID = r.PersonOrGroupID
    where ar.Start >= @start
     and ar.Start <= @end
    and ar.[End] <= @end;   
end
go

Мы также можем изменить кластеризованный индекс на Назначениях на более полезный AppointmentID:

drop index cdxAppointmentStart on Appointments;
create clustered index cdxAppointmentAppointmentID on Appointments (AppointmentID);
go

Возвращает встречи в том же списке @acl для того же диапазона дат в 77 мс (в теплом кэше).

Теперь, конечно, фактическая схема, которую вы должны использовать, зависит от гораздо большего числа факторов, которые не принимаются во внимание. Но я надеюсь, что это дало вам представление о правильных действиях, которые необходимо предпринять сейчас, чтобы добиться достойных результатов. Добавление табличного параметра в контекст выполнения клиента и его передачу в процедуру, а также интеграция с LINQ оставлены читателю в качестве упражнения.

2 голосов
/ 02 августа 2010

Если я правильно понимаю, и Appointment имеет коллекцию Roles и коллекцию Notes. Если это так (и вы правильно смоделировали это в конструкторе), у вас есть эти Roles и Notes свойства в классе Appointment. Когда вы изменяете проекцию (select) вашего запроса q, выбираете саму Appointment, вы можете помочь LINQ to SQL получить следующие коллекции для вас. В этом случае вы должны написать свой запрос следующим образом:

var q =
    from appt in visible
    ...
    select appt;

После этого вы можете использовать свойство LoadOptions DataContext, чтобы предварительно выбрать для вас подколлекцию следующим образом:

using (var db = new AppointmentContext())
{
    db.LoadOptions.LoadWith<Appointment>(a => a.Roles);

    // Do the rest here
}

Одна из проблем, однако, здесь заключается в том, что я думаю, что LoadWith ограничен для загрузки одной подколлекции, а не двух.

Вы можете решить эту проблему, написав это в двух запросах. Первый запрос: вы выбираете встречи и используете LoadWith, чтобы получить все Roles. Затем используйте второй запрос (в новом DataContext) и используйте LoadWith, чтобы получить все Notes).

Удачи.

1 голос
/ 02 августа 2010
where !(appt.StartDateTime >= end || appt.EndDateTime <= start)

Это может быть очень хорошим критерием AND.

where appt.StartDateTime < end && start < appt.EndDateTime

acl.ToIds().

Извлечь это из запроса, бессмысленно просить базу данных выполнить операцию.

List<int> POGIDs = acl.ToIds();

join role in

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


Попробуйте сделать это с DataLoadOptions и без него.Если запрос работает без DataLoadOptions, есть другой (более ручной) способ загрузки связанных строк.

DataLoadOptions myOptions = new DataLoadOptions();
myOptions.LoadWith<Appointment>(appt => appt.Roles);
myOptions.LoadWith<Appointment>(appt => appt.Notes);
dc.LoadOptions = myOptions;


List<int> POGIDs = acl.ToIds();

IQueryable<Roles> roleQuery = dc.Roles
  .Where(r => POGIDs.Contains(r.PersonOrGroupId));

IQueryable<Appointment> visible =
  dc.Appointments
    .Where(appt => appt.StartDateTime < end && start < appt.EndDateTime)
    .Where(appt => appt.Roles.Any(r => roleQuery.Contains(r));

IQueryable<Appointment> q =
  visible.OrderBy(appt => appt.StartDateTime);

List<Appointment> rows = q.ToList();

Вот «более ручной» способ получения связанных данных.ПРИМЕЧАНИЕ. Этот метод прерывается, когда в apptIds или POGID содержится более ~ 2100 дюймов.Есть способ обойти это тоже ...

List<int> POGIDs = acl.ToIds();

List<Role> visibleRoles = dc.Roles
  .Where(r => POGIDs.Contains(r.PersonOrGroupId)
  .ToList()

List<int> apptIds = visibleRoles.Select(r => r.AppointmentId).ToList();

List<Appointment> appointments = dc.Appointments
  .Where(appt => appt.StartDateTime < end && start < appt.EndDate)
  .Where(appt => apptIds.Contains(appt.Id))
  .OrderBy(appt => appt.StartDateTime)
  .ToList();

ILookup<int, Roles> appointmentRoles = dc.Roles
  .Where(r => apptIds.Contains(r.AppointmentId))
  .ToLookup(r => r.AppointmentId);

ILookup<int, Notes> appointmentNotes = dc.AppointmentNotes
  .Where(n => apptIds.Contains(n.AppointmentId));
  .ToLookup(n => n.AppointmentId);

foreach(Appointment record in appointments)
{
  int key = record.AppointmentId;
  List<Roles> theRoles = appointmentRoles[key].ToList();
  List<Notes> theNotes = appointmentNotes[key].ToList();
}

Этот стиль подчеркивает, где нужны индексы:

Roles.PersonOrGroupId
Appointments.AppointmentId (should be PK already)
Roles.AppointmentId
Notes.AppointmentId
...