Rails:如何构建每天/每月/每年的统计数据或如何缺少与数据库无关的 SQL 函数(例如:STRFTIME、DATE_FORMAT、DATE_TRUNC)

Posted

技术标签:

【中文标题】Rails:如何构建每天/每月/每年的统计数据或如何缺少与数据库无关的 SQL 函数(例如:STRFTIME、DATE_FORMAT、DATE_TRUNC)【英文标题】:Rails : How to build statistics per day/month/year or How database agnostic SQL functions are missing (ex. : STRFTIME, DATE_FORMAT, DATE_TRUNC) 【发布时间】:2011-05-01 01:01:26 【问题描述】:

我一直在网上搜索,但我没有任何线索。

假设您必须在 Rails 应用程序的管理区域中构建仪表板,并且希望获得每天的订阅数。 假设您将 SQLite3 用于开发MySQL 用于生产(相当标准的设置)

基本上有两种选择:

1) 使用 Subscriber.all 从数据库中检索所有行,并使用 Enumerable.group_by 在 Rails 应用程序中按天聚合:

@subscribers = Subscriber.all
@subscriptions_per_day = @subscribers.group_by  |s| s.created_at.beginning_of_day 

我认为这是一个非常糟糕的主意。对于小型应用程序来说,从数据库中检索所有行是可以接受的,但它根本无法扩展。数据库聚合和日期函数来救援!

2) 使用聚合和日期函数在数据库中运行 SQL 查询

Subscriber.select('STRFTIME("%Y-%m-%d", created_at) AS day, COUNT(*) AS subscriptions').group('day')

将在此 SQL 查询中运行:

SELECT STRFTIME("%Y-%m-%d", created_at) AS day, COUNT(*) AS subscriptions
FROM subscribers
GROUP BY day

好多了。现在聚合在针对此类任务进行了优化的数据库中完成,并且每天只有一行从数据库返回到 Rails 应用程序。

...但是等等...现在该应用程序必须在我使用 mysql 的生产环境中上线! 将STRFTIME() 替换为DATE_FORMAT()。 如果明天我切换到 PostgreSQL 怎么办? 将DATE_FORMAT() 替换为DATE_TRUNC()

我喜欢使用 SQLite 进行开发。简单易行。 我也喜欢 Rails 与数据库无关的想法。 但是为什么 Rails 没有提供一种方法来翻译执行完全相同的操作,但在每个 RDBMS 中具有不同语法的 SQL 函数(这种差异真的很愚蠢,但是,现在抱怨已经太迟了它)?

我不敢相信我在 Web 上找到了这么少关于 Rails 应用程序的基本功能的答案:计算每天、每月或每年的订阅量。

告诉我我错过了什么:)

编辑

自从我发布这个问题以来已经有几年了。 经验表明,我应该为 dev 和 prod 使用相同的数据库。所以我现在认为与数据库无关的要求无关紧要。

Dev/prod parityFTW。

【问题讨论】:

这是一个比看起来更棘手的问题。想知道为什么,想想这个问题:“一天有多少小时?”答案是“如果忽略闰秒,平均为 24”。由于 DST 的变化,这只是一个平均答案,这是政客们喜欢修补的东西。一天的长度也因地区而异。数据库是否应该知道所有那些官僚巴洛克式的电影,还是它只是查看应用程序的一个方面? 目的是分析趋势。我理解你的观点,但我认为我不需要那种精确度来分析趋势。但只是好奇:你认为 Ruby 会处理这些特殊性而数据库没有吗? 看看类似的SO question-answer 编辑了问题以放弃与数据库无关的要求,以支持开发/产品奇偶校验。 【参考方案1】:

我最终编写了自己的 gem。查看并随时贡献: https://github.com/lakim/sql_funk

它允许您拨打以下电话:

Subscriber.count_by("created_at", :group_by => "day")

【讨论】:

【参考方案2】:

您谈到了一些非常困难的问题,不幸的是,Rails 完全忽略了这些问题。 ActiveRecord::Calculations 文档的编写就像您所需要的一样,但是数据库可以做更高级的事情。正如 Donal Fellows 在他的评论中提到的,这个问题比看起来要棘手得多。

在过去的两年中,我开发了一个大量使用聚合的 Rails 应用程序,并且我尝试了几种不同的方法来解决这个问题。不幸的是,我没有忽略夏令时之类的奢侈,因为统计数据是“唯一的趋势”。我生成的计算由我的客户测试以符合精确的规格。

