SQL确定最少连续访问天数?


125

以下用户历史记录表包含给定用户每天(在UTC的24小时内)访问网站的每一条记录。它有成千上万条记录,但每个用户每天只有一条记录。如果用户当天没有访问该网站,则不会生成任何记录。

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查询,该查询告诉我哪些用户ID连续(n)天访问了该网站,却没有一天丢失。

换句话说,该表中有(n)个记录具有连续(前一天或后一天)日期的记录?如果序列中缺少任何一天,则该序列将中断并且应从1重新开始;否则,序列将重新开始。我们正在寻找在此连续几天没有间断的用户。

当然,此查询与特定的Stack Overflow标志之间的任何相似之处纯属巧合。.::)


经过28(<30)天的会员资格,我获得了发烧友徽章。神秘主义。
2009年

3
您的日期是否存储为UTC?如果是这样,如果CA居民一天早上8点访问,然后第二天晚上8点访问该站点,会发生什么情况?尽管他/她在太平洋时区连续访问了几天,但由于DB将时间存储为UTC,因此不会将其记录在DB中。
盖伊,

杰夫/贾罗德-您可以检出meta.stackexchange.com/questions/865/…吗?
罗布·法利

Answers:


69

答案显然是:

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 = 52551

编辑:

好吧,这是我的认真回答:

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 >= @days

编辑:

[Jeff Atwood]这是一个很棒的快速解决方案,值得接受,但是Rob Farley的解决方案也非常出色,而且可以说甚至更快(!)。请也检查一下!


@Artem:那是我最初的想法,但是当我想到它时,如果您在(UserId,CreationDate)上有一个索引,则记录将连续显示在索引中,并且应该会表现良好。
Mehrdad Afshari

为此,我在500k行的约15秒内获得了结果。
Jim T

4
使用DATEADD(dd,DATEDIFF(dd,0,CreationDate),0)在所有这些测试中将CreateionDate缩短至几天(仅在右侧或杀死SARG),这是通过从零减去提供的日期来实现的(Microsoft SQL Server解释为1900-01-01 00:00:00并给出天数。然后将该值重新加到零日期,得到与截断时间相同的日期。
IDisposable

1
我只能告诉您的是,如果不更改IDisposable,则计算是不正确的。我亲自验证了数据。与1天差距有些用户WOULD获得徽章不正确。
杰夫·阿特伍德

3
此查询有可能会错过在23:59:59.5发生的访问-将其更改为:ON uh2.CreationDate >= uh1.CreationDate AND uh2.CreationDate < DATEADD(dd, DATEDIFF(dd, 0, uh1.CreationDate) + @days, 0),表示“在31日后还没有”的情况。也意味着您可以跳过@seconds计算。
罗伯·法利

147

怎么样(请确保前面的语句以分号结尾):

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”,或说“ HAVING count(*)> 14”作为阈值...

我还没有测试过-只是把它写在我的头上。希望可以在SQL2005及更高版本上使用。

...并且通过表名的索引(UserID,CreationDate)将大有帮助

编辑:原来偏移是保留字,所以我改用TheOffset。

编辑:建议使用COUNT(*)是非常有效的-首先我应该这样做,但实际上并没有考虑。以前它使用的是datediff(day,min(CreationDate),max(CreationDate))。


1
哦你还应该加; 之前带有->;带有
Mladen Prajdic,2009年

2
Mladen-不,您应该使用分号结束上一个语句。;)杰夫-好的,请改用[偏移]。我想偏移量是保留字。就像我说的,我没有测试过。
罗布·法利

1
只是重复我自己,因为这是一个经常看到的问题。使用DATEADD(dd,DATEDIFF(dd,0,CreationDate),0)在所有这些测试中将CreateionDate缩短至几天(仅在右侧或杀死SARG),这是通过从零减去提供的日期来实现的-Microsoft SQL Server解释为1900-01-01 00:00:00并给出天数。然后将此值重新加到零日期,得到与截断时间相同的日期。
IDisposable

