SQL для определения минимальных последовательных дней доступа?
следующая таблица истории пользователей содержит одна запись за каждый день, когда данный пользователь получил доступ к веб-сайту (в 24-часовой период UTC). Он имеет много тысяч записей, но только одну запись в день на одного пользователя. Если пользователь не заходил на сайт в течение этого дня, запись не будет создана.
Id UserId CreationDate
------ ------ ------------
750997 12 2009-07-07 18:42:20.723
750998 15 2009-07-07 18:42:20.927
751000 19 2009-07-07 18:42:22.283
то, что я ищу, это SQL-запрос в этой таблице С хорошей производительностью, что говорит мне, какие идентификаторы пользователей получили доступ к веб-сайту для (n) дней, не пропуская ни дня.
другими словами, сколько пользователей имеют (n) записей в этой таблице с последовательными датами (день до или день после)? Если какой-либо день отсутствует в последовательности, последовательность нарушается и должна перезапускаться снова в 1; мы ищем пользователей, которые достигли непрерывного количества дней здесь без пробелов.
любое сходство между этим запросом и определенный значок переполнения стека это чисто совпадение, конечно.. :)
19 ответов:
ответ очевиден:
SELECT DISTINCT UserId FROM UserHistory uh1 WHERE ( SELECT COUNT(*) FROM UserHistory uh2 WHERE uh2.CreationDate BETWEEN uh1.CreationDate AND DATEADD(d, @days, uh1.CreationDate) ) = @days OR UserId = 52551EDIT:
ладно вот мой серьезный ответ:
DECLARE @days int DECLARE @seconds bigint SET @days = 30 SET @seconds = (@days * 24 * 60 * 60) - 1 SELECT DISTINCT UserId FROM ( SELECT uh1.UserId, Count(uh1.Id) as Conseq FROM UserHistory uh1 INNER JOIN UserHistory uh2 ON uh2.CreationDate BETWEEN uh1.CreationDate AND DATEADD(s, @seconds, DATEADD(dd, DATEDIFF(dd, 0, uh1.CreationDate), 0)) AND uh1.UserId = uh2.UserId GROUP BY uh1.Id, uh1.UserId ) as Tbl WHERE Conseq >= @daysEDIT:
[Джефф Этвуд] это отличное быстрое решение и заслуживает того, чтобы его приняли, но решение Роба Фарли также отлично и, возможно, даже быстрее (!). Пожалуйста, проверьте его тоже!
Как насчет (И, пожалуйста, убедитесь, что предыдущее утверждение закончилось точкой с запятой):
WITH numberedrows AS (SELECT ROW_NUMBER() OVER (PARTITION BY UserID ORDER BY CreationDate) - DATEDIFF(day,'19000101',CreationDate) AS TheOffset, CreationDate, UserID FROM tablename) SELECT MIN(CreationDate), MAX(CreationDate), COUNT(*) AS NumConsecutiveDays, UserID FROM numberedrows GROUP BY UserID, TheOffsetидея заключается в том, что если у нас есть список дней (как число) и row_number, то пропущенные дни делают смещение между этими двумя списками немного больше. Поэтому мы ищем диапазон, который имеет последовательное смещение.
вы можете использовать " ORDER BY NumConsecutiveDays DESC "в конце этого или сказать" имея count(*) > 14 " для порога...
Я не проверил это, хотя-просто списав это с моей головы. Надеюсь, работает в SQL2005 и дальше.
...и будет очень помогал индекс имя_таблицы(имя пользователя, дата создания)
Edited: оказывается, Offset-это зарезервированное слово, поэтому я использовал вместо него Offset.
Edited: предложение использовать COUNT (*) очень справедливо - я должен был сделать это в первую очередь, но на самом деле не думал. Ранее он был с помощью функции datediff(день, минута(дата создания), Макс (CreationDate)) вместо этого.
Роб
если вы можете изменить схему таблицы, я бы предложил добавить столбец
LongestStreakк таблице, которую вы установили в число последовательных дней, заканчивающихся наCreationDate. Легко обновить таблицу во время входа в систему (аналогично тому, что вы уже делаете, если нет строк текущего дня, вы проверите, существует ли какая-либо строка за предыдущий день. Если true, вы будете увеличиватьLongestStreakв новой строке, в противном случае, вы установите его в 1.)запрос будет очевиден после добавления этого колонка:
if exists(select * from table where LongestStreak >= 30 and UserId = @UserId) -- award the Woot badge.
некоторые красиво выразительные SQL по строкам:
select userId, dbo.MaxConsecutiveDates(CreationDate) as blah from dbo.Logins group by userIdЕсли у вас есть определяемая пользователем агрегатная функция что-то вроде (остерегайтесь этого багги):
using System; using System.Data.SqlTypes; using Microsoft.SqlServer.Server; using System.Runtime.InteropServices; namespace SqlServerProject1 { [StructLayout(LayoutKind.Sequential)] [Serializable] internal struct MaxConsecutiveState { public int CurrentSequentialDays; public int MaxSequentialDays; public SqlDateTime LastDate; } [Serializable] [SqlUserDefinedAggregate( Format.Native, IsInvariantToNulls = true, //optimizer property IsInvariantToDuplicates = false, //optimizer property IsInvariantToOrder = false) //optimizer property ] [StructLayout(LayoutKind.Sequential)] public class MaxConsecutiveDates { /// <summary> /// The variable that holds the intermediate result of the concatenation /// </summary> private MaxConsecutiveState _intermediateResult; /// <summary> /// Initialize the internal data structures /// </summary> public void Init() { _intermediateResult = new MaxConsecutiveState { LastDate = SqlDateTime.MinValue, CurrentSequentialDays = 0, MaxSequentialDays = 0 }; } /// <summary> /// Accumulate the next value, not if the value is null /// </summary> /// <param name="value"></param> public void Accumulate(SqlDateTime value) { if (value.IsNull) { return; } int sequentialDays = _intermediateResult.CurrentSequentialDays; int maxSequentialDays = _intermediateResult.MaxSequentialDays; DateTime currentDate = value.Value.Date; if (currentDate.AddDays(-1).Equals(new DateTime(_intermediateResult.LastDate.TimeTicks))) sequentialDays++; else { maxSequentialDays = Math.Max(sequentialDays, maxSequentialDays); sequentialDays = 1; } _intermediateResult = new MaxConsecutiveState { CurrentSequentialDays = sequentialDays, LastDate = currentDate, MaxSequentialDays = maxSequentialDays }; } /// <summary> /// Merge the partially computed aggregate with this aggregate. /// </summary> /// <param name="other"></param> public void Merge(MaxConsecutiveDates other) { // add stuff for two separate calculations } /// <summary> /// Called at the end of aggregation, to return the results of the aggregation. /// </summary> /// <returns></returns> public SqlInt32 Terminate() { int max = Math.Max((int) ((sbyte) _intermediateResult.CurrentSequentialDays), (sbyte) _intermediateResult.MaxSequentialDays); return new SqlInt32(max); } } }
Похоже, вы могли бы воспользоваться тем фактом, что для непрерывности в течение n дней потребуется n строк.
что-то вроде:
SELECT users.UserId, count(1) as cnt FROM users WHERE users.CreationDate > now() - INTERVAL 30 DAY GROUP BY UserId HAVING cnt = 30
выполнение этого с помощью одного SQL-запроса кажется мне слишком сложным. Давайте разобьем ответ на две части.
- что вы должны были сделать до сих пор и должны начать делать так:
Запустите ежедневное задание cron, которое проверяет каждого пользователя, который вошел в систему сегодня, а затем увеличивает счетчик, если он есть, или устанавливает его в 0, если он этого не сделал.- что вы должны сделать сейчас:
- Экспорт этой таблицы на сервер, который не запускает ваш сайт и не будет потребуется некоторое время. ;)
- Сортировка по пользователю, а затем дата.
- проходите через него последовательно, держите счетчик...
Если это так важно для вас, источник этого события и водить таблицы, чтобы дать вам эту информацию. Нет необходимости убивать машину со всеми этими сумасшедшими запросами.
вы можете использовать рекурсивный CTE (SQL Server 2005+):
WITH recur_date AS ( SELECT t.userid, t.creationDate, DATEADD(day, 1, t.created) 'nextDay', 1 'level' FROM TABLE t UNION ALL SELECT t.userid, t.creationDate, DATEADD(day, 1, t.created) 'nextDay', rd.level + 1 'level' FROM TABLE t JOIN recur_date rd on t.creationDate = rd.nextDay AND t.userid = rd.userid) SELECT t.* FROM recur_date t WHERE t.level = @numDays ORDER BY t.userid
У Джо Селко есть полная Глава об этом в SQL для Smarties (вызов его запусков и последовательностей). У меня нет этой книги дома, так что когда я приду на работу... Я на самом деле отвечу на этот вопрос. (предполагая, что таблица истории называется dbo.История пользователя и количество дней - @Days)
еще одна зацепка от блог команды SQL на runs
другая идея, которую я имел, но у меня нет удобного SQL-сервера для работы здесь, - это использовать CTE с секционированным номером ROW_NUMBER, например это:
WITH Runs AS (SELECT UserID , CreationDate , ROW_NUMBER() OVER(PARTITION BY UserId ORDER BY CreationDate) - ROW_NUMBER() OVER(PARTITION BY UserId, NoBreak ORDER BY CreationDate) AS RunNumber FROM (SELECT UH.UserID , UH.CreationDate , ISNULL((SELECT TOP 1 1 FROM dbo.UserHistory AS Prior WHERE Prior.UserId = UH.UserId AND Prior.CreationDate BETWEEN DATEADD(dd, DATEDIFF(dd, 0, UH.CreationDate), -1) AND DATEADD(dd, DATEDIFF(dd, 0, UH.CreationDate), 0)), 0) AS NoBreak FROM dbo.UserHistory AS UH) AS Consecutive ) SELECT UserID, MIN(CreationDate) AS RunStart, MAX(CreationDate) AS RunEnd FROM Runs GROUP BY UserID, RunNumber HAVING DATEDIFF(dd, MIN(CreationDate), MAX(CreationDate)) >= @Daysвыше вероятность БОЛЬШЕ чем это должно быть, но оставлено как щекотка мозга, когда у вас есть какое-то другое определение "пробега", чем просто даты.
пару параметры SQL Server 2012 (при условии, что N = 100 ниже).
;WITH T(UserID, NRowsPrevious) AS (SELECT UserID, DATEDIFF(DAY, LAG(CreationDate, 100) OVER (PARTITION BY UserID ORDER BY CreationDate), CreationDate) FROM UserHistory) SELECT DISTINCT UserID FROM T WHERE NRowsPrevious = 100хотя с моими образцами данных следующее работает более эффективно
;WITH U AS (SELECT DISTINCT UserId FROM UserHistory) /*Ideally replace with Users table*/ SELECT UserId FROM U CROSS APPLY (SELECT TOP 1 * FROM (SELECT DATEDIFF(DAY, LAG(CreationDate, 100) OVER (ORDER BY CreationDate), CreationDate) FROM UserHistory UH WHERE U.UserId = UH.UserID) T(NRowsPrevious) WHERE NRowsPrevious = 100) Oоба полагаются на ограничение, указанное в вопросе, что существует не более одной записи в день на пользователя.
что-то вроде этого?
select distinct userid from table t1, table t2 where t1.UserId = t2.UserId AND trunc(t1.CreationDate) = trunc(t2.CreationDate) + n AND ( select count(*) from table t3 where t1.UserId = t3.UserId and CreationDate between trunc(t1.CreationDate) and trunc(t1.CreationDate)+n ) = n
я использовал простое математическое свойство, чтобы определить, кто последовательно обращался к сайту. Это свойство заключается в том, что вы должны иметь дневную разницу между первым и последним доступом, равную количеству записей в журнале таблицы доступа.
вот SQL-скрипт, который я тестировал в Oracle DB (он должен работать и в других DBs):
-- show basic understand of the math properties select ceil(max (creation_date) - min (creation_date)) max_min_days_diff, count ( * ) real_day_count from user_access_log group by user_id; -- select all users that have consecutively accessed the site select user_id from user_access_log group by user_id having ceil(max (creation_date) - min (creation_date)) / count ( * ) = 1; -- get the count of all users that have consecutively accessed the site select count(user_id) user_count from user_access_log group by user_id having ceil(max (creation_date) - min (creation_date)) / count ( * ) = 1;таблица ДКП скрипт:
-- create table create table user_access_log (id number, user_id number, creation_date date); -- insert seed data insert into user_access_log (id, user_id, creation_date) values (1, 12, sysdate); insert into user_access_log (id, user_id, creation_date) values (2, 12, sysdate + 1); insert into user_access_log (id, user_id, creation_date) values (3, 12, sysdate + 2); insert into user_access_log (id, user_id, creation_date) values (4, 16, sysdate); insert into user_access_log (id, user_id, creation_date) values (5, 16, sysdate + 1); insert into user_access_log (id, user_id, creation_date) values (6, 16, sysdate + 5);
declare @startdate as datetime, @days as int set @startdate = cast('11 Jan 2009' as datetime) -- The startdate set @days = 5 -- The number of consecutive days SELECT userid ,count(1) as [Number of Consecutive Days] FROM UserHistory WHERE creationdate >= @startdate AND creationdate < dateadd(dd, @days, cast(convert(char(11), @startdate, 113) as datetime)) GROUP BY userid HAVING count(1) >= @daysзаявление
cast(convert(char(11), @startdate, 113) as datetime)удаляет часть дня мы начинаем в полночь.Я бы предположил также, что
creationdateиuseridстолбцы проиндексированы.Я только что понял, что это не скажет вам все пользователи и их общее количество последовательных дней. Но расскажет вам, какие пользователи будут посещать определенное количество дней с даты вашего выбора.
пересмотренное решение:
declare @days as int set @days = 30 select t1.userid from UserHistory t1 where (select count(1) from UserHistory t3 where t3.userid = t1.userid and t3.creationdate >= DATEADD(dd, DATEDIFF(dd, 0, t1.creationdate), 0) and t3.creationdate < DATEADD(dd, DATEDIFF(dd, 0, t1.creationdate) + @days, 0) group by t3.userid ) >= @days group by t1.useridЯ проверил это, и это будет запрос для всех пользователей и всех дат. Он основан на 1-й Спенсер (шутка?) решение, но мой работает.
обновление: улучшена обработка даты во втором решении.
Это должно делать то, что вы хотите, но у меня недостаточно данных для проверки эффективности. Свернутый материал CONVERT/FLOOR заключается в том, чтобы удалить часть времени из поля datetime. Если вы используете SQL Server 2008, то вы можете использовать CAST (x.CreationDate в качестве даты).
DECLARE @Range as INT SET @Range = 10 SELECT DISTINCT UserId, CONVERT(DATETIME, FLOOR(CONVERT(FLOAT, a.CreationDate))) FROM tblUserLogin a WHERE EXISTS (SELECT 1 FROM tblUserLogin b WHERE a.userId = b.userId AND (SELECT COUNT(DISTINCT(CONVERT(DATETIME, FLOOR(CONVERT(FLOAT, CreationDate))))) FROM tblUserLogin c WHERE c.userid = b.userid AND CONVERT(DATETIME, FLOOR(CONVERT(FLOAT, c.CreationDate))) BETWEEN CONVERT(DATETIME, FLOOR(CONVERT(FLOAT, a.CreationDate))) and CONVERT(DATETIME, FLOOR(CONVERT(FLOAT, a.CreationDate)))+@Range-1) = @Range)скрипт создания
CREATE TABLE [dbo].[tblUserLogin]( [Id] [int] IDENTITY(1,1) NOT NULL, [UserId] [int] NULL, [CreationDate] [datetime] NULL ) ON [PRIMARY]
Спенсер почти сделал это, но это должен быть рабочий код:
SELECT DISTINCT UserId FROM History h1 WHERE ( SELECT COUNT(*) FROM History WHERE UserId = h1.UserId AND CreationDate BETWEEN h1.CreationDate AND DATEADD(d, @n-1, h1.CreationDate) ) >= @n
С моей головы, MySQLish:
SELECT start.UserId FROM UserHistory AS start LEFT OUTER JOIN UserHistory AS pre_start ON pre_start.UserId=start.UserId AND DATE(pre_start.CreationDate)=DATE_SUB(DATE(start.CreationDate), INTERVAL 1 DAY) LEFT OUTER JOIN UserHistory AS subsequent ON subsequent.UserId=start.UserId AND DATE(subsequent.CreationDate)<=DATE_ADD(DATE(start.CreationDate), INTERVAL 30 DAY) WHERE pre_start.Id IS NULL GROUP BY start.Id HAVING COUNT(subsequent.Id)=30непроверенный, и почти наверняка нуждается в некотором преобразовании для MSSQL, но я думаю, что это дает некоторые идеи.
Как насчет использования таблиц подсчета? Он следует более алгоритмическому подходу, и план выполнения-это ветер. Заполните таблицу tallyTable числами от 1 до 'MaxDaysBehind', которые вы хотите сканировать таблицу (т. е. 90 будет выглядеть на 3 месяца и т. д.).
declare @ContinousDays int set @ContinousDays = 30 -- select those that have 30 consecutive days create table #tallyTable (Tally int) insert into #tallyTable values (1) ... insert into #tallyTable values (90) -- insert numbers for as many days behind as you want to scan select [UserId],count(*),t.Tally from HistoryTable join #tallyTable as t on t.Tally>0 where [CreationDate]> getdate()[email protected] and [CreationDate]<getdate()-t.Tally group by [UserId],t.Tally having count(*)>=@ContinousDays delete #tallyTable
немного подправить запрос Билла. Возможно, вам придется усечь дату перед группировкой, чтобы считать только один вход в день...
SELECT UserId from History WHERE CreationDate > ( now() - n ) GROUP BY UserId, DATEADD(dd, DATEDIFF(dd, 0, CreationDate), 0) AS TruncatedCreationDate HAVING COUNT(TruncatedCreationDate) >= nредактировать в использовании функция dateadd(DD, то функция datediff(ДД, 0, дата создания), 0) вместо Convert( тип char(10) , дата создания, 101 ).
@IDisposable Я хотел использовать datepart раньше, но мне было слишком лениво искать синтаксис, поэтому я решил использовать convert вместо этого. Я не знаю, что это оказало значительное влияние спасибо! теперь я знаю.
предполагая схему, которая выглядит так:
create table dba.visits ( id integer not null, user_id integer not null, creation_date date not null );это позволит извлечь непрерывные диапазоны из последовательности дат с пробелами.
select l.creation_date as start_d, -- Get first date in contiguous range ( select min(a.creation_date ) as creation_date from "DBA"."visits" a left outer join "DBA"."visits" b on a.creation_date = dateadd(day, -1, b.creation_date ) and a.user_id = b.user_id where b.creation_date is null and a.creation_date >= l.creation_date and a.user_id = l.user_id ) as end_d -- Get last date in contiguous range from "DBA"."visits" l left outer join "DBA"."visits" r on r.creation_date = dateadd(day, -1, l.creation_date ) and r.user_id = l.user_id where r.creation_date is null
Comments