为了稍微扩展一下这个问题,我想你会发现你当前的按日期分组的解决方案是不够的。使用 STRFTIME 似乎是一个自然的选择。主要问题是它不允许您按任意时间段进行分组。如果您想按年、月、日、小时和/或分钟进行聚合,STRFTIME 可以正常工作。如果没有,您会发现自己正在寻找另一种解决方案。另一个大问题是聚合时的聚合问题。比如说,你想按月分组,但你想从每个月的 15 号开始。您将如何使用 STRFTIME 进行操作?您必须按每天分组,然后按月分组,但随后有人会计算每个月第 15 天的起始偏移量。最后一根稻草是,按STRFTIME分组需要按字符串值分组,在聚合时执行聚合时您会发现这非常慢。

我找到的性能最好、设计最好的解决方案是基于整数时间段的解决方案。这是我的一个 mysql 查询的摘录:

SELECT
  field1, field2, field3,
  CEIL((UNIX_TIMESTAMP(CONVERT_TZ(date, '+0:00', @@session.time_zone)) + :begin_offset) / :time_interval) AS time_period
FROM
  some_table
GROUP BY 
  time_period

在这种情况下,:time_interval 是分组周期中的秒数(例如每天 86400),:begin_offset 是偏移周期开始的秒数。 CONVERT_TZ() 业务说明了 mysql 解释日期的方式。 mysql 总是假定日期字段在 mysql 本地时区。但是因为我以 UTC 存储时间,所以如果我希望 UNIX_TIMESTAMP() 函数给我一个正确的响应,我必须将它从 UTC 转换为会话时区。时间段最终是一个整数,描述了自 unix 时间开始以来的时间间隔数。此解决方案更加灵活,因为它允许您按任意时间段进行分组,并且不需要在聚合时进行聚合。

现在,进入我的真正重点。对于一个强大的解决方案,我建议您考虑完全不使用 Rails 来生成这些查询。最大的问题是聚合的性能特征和微妙之处在数据库之间是不同的。您可能会发现一种设计在您的开发环境中运行良好,但在生产环境中却不行,反之亦然。为了让 Rails 在查询构造中与这两个数据库完美配合,您将费尽周折。

相反,我建议您在所选数据库中生成特定于数据库的视图,并将它们带到正确的环境中。尝试像对任何其他 ActiveRecord 表(id​​ 和所有)一样对视图建模,当然还要使视图中的字段在数据库中相同。因为这些统计信息是只读查询,所以您可以使用模型来支持它们并假装它们是完整的表。如果有人尝试保存、创建、更新或销毁,只需引发异常即可。

您不仅会通过使用 Rails 的方式来简化模型管理,还会发现您可以用纯 SQL 做梦也想不到的方式为聚合功能编写单元测试。如果您决定切换数据库,您将不得不重写这些视图,但您的测试会告诉您哪里错了,让生活变得更轻松。

【讨论】:

这似乎是非常可靠的建议。扭曲轨道来完成数据库可以在单个查询中执行的操作似乎既耗时又容易出错——更不用说数据库可能会更快地生成答案,而且不会占用内存。【参考方案3】:

我刚刚发布了一个 gem,它允许您使用 MySQL 轻松完成此操作。 https://github.com/ankane/groupdate

您也应该真正尝试在开发中运行 MySQL。您的开发和生产环境应尽可能接近 - 减少开发工作并完全破坏生产的机会。

【讨论】:

【参考方案4】:

如果您追求的是 db 不可知论,我可以想到几个选项:

为存储格式化日期或时间戳的订阅者创建一个新字段(我们称之为 day_str)并使用 ActiveRecord.count:

daily_subscriber_counts = Subscriber.count(:group => "day_str")

权衡当然是记录大小稍大,但这几乎可以消除性能问题。

您也可以根据可视化数据的粒度,只需调用 .count 几次,并根据需要设置日期...

((Date.today - 7)..Date.today).each |d|
    daily_subscriber_counts[d] = Subscriber.count(:conditions => ["created_at >= ? AND created_at < ?", d.to_time, (d+1).to_time)
end

这也可以自定义以适应不同的粒度(每月、每年、每天、每小时)。如果您想按天对所有订阅者进行分组(也没有机会运行它),这不是最有效的解决方案,但我想您想按月、日、小时进行分组如果您分别查看的是一年、几个月或几天的数据。

如果你愿意使用 mysql 和 sqlite,你可以使用...

daily_subscriber_counts = Subscriber.count(:group => "date(created_at)")

...因为它们共享相似的 date() 函数。

【讨论】:

我喜欢你的第一个选项。帮助我跳出框框思考。但是考虑到它只会被管理员使用,权衡(更大的记录大小)甚至更大。 有谁知道为每个 RDBMS 翻译 SQL 函数的 Rails 插件?我仍然认为这将是最好的选择。 从技术上讲,rails 提供了该功能(因此使用查找器将条件哈希转换为 SQL 查询)。如果你确信你会坚持使用 SQLite 和 MySQL,它们的 date() 函数很相似……试试 daily_subscriber_counts = Subscriber.count(:group => "date(created_at)")【参考方案5】:

我会稍微改进/扩展 PBaumann 的答案,并在您的数据库中包含一个 Dates 表。您需要在查询中加入:

SELECT D.DateText AS Day, COUNT(*) AS Subscriptions
FROM subscribers AS S
  INNER JOIN Dates AS D ON S.created_at = D.Date
GROUP BY D.DateText

...但是您可以在不调用任何函数的情况下获得格式良好的值。在 Dates.Date 上进行 PK,可以合并 join,应该很快。

如果您有国际受众,您可以使用 DateTextUS、DateTextGB、DateTextGer 等,但显然这不是一个完美的解决方案。

另一个选项:使用 CONVERT() 将日期转换为数据库端的文本,这是 ANSI 并且可以跨数据库使用;我现在懒得确认了。

【讨论】:

【参考方案6】:

我是这样做的:

我有一个允许存储原始事件的类 Stat。 (代码是从我开始用 Ruby 编码的最初几周开始的,所以请原谅其中的一些内容 :-))