1
IDisposable-是的,我经常自己做。我只是不担心它在这里做。它不会比将其强制转换为int更快,但是它确实具有计算小时数,数月数之类的灵活性。
罗布·法利

1
我刚刚写了一篇关于用DENSE_RANK()解决此问题的博客文章。tinyurl.com/denserank
罗布·法利

18

如果您可以更改表格架构,建议您在表格中添加一列LongestStreak,并将其设置为以结尾的连续天数CreationDate。在登录时更新表很容易(类似于您已经在做的事情,如果当天没有行,您将检查前一天是否有任何行。如果为true,则LongestStreak在新行,否则,将其设置为1。)

添加此列后,查询将显而易见:

if exists(select * from table
          where LongestStreak >= 30 and UserId = @UserId)
   -- award the Woot badge.

1
+1我有一个类似的想法,但有位字段(IsConsecutive),这将是1,如果前一天的记录,否则为0
弗雷德里克·莫克

7
我们不会为此更改架构
Jeff Atwood,2009年

IsConsecutive可以是UserHistory表中定义的计算列。您还可以使其成为一个物化的(存储的)计算列,该列是在插入行IFF(如果且仅当)时创建的,您始终按时间顺序插入行。
IDisposable

(因为NOBODY会执行SELECT *,我们知道添加此计算列将不会影响查询计划,除非已引用该列...对吧?!?)
IDisposable

3
这绝对是一个有效的解决方案,但这不是我想要的。所以我给它一个“大拇指侧身” ...
杰夫·阿特伍德

6

一些表现力很好的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);
        }
    }
}

4

似乎您可以利用以下事实:要连续n天,则需要有n行。

所以像这样:

SELECT users.UserId, count(1) as cnt
FROM users
WHERE users.CreationDate > now() - INTERVAL 30 DAY
GROUP BY UserId
HAVING cnt = 30

是的,我们能的记录,可以肯定的数量..但只有消除了一些可能性,因为我们可以有跨越数年的访问,有很多日常的差距为120天
杰夫·阿特伍德

1
好的,但是一旦您获得了该页面的奖励,您只需每天运行一次。我认为对于这种情况,上面的方法可以解决问题。要赶上进度,您需要做的就是使用BETWEEN将WHERE子句变成一个滑动窗口。
比尔

1
每次任务运行都是无状态且独立的;除了问题表之外,它不知道先验运行情况
Jeff Atwood 2009年

3

对我而言,使用单个SQL查询执行此操作似乎过于复杂。让我将这个答案分为两个部分。

  1. 您应该做的到现在为止,现在应该开始做:
    运行一项日常cron作业,检查他今天登录的每个用户,然后增加一个计数器(如果有)或将其设置为0(如果没有)。
  2. 您现在应该执行的操作:
    -将该表导出到不会运行您的网站且一会儿不需要的服务器。;)
    -按用户排序,然后按日期排序。
    -依次浏览,保持计数器...

我们可以将代码编写为查询和循环,这是..dary,我说..很简单。我对目前的SQL唯一方式感到好奇。
杰夫·阿特伍德

2

如果这对您来说如此重要,请发送该事件并驱动一个表以提供此信息。无需使用所有这些疯狂的查询来杀死机器。


2

您可以使用递归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

2

Joe Celko在有关Smarties的SQL中对此有完整的章节(称为运行和序列)。我家里没有那本书,所以当我上班时……我会回答的。(假设历史记录表称为dbo.UserHistory,天数为@Days)

另一个线索来自SQL Team的运行博客

我有另一个想法,但是这里没有方便使用的SQL Server是将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

上面的内容可能比必须的要困难得多,但是当您对“运行”有除日期以外的其他定义时,上面的内容会让人脑子发痒。


2

几个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

两者都依赖于该问题中所述的约束条件,即每个用户每天最多有一个记录。


