GolosMovement / StatElectGenerator

Project to autmatically process electoral data
2 stars 0 forks source link

Расчетная таблица Flexible pivot table #96

Open BlackWrangler opened 6 years ago

BlackWrangler commented 6 years ago

Функция PIVOT строит простую и удобную таблицу за 50 секунд для 100 000 записей. Таблица может быть global temporary или обычной. Разница только в "##" ('CalcTbl' + convert(nvarchar,@PrSetID) или ('##CalcTbl' + convert(nvarchar,@PrSetID)

Если global temporary будет быстро умирать, можно пользоваться постоянной. Размер - 10 Мб для самых больших выборов. Индексов, кажется, нет.

С помощью этой расчетной таблицы, как временной, так и реальной scatter-plot рассчитывается за 15 секунд, histogram - 24 секунды на 100 000 записей. Большая часть времени уходит на построение иерархии.

Это примитивными SQL query кусками найдеными в Интернете. Наверно, RDM сможет лучше. Формулы, кажется, абсолютно аналогичны задаваемыми при настройке пресетов.

Построение расчетной таблицы можно сделать гибким, не зависящим от конкретных выборов. Был бы только ProtocolSetID (в скрипте - @PrSetID)

Скрипт для построения global temporary:

--- создание временной таблицы, если её нет 
DECLARE @columns NVARCHAR(MAX), @sql NVARCHAR(MAX), @PrSetID Int;
SET @PrSetID = 9;
IF OBJECT_ID('tempdb..##CalcTbl' + convert(nvarchar,@PrSetID)) IS NULL
-- заполняем таблицу значениями выборов
BEGIN

SET @columns = N'';
SELECT  @columns += N', ln.' + QUOTENAME(ln)  
FROM 
(SELECT LineDescriptions.Id as ln
FROM LineDescriptions 
where 
  LineDescriptions.ProtocolSetId=@PrSetID and LineDescriptions.IsCalcResult=1

  ) AS x;

SET @sql = N'
SELECT prid,' + STUFF(@columns, 1, 2, '') + '
INTO ##CalcTbl' + convert(nvarchar,@PrSetID)  +'
FROM
(
SELECT ln.ProtocolId as prid, ln.LineDescriptionId, ln.Value

FROM LineNumbers as ln
Join 
LineDescriptions on LineDescriptions.id=ln.LineDescriptionId
where LineDescriptions.ProtocolSetId=' + convert(nvarchar,@PrSetID)  +')
 AS j
PIVOT   
(
  SUM(Value) FOR LineDescriptionId IN ('
  + STUFF(REPLACE(@columns, ', ln.[', ',['), 1, 1, '')
  + ')
) AS ln;';
--PRINT @sql;
EXEC sp_executesql @sql;

END

Для постоянной: убрать ## и использовать IF OBJECT_ID('CalcTbl' + convert(nvarchar,@PrSetID), 'U') IS NULL

Получается табличка типа:

image

Скаттер-плот (15 секунд)

-- scatter-plot из постоянной 

--from CalcTbl9

-- для получения всех childen
with Hierachy(ID, ParentID, TitleRus )
as
(
select id, ParentID, TitleRus 
    from Protocols c
    where c.ProtocolSetId = 9 and ParentID is null
    --where c.Id in (460424, 465325) -- insert parameter here
    union all
    select c.Id, c.ParentID, c.TitleRus 
    from Protocols c
    inner join Hierachy ch
    on c.ParentId = ch.ID
)

Select hierachy.ID, TitleRus, 1.00*(ct.[209]+ct.[210]+ct.[211])/(ct.[207]+0.01) as turnout,
1.00*ct.[221]/(ct.[215]+ct.[216]+0.01) as Zhir

From Hierachy 

JOIN
CalcTbl9 as ct  on ct.prid=hierachy.id

Гистограмма (20-24 секунды)

-- гисторамма из постоянной 
DECLARE @Timer datetime;
SET @Timer = GETDATE();

with Hierachy(ID, ParentID, TitleRus, Level)
as
(
select id, ParentID, TitleRus, 0 as Level
    from Protocols c
    where c.ProtocolSetId=9 and c.ParentId is null
    --where c.Id in (460424, 465325) -- insert parameter here
     -- insert parameter here
    union all
    select c.Id, c.ParentID, c.TitleRus, ch.Level + 1
    from Protocols c
    inner join Hierachy ch
    on c.ParentId = ch.ID
)

SELECT sum(ct.[207]) as list,
min(1+floor((1.00*(ct.[209]+ct.[210]+ct.[211])/ct.[207]-0)/0.05)) as bin

From CalcTbl9 as ct 

JOIN
Hierachy  on ct.prid=hierachy.id

Group by 1+floor((1.00*(ct.[209]+ct.[210]+ct.[211])/ct.[207]-0)/0.05)
order by bin

SELECT DATEDIFF(ms, @Timer, GETDATE())/1000.00 as Seconds_to_run
GO 

-- из постоянной таблицы считает 24 секунды

Всё это исследование заняло 3 часа.

artembaikuzin commented 6 years ago

Искал решение проблемы долгих запросов, если в выдаче имеется поле LineNumbers.ProtocolId: https://github.com/GolosMovement/StatElectGenerator/issues/86#issuecomment-440567953

Проблема была в том, что СУБД сканировала всю таблицу LineNumbers (использовался clustered index scan, т.е. проход по таблице, как она располагается на носителе - по PK) не используя созданный специально для этого nonclustered индекс на ProtocolId. Поэтому запросы хоть как-то связанные с ProtocolId были медленные. Чтобы такого не было при joined запросах, нужно дополнительно создавать nonclustered index на поле join + include на ProtocolId:

CREATE NONCLUSTERED INDEX [IX_LineNumbers_LineDescriptionId_ProtocolIdValue]
ON [dbo].[LineNumbers]([LineDescriptionId] ASC)
INCLUDE ([ProtocolId],[Value])

WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF,
    SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF,
    ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON)

Value добавил для оптимизации запросов вычисления динамических пресетов.

В итоге, провел тесты на dev машине и на staging. Входные данные такие:

staging:
Protocols: 1 291 308
LineNumbers: 23 127 818

dev:
Protocols: 2 094 299
LineNumbers: 42 389 640

Тестировал на Президент РФ 2018, QA#3. Вычисляемая формула взята с "потолка", вычисляет что-то с большим количеством LineDescription. Можно поправить на реальную, но сути это не изменит. Выборка по всем протоколам выборов.

dev

MAX-CASE

SELECT
    valuesQuery.*,
    (valuesQuery.[529] +
    valuesQuery.[530] -
    valuesQuery.[531] +
    valuesQuery.[532] +
    valuesQuery.[533] -
    valuesQuery.[534] +
    valuesQuery.[535] -
    valuesQuery.[536]) / (NULLIF(valuesQuery.[526], 0.0) * 1.33) AS Result
FROM (
    SELECT
        baseQuery.ProtocolId,
        MAX(CASE WHEN baseQuery.LineDescriptionId = 526 THEN baseQuery.Value END) AS [526],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 529 THEN baseQuery.Value END) AS [529],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 530 THEN baseQuery.Value END) AS [530],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 531 THEN baseQuery.Value END) AS [531],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 532 THEN baseQuery.Value END) AS [532],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 533 THEN baseQuery.Value END) AS [533],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 534 THEN baseQuery.Value END) AS [534],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 535 THEN baseQuery.Value END) AS [535],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 536 THEN baseQuery.Value END) AS [536]
    FROM (
        SELECT
            LineNumbers.LineDescriptionId,
            LineNumbers.ProtocolId,
            LineNumbers.Value
        FROM LineNumbers
        WHERE LineNumbers.LineDescriptionId in (526, 529, 530, 531, 532, 533, 534, 535, 536)
    ) AS baseQuery
    GROUP BY baseQuery.ProtocolId
) AS valuesQuery