class Stat < ActiveRecord::Base
    belongs_to :statable, :polymorphic => true

    attr_accessible :statable_id, :statable_type, :statable_stattype_id, :source_url, :referral_url, :temp_user_guid

    # you can replace this with a cron job for better performance
    # the reason I have it here is because I care about real-time stats
    after_save :aggregate

    def aggregate
    aggregateinterval(1.hour)
    #aggregateinterval(10.minutes)
end

    # will aggregate an interval with the following properties:
    # take t = 1.hour as an example
    # it's 5:21 pm now, it will aggregate everything between 5 and 6
    # and put them in the interval with start time 5:00 pm and 6:00 pm for today's date
    # if you wish to create a cron job for this, you can specify the start time, and t
def aggregateinterval(t=1.hour)
    aggregated_stat = AggregatedStat.where('start_time = ? and end_time = ? and statable_id = ? and statable_type = ? and statable_stattype_id = ?', Time.now.utc.floor(t), Time.now.utc.floor(t) + t, self.statable_id, self.statable_type, self.statable_stattype_id)

    if (aggregated_stat.nil? || aggregated_stat.empty?)
        aggregated_stat = AggregatedStat.new
    else
        aggregated_stat = aggregated_stat.first
    end

            aggregated_stat.statable_id = self.statable_id
    aggregated_stat.statable_type = self.statable_type
    aggregated_stat.statable_stattype_id = self.statable_stattype_id
    aggregated_stat.start_time = Time.now.utc.floor(t)
    aggregated_stat.end_time = Time.now.utc.floor(t) + t
    # in minutes
    aggregated_stat.interval_size = t / 60

    if (!aggregated_stat.count)
        aggregated_stat.count = 0
    end
    aggregated_stat.count = aggregated_stat.count + 1


    aggregated_stat.save
end

end

这里是 AggregatedStat 类:

class AggregatedStat < ActiveRecord::Base
    belongs_to :statable, :polymorphic => true

    attr_accessible :statable_id, :statable_type, :statable_stattype_id, :start_time, :end_time

添加到数据库中的每个可统计项目都有一个 statable_type 和一个 statable_stattype_id 以及一些其他通用统计数据。 statable_type 和 statable_stattype_id 用于多态类,可以保存(字符串)“User”和 1 等值,这意味着您正在存储关于用户编号 1 的统计信息。

您可以添加更多列,并让代码中的映射器在需要时提取正确的列。创建多个表使管理变得更加困难。

在上面的代码中,StatableStattypes 只是一个包含您要记录的“事件”的表格...我使用表格是因为以前的经验告诉我,我不想寻找什么类型的统计信息数据库中的数字是指。

class StatableStattype < ActiveRecord::Base
    attr_accessible :name, :description

    has_many :stats
end

现在转到您想要获得一些统计信息的课程并执行以下操作:

class User < ActiveRecord::Base
  # first line isn't too useful except for testing
  has_many :stats, :as => :statable, :dependent => :destroy
  has_many :aggregated_stats, :as => :statable, :dependent => :destroy
end

然后,您可以使用以下代码查询某个用户(或以下示例中的位置)的聚合统计信息:

Location.first.aggregated_stats.where("start_time > ?", DateTime.now - 8.month)

【讨论】:

以上是关于Rails:如何构建每天/每月/每年的统计数据或如何缺少与数据库无关的 SQL 函数(例如:STRFTIME、DATE_FORMAT、DATE_TRUNC)的主要内容,如果未能解决你的问题,请参考以下文章

SQL语句统计每天每月每年的 数据

SQL语句统计每天每月每年的 数据

SQL语句统计每天每月每年的数据

每天、每周、每月和每年调用一个方法

sqlserver 获取到日期范围内每天,每周,每月,每年记录

cron和crontab命令详解 crontab 每分钟每小时每天每周每月每年定时执行 crontab每5分钟执行一次