1

像这样吗

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

1

我使用一个简单的数学属性来确定谁连续访问了该网站。此属性是,您首次访问和最后一次访问之间的日差应等于访问表日志中的记录数。

这是我在Oracle DB中测试过的SQL脚本(它也应该在其他DB中工作):

-- 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);

1
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)删除了日期的时间部分,因此我们从午夜开始。

我也假设creationdateuserid列都已建立索引。

我只是意识到,这不会告诉您所有用户及其连续的总天数。但是会告诉您,从您选择的日期开始,哪些用户已经访问了指定的天数。

修改后的解决方案:

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

我已经检查了它,它将查询所有用户和所有日期。它基于Spencer的第一个(笑话?)解决方案,但是我的工作正常。

更新:改进了第二个解决方案中的日期处理。


关闭,但我们需要在任何(n)天期间都可正常使用的内容,而不是在固定的开始日期
Jeff Atwood,2009年

0

这应该可以满足您的要求,但是我没有足够的数据来测试效率。复杂的CONVERT / FLOOR内容是将时间部分从datetime字段中剥离。如果您使用的是SQL Server 2008,则可以使用CAST(x.CreationDate AS DATE)。

声明@Range为INT
SET @范围= 10

SELECT DISTINCT UserId,CONVERT(DATETIME,FLOOR(CONVERT(FLOAT,a.CreationDate)))
  从tblUserLogin一个
存在的地方
   (选择1 
      来自tblUserLogin b 
     其中a.userId = b.userId 
       AND(SELECT COUNT(DISTINCT(CONVERT(DATETIME,FLOOR(CONVERT(FLOAT,CreationDate)))))) 
              来自tblUserLogin c 
             在哪里c.userid = b.userid 
               AND CONVERT(DATETIME,FLOOR(CONVERT(FLOAT,c.CreationDate)))在CONVERT(DATETIME,FLOOR(CONVERT(FLOAT,a.CreationDate)))和CONVERT(DATETIME,FLOOR(CONVERT(FLOAT,a.CreationDate)))之间)+ @ Range-1)= @Range)

创建脚本

创建表[dbo]。[tblUserLogin](
    [Id] [int] IDENTITY(1,1)NOT NULL,
    [UserId] [int] NULL,
    [CreationDate] [datetime] NULL
)在[PRIMARY]上

蛮残酷的。406,624行为26秒。
杰夫·阿特伍德

您多久检查一次授予徽章?如果只是一天一次,那么在缓慢的时间内击中26秒似乎并不算糟糕。但是,随着表的增加,性能将降低。重新阅读问题后,时间可能不再重要,因为每天只有一条记录。
戴夫·巴克

0

Spencer几乎做到了,但这应该是工作代码:

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

0

在我的头上,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需要进行一些转换,但是我认为可以提供一些想法。


0

如何使用Tally表?它遵循一种更加算法化的方法,执行计划轻而易举。在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()-@ContinousDays-t.Tally and 
      [CreationDate]<getdate()-t.Tally 
group by [UserId],t.Tally 
having count(*)>=@ContinousDays

delete #tallyTable

0

稍微调整Bill的查询。您可能需要在分组之前截断日期,以便每天只计算一次登录...

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(dd,0,CreationDate),0)代替convert(char(10),CreationDate,101)。

@IDisposable我想早点使用datepart,但是我懒得查找语法,所以我发现id改用convert。我知道这会产生重大影响谢谢!现在我明白了。


最好将SQL DATETIME截断为仅日期,最好使用DATEADD(dd,DATEDIFF(dd,0,UH.CreationDate),0)
IDisposable

(以上方法通过将整天的差异取为0(例如1900-01-01 00:00:00.000),然后将整天的差异加回0(例如1900-01-01 00:00:00) 。这导致DATETIME的时间部分被舍弃)
IDisposable

0

假设模式如下:

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
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.