ORDER BY valuesQuery.ProtocolId

-- Elapsed time: 00:00:01.3906250

dev-max-case

PIVOT

SELECT
    baseQuery.*,
    (baseQuery.[529] +
    baseQuery.[530] -
    baseQuery.[531] +
    baseQuery.[532] +
    baseQuery.[533] -
    baseQuery.[534] +
    baseQuery.[535] -
    baseQuery.[536]) / (NULLIF(baseQuery.[526], 0.0) * 1.33) AS Result
FROM (
    SELECT ProtocolId,
        [526], [529], [530], [531], [532], [533], [534], [535], [536]
    FROM
    (
        SELECT LineNumbers.Value,
               LineNumbers.LineDescriptionId,
               LineNumbers.ProtocolId
          FROM LineNumbers
         WHERE LineNumbers.LineDescriptionId in (526, 529, 530, 531, 532, 533, 534, 535, 536)
    ) AS sourceQuery
    PIVOT
    (
        MAX(Value) FOR LineDescriptionId IN ([526], [529], [530], [531], [532], [533], [534], [535], [536])
    ) AS pivotQuery
) AS baseQuery
ORDER BY baseQuery.ProtocolId

-- Elapsed time: 00:00:01.3437500

dev-pivot

staging

MAX-CASE

SELECT
    valuesQuery.*,
    (valuesQuery.[219] +
    valuesQuery.[220] -
    valuesQuery.[221] +
    valuesQuery.[222] +
    valuesQuery.[223] -
    valuesQuery.[224] +
    valuesQuery.[225] -
    valuesQuery.[226]) / (NULLIF(valuesQuery.[216], 0.0) * 1.33) AS Result
FROM (
    SELECT
        baseQuery.ProtocolId,
        MAX(CASE WHEN baseQuery.LineDescriptionId = 216 THEN baseQuery.Value END) AS [216],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 219 THEN baseQuery.Value END) AS [219],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 220 THEN baseQuery.Value END) AS [220],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 221 THEN baseQuery.Value END) AS [221],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 222 THEN baseQuery.Value END) AS [222],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 223 THEN baseQuery.Value END) AS [223],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 224 THEN baseQuery.Value END) AS [224],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 225 THEN baseQuery.Value END) AS [225],
        MAX(CASE WHEN baseQuery.LineDescriptionId = 226 THEN baseQuery.Value END) AS [226]
    FROM (
        SELECT
            LineNumbers.LineDescriptionId,
            LineNumbers.ProtocolId,
            LineNumbers.Value
        FROM LineNumbers
        WHERE LineNumbers.LineDescriptionId in (216, 219, 220, 221, 222, 223, 224, 225, 226)
    ) AS baseQuery
    GROUP BY baseQuery.ProtocolId
) AS valuesQuery

ORDER BY valuesQuery.ProtocolId

-- Elapsed time: 00:00:18.3564453

staging-max-case

PIVOT

SELECT
    baseQuery.*,
    (baseQuery.[219] +
    baseQuery.[220] -
    baseQuery.[221] +
    baseQuery.[222] +
    baseQuery.[223] -
    baseQuery.[224] +
    baseQuery.[225] -
    baseQuery.[226]) / (NULLIF(baseQuery.[216], 0.0) * 1.33) AS Result
FROM (
    SELECT ProtocolId,
        [216], [219], [220], [221], [222], [223], [224], [225], [226]
    FROM
    (
        SELECT LineNumbers.Value,
               LineNumbers.LineDescriptionId,
               LineNumbers.ProtocolId
          FROM LineNumbers
         WHERE LineNumbers.LineDescriptionId in (216, 219, 220, 221, 222, 223, 224, 225, 226)
    ) AS sourceQuery
    PIVOT
    (
        MAX(Value) FOR LineDescriptionId IN ([216], [219], [220], [221], [222], [223], [224], [225], [226])
    ) AS pivotQuery
) AS baseQuery
ORDER BY ProtocolId

-- Elapsed time: 00:00:15.8750000

staging-pivot

Итог

Запросы по иерархии с 97699 записей с динамическими пресетами работают 1-2 секунды на dev машине (40 миллионов записей в LineNumbers). На staging (а значит и production) это длится 15-20 секунд (23 миллиона записей в LineNumbers). Нужно использовать метод PIVOT, т.к. на staging он быстрее, когда как на dev разницы нет.

Возможно, на реальном приложении запросы будут еще быстрее, т.к. время на непосредственную передачу данных между приложением и БД будет меньше.

В любом случае, azure db сейчас работает на порядок медленнее чем dev машины на которых идет разработка. Без оптимизационного индекса для ProtocolId запросы MAX-CASE/PIVOT занимают 10-11 секунд на dev машине, на staging их невозможно будет дождаться.

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

В итоге формулы станут, например, выглядеть так: ([555] + [999]) / NULLIF([123] + [321], 0) * 100.0.

BlackWrangler commented 6 years ago

Прекрасно. Теперь у нас есть ещё один путь к динамическим пресетам. Изящнее чем в моих экспериментах. Скаттер-плот по скорости не хуже.

Скорость Пара комментариев по скорости. Гистограмма у меня строилась на 10 секунд дольше. Интересно, как это будет в методах Артёма.

Какое-то время занимает построение иерархии (когда нужно выбрать не все данные, а нескольких регионов). У меня это входило в эксперименты.

Без выбора иерархии, т.е. по всем данным ProtocolSet, если создана расчётная таблица, как в моих экспериментах запрос из метода Артёма длится 5 секунд на Azure

image

Сравнение идей по динамическим пресетам Предложение Артёма + Изящно, не создается много постоянных таблиц - Какое-то место займут дополнительные индексы (м.б. они и так нужны) - Довольно сложные запросы

Расчётные обычные таблицы - Неизящно, создаются таблицы на каждый Preset. + Простая работа в дальнейшем (простые запросы, скорость)

Расчётные глобальные временные таблицы - Надо ещё понять, как долго она может жить + Плюсы обоих методов

Каким путём идти

Это главный вопрос. 2 ноября я протестировал хранимые пресеты. Функционал не сработал. https://github.com/GolosMovement/StatElectGenerator/issues/86 Поэтому я стал ковыряться сам и предлагать считать налету. Не потому, что я хотел заставить выполнить двойную работу, а потому что мне кажется сложный участок можно обойти. По крайней мере, запросы, на первый взгляд, корректно выполняют этот функционал. Я помню, сколько было работы с ошибками импорта, и опасался того же при хранимых пресетах (это очень похоже на импорт).

Поэтому я и начал предлагать считать пресеты налету и пробовал запросы. Чтобы было меньше работы, а не больше.

Если вы решите, что проще заставить работать хранимые - ок. Так изначально ставилась задача. Если вы решите, что проще опираться на запросы (или сделать то же своими методами) - ок. Я понимаю, что это дополнительная работа, и предлагал сократить функционал следующих модулей до минимума. Т.е. взять старые скаттер-плот и гистограмм-генератор и переключить их на пресеты. Как с GAB. Мы уже сможем показать практически полезный функционал, хотя и старый.

Поэтому я и спрашивал каким путём вам проще идти

  1. Исправить ошибки функционала на хранимых величинах
    1. Сделать на запросах или как-нибудь ещё.

Как вам лучше, так давайте и двигаться. Но двигаться надо. С 2 ноября прошло 20 дней, а пресеты никак пока не работают. 9 декабря выборы в Армении, где просят показать работу на практике. GAB у меня просят каждый день уже неделю. Скоро 3 месяца с российских выборов, когда мы обещали показать какую-то работу, а мы до сих пор не можем показать ничего (LAB держит глюк по Швеции).

Давайте двигаться, все пути открыты.

artembaikuzin commented 5 years ago

Нужно довести скорость работы prod/staging до уровня скорости работы dev машины. Это большой тормоз из-за которого мы не функционалом занимаемся, а делаем преждевременную оптимизацию. Я об этом уже говорил и приводил примеры из других СУБД (PostgreSQL, синтетический тест, а также реальный проект). Те проблемы, которые мы имеем сейчас на данных в 20 млн на prod, мы на dev получим на данных 200 млн, а то и больше.

На dev, судя по execution plan, используется параллелизация: https://www.brentozar.com/pastetheplan/?id=SkbVX4tCQ

dev-ep

Тот же самый запрос на staging выглядит так (распараллеливания нет): https://www.brentozar.com/pastetheplan/?id=Sy-uQ4tRQ

staging-ep

Единственное, что приходит на ум, судя по плану.

Также, MSSQL/Azure имеет некую особенность в работе (index + include, IX_LineNumbers_LineDescriptionId_ProtocolIdValue) по сравнению с другими СУБД, об этом написал выше в тесте. За темами обсуждения можно следить тут:

https://social.msdn.microsoft.com/Forums/sqlserver/en-US/9e465f9a-544a-420f-b4c9-d93f9b54c815/azure-dbmssql-2017-query-performance-regression?forum=transactsql

https://stackoverflow.com/questions/53407962/azure-db-mssql-2017-query-performance-regression/53452034

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

Мы это усно обсудили и без динамических пресетов дальнейшие модули работать не будут вообще (агрегация, count(1) и т.п.). Соответственно, нужно отказаться от рассчитанных заранее пресетов.

Т.е. взять старые скаттер-плот и гистограмм-генератор и переключить их на пресеты. Как с GAB. Мы уже сможем показать практически полезный функционал, хотя и старый.

Поэтому я и спрашивал каким путём вам проще идти

  1. Исправить ошибки функционала на хранимых величинах

Была ошибка связанная с обработкой NULL значений, о которой я предупреждал тут 09.11 (зачем делать двойную работу, если мы на тот момент решали вопрос о динамических пресетах?): https://github.com/GolosMovement/StatElectGenerator/issues/27#issuecomment-437416286 Сделано это было из-за вот этой задачи: https://github.com/GolosMovement/StatElectGenerator/issues/87

Также была ошибка, которая связана с повторяющимися идентификаторами LineDescription в формулах, которые не были предусмотрены мной заранее: https://github.com/GolosMovement/StatElectGenerator/issues/95

Обе ошибки исправлены.

Сам функционал (модуль GBDV) доступен с 01.11: https://github.com/GolosMovement/StatElectGenerator/issues/80#ref-commit-685aec0 Расчет пресетов сделали 30.10: https://github.com/GolosMovement/StatElectGenerator/issues/50#event-1934284932

  1. Сделать на запросах или как-нибудь ещё.

Как вам лучше, так давайте и двигаться. Но двигаться надо. С 2 ноября прошло 20 дней, а пресеты никак пока не работают. 9 декабря выборы в Армении, где просят показать работу на практике. GAB у меня просят каждый день уже неделю. Скоро 3 месяца с российских выборов, когда мы обещали показать какую-то работу, а мы до сих пор не можем показать ничего (LAB держит глюк по Швеции).

Давайте двигаться, все пути открыты.

Да, давайте 🚀

BlackWrangler commented 5 years ago

С нуля сделал работающий прототип по самому пока сложному: histogram generator.

https://egsandbox.azurewebsites.net/histogramgenerator.aspx

Работает на временных глобальных таблицах.

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

При анализе также проверяется на её наличие, и подгружается в случае отсутствия (где-то 20-30 сек.)

Формулы введены по Польше, Франции, России.

Работает шустро.

Время засекает с момента процедуры подготовки и отправки запроса до получения данных от sql сервера.

2 тысячи бинов - 3 сек (вся Россия, Президентские РФ 2018) На 10 тысячах бинов - 14 сек Франция, 13 сек или 36 сек (если временая отвалилась) по всей России, 20 тысяч - 35 сек (вся Россия)

image

На 100 тысячах бинов были 84, 88 сек (Россия, 11 регионов), по всей России - вылетает после простоя (таймаут?).

На миллион бинов строит 13 сек (вся Франция).

image

На миллион бинов - вылетает по всей России.

Нужно учитывать простоту решения для будущих наворотов (посторить 2 гистограммы одновременно. Гистограммы на 10 тысяч бинов. Это обычная практика в анализе).

Иногда вылетает (таймаут?). Нужно тут же перезагрузить. Не было времени настроить.

Само кодирование работы с данными заняло не более 2 часов. От формирования запроса до вывода.

Часов 8 - чтение учебников по программированию и интерфейс (всё забыл :( )

Узкие места: Асинхронный запрос. Что будет, если пользователь начнёт анализ, пока таблица не сформировалась? (она мгновенно показывается как существующая, если запущено её формирование). Стабильность временных таблиц. Но остается возможность реальных хранимых расчётных таблиц.

Нужно ещё посмотреть производительность запросов Артёма, но понять их мне не хватает знаний.