ActiveRecord - 非规范化案例研究

Posted

技术标签:

【中文标题】ActiveRecord - 非规范化案例研究【英文标题】:ActiveRecord - Denormalization Case Study 【发布时间】:2012-08-29 14:07:01 【问题描述】:

处理以下 8 个不同 SQL 问题的最佳方法是什么。

我在下面放置了一个数据库模式,它在我的 Rails 模型中是如何表示的,以及我需要从数据库中取出数据的七个问题。有些问题我已经回答了,有些问题我不确定最佳解决方案。

问题 #7 是一个曲线球,因为它可能会改变所有其他问题的答案。

标准

    不应要求 n+1 个查询。多个查询是可以的,但如果返回的每一行都需要一个额外的查询,那么它是不可扩展的。 不应要求后处理来过滤 SQL 可以自行执行的结果。例如,第 5 项的答案不应该是从数据存储中提取所有学生,然后删除没有课程的学生。 检索对象的计数不应触发另一个 SQL 查询。 如果 SQL 允许我聚合数据,则不必通过非规范化添加数据库列 NOSQL 解决方案(例如 MongoDB 或 CouchDB)是否更适合回答以下所有问题?

数据库架构

学生 -------- ID 姓名 培训班 ----- ID 姓名 年级 招生 ---------- ID 学生卡 Course_ID

ActiveRecord 模型


class Course < ActiveRecord::Base
  has_many :enrollments
  has_many :students, :through=>:enrollments
end
class Enrollment < ActiveRecord::Base
  belongs_to :student
  belongs_to :course
end
class Student < ActiveRecord::Base
  has_many :enrollments
  has_many :courses, :through => :enrollments
end

问题

1) 检索 9 年级数学课程的所有学生

SQL


SELECT s.* FROM Students s
LEFT JOIN Enrollments e on e.student_id = s.id
LEFT JOIN Courses c on e.course_id = c.id
WHERE c.grade = 9 AND c.name = 'Math'

解决方案

这个很简单。 ActiveRecord 处理得很好


c = Course.where(:grade=>9).where(:name=>'Math').first
c.students

2) 检索 John 上的所有课程

SQL


SELECT c.* FROM Courses c
LEFT JOIN Enrollments e on c.id = e.course_id
LEFT JOIN Students s on e.student_id = s.id
WHERE s.name = 'John'

解决方案

再次,简单。


s = Student.where(:name=>'John').first
s.courses

3) 检索所有 9 年级课程以及参加该课程的学生人数(但不检索学生)

SQL


SELECT c.*, count(e.student_id) FROM Courses C
LEFT JOIN Enrollments e on c.id = e.course_id
WHERE c.grade = 9 GROUP BY c.id

解决方案

计数器缓存在这里可以很好地工作。

类 AddCounters 0 add_column :courses, :students_count, :integer, :default=>0 Student.reset_column_information Student.all.each 做 |s| Student.update_counters s.id, :courses_count => s.courses.length 结尾 Course.reset_column_information Course.all.each 做 |c| Course.update_counters c.id, :students_count => c.students.length 结尾 结尾 向下定义 remove_column:学生,:courses_count remove_column:课程,:students_count 结尾 结尾

活动记录

Course.where(:grade=>9).each 做 |c| 提出“#c.name - #c.students.size” 结尾

4) 检索至少修读三门 11 年级课程、多于一门 10 年级课程且不修读 9 年级课程的所有学生

没有解决办法

不确定最佳解决方案。如果没有为每个学生的每个年级水平的课程数量保留计数器缓存,在 SQL 中执行此操作将非常混乱。我可以添加一个钩子来自己更新这些信息。我不想把所有学生和课程都拉到后期处理中。

慢解

以下解决方案会产生大量查询。可能无法预加载课程。 (例如,学生来自协会的课程)


students = some_course.students
matching_students = []
students.each do |s|
  courses_9 = 0
  courses_10 = 0
  courses_11 = 0
  s.courses.each do |c|
    courses_9  += 1 if c.grade == 9
    courses_10 += 1 if c.grade == 10
    courses_11 += 1 if c.grade == 11
  end
  if courses_11 <= 3 && courses_10 > 1 && courses_9 == 0
    matching_students << s
  end
end
return matching_students

5) 检索所有上过一门以上数学课程的学生 查询)

SQL


SELECT s.*, count(e.course_id) as num_Courses FROM Students s
INNER JOIN Enrollments e on s.id = e.student_id
INNER JOIN Courses c on e.course_id = c.id AND c.name = 'Math'
GROUP BY s.id HAVING num_Courses > 0

或者


SELECT DISTINCT s.* FROM Students s
INNER JOIN Enrollments e_math_1 on e_math_1.student_id = s.id
INNER JOIN Courses c_math_1 ON e_math_1.course_id = c_math_1.id AND c_math_1.name = 'Math'
INNER JOIN Enrollments e_math_2 on e_math_2.student_id = s.id
INNER JOIN Courses c_math_2 ON e_math_2.course_id = c_math_2.id AND c_math_2.name = 'Math'
WHERE c_math_1.id != c_math_2.id

没有解决办法

不确定最佳解决方案。棘手的部分是 ActiveRecord(或 NoSQL)解决方案无法检索所有学生,然后再查看他们的课程,因为这太慢了。

慢解


students = SomeObject.students
multiple_math_course_students = []
students.each do |s|
  has_math_course = false
  add_student = false
  s.courses.each do |c|
    if c.name == 'Math'
      if has_math_course
        add_student = true
      else
        has_math_course = true
      end
    end
  end
  multiple_math_course_students << s if add_student
end

6) 检索所有正在学习数学和科学课程的学生

SQL


SELECT s.* FROM Students s
INNER JOIN Enrollments e_math on e_math.student_id = s.id
INNER JOIN Courses c_math ON e_math.course_id = c_math.id
INNER JOIN Enrollments e_science on e_science.student_id = s.id
INNER JOIN Courses c_science on e_science.course_id = c_science.id WHERE c_math.name = 'Math' AND c_science.name = 'Science'

没有解决办法

这涉及两次加入同一个表(或在 Rails 中,关联)。有没有办法用 ActiveRecord 的 AREL 包装器顺利地做到这一点?您可以为科学课和数学课创建一个单独的关联,允许您对每个课程进行单独的操作,但这不适用于下面的 #7。

慢解


students = SomeObject.students
math_and_science_students = []
students.each do |s|
  has_math_course = false
  has_science_course = false
  s.courses.each do |c|
    has_math_course = true if c.name == 'Math'
    has_science_course = true if c.name == 'Science'
  end
  math_and_science_students << s if has_math_course && has_science_course
end

7) 客户声明,只要系统中显示学生,在学生旁边显示一个数字,显示他们正在学习的最高年级课程。例如,如果 Suzie 正在学习 9 年级的科学课程和 10 年级的数学课程,则在 Suzie 旁边显示“10”。

解决方案

为每个学生记录查询数据库是不可接受的。显示 100 个学生的页面需要 100 个查询。此时,我想通过在学生表中放置一个带有“***别课程”的标志来对数据库进行非规范化。这是我最好的做法吗?从一开始就使用关系数据库以外的其他数据存储会更好吗?

假设客户要求将任意数据显示为徽章:最高年级、参加的数学课程数量、如果同时学习数学、科学和历史,则获得金徽章,等等。这些案例中的每一个都应该要求对数据库进行非规范化吗?非规范化数据是否应该与规范化数据保存在同一个关系数据库中?

【问题讨论】:

【参考方案1】:

首先,我认为您的数据库架构很好。我不会根据这些用例去规范化,因为它们很常见。

其次,你要学会区分 Persistence、业务逻辑和报告。 ActiveRecord 有利于基本的持久化和封装业务逻辑。它处理 CRUD 内容并让您将应用程序的大量逻辑放入模型中。但是,您谈论的许多逻辑听起来都像是报告,尤其是#6。您将不得不接受,对于像这样的某种查询逻辑,原始 SQL 将是您最好的选择。我认为如果你在那里更舒服的话,你已经实现的缓存计数器可能会帮助你保持活跃的记录和模型,但很可能你将不得不像你对其中几个解决方案所做的那样使用普通的 sql。报告通常需要直接的 sql。

规范化的数据库对于良好的应用程序设计至关重要。对于 OLTP 事务和业务逻辑而言,它对于使您的代码干净非常重要。不要仅仅因为你必须在 sql 中做一些连接就去规范化。这就是sql擅长的。通过非规范化你要做的就是让你的一些报告逻辑更快更容易,代价是让你的持久性和 OLTP 逻辑变得更慢和更难。

所以我会开始保留您的规范化数据库。如果您需要加入相关表,您通常可以使用 activerecord 的 include 方法来执行此操作,而无需使用常规 sql。要执行基于联接的计数之类的操作,您必须使用纯 sql。

最终,如果您的数据库变得非常庞大,包含大量数据,那么您的报告将会很慢,因为您必须执行所有连接操作。这可以。那时,马上开始考虑制作一个非规范化的单独报告数据库,您可以从规范化数据库中每小时、每晚、每周等更新。然后移动您的报告逻辑以查询报告数据库,而无需进行联接。然而,没有必要以这种方式开始。您只是在不确定收益的情况下产生了额外的复杂性和费用。也许您的带有连接的报告 sql 将无限期地工作而无需使用索引进行非规范化。不要过早优化。

我认为 nosql 也不一定是答案。据我所知,NoSQL 适用于特定用例。您的应用程序的用例和架构似乎非常适合关系数据库。

总的来说,我认为原始 sql(不是 arel/activerecord)和您实现的计数器的组合很好。

【讨论】:

感谢您的回答,非常感谢。我工作的应用程序是客户驱动的。尽管数据存储可能最好在报告中保留聚合或“更高级”的查询,但客户可以抛出一个曲线球,说:无论何时显示学生,也显示他们***的课程编号。在那种情况下,你会支持你的规范化数据库吗?以问题 #1 为例:假设您有一个页面一次显示 100 名学生。为了计算每个学生的最高分,您必须为每个学生运行至少一个查询,即 101 生成页面的查询总数!这不是报告,它只是需要为每个学生显示的额外信息。我应该指定我正在使用 Rails。我不确定这是否会影响您的答案,但 Rails 中的 find_by_sql 会预加载模型数据,但不会预加载额外的聚合数据,例如特殊计数。 我想我不明白...为什么需要 101 次查询?如果您查询所有排名最高的学生(我猜是第 9 名),那将只有 1或 2 个查询。 我了解这些页面可能不是正式的“报告”,但它们包含报告逻辑。只有一些逻辑在 sql 中使用连接更好地实现,而不是使用 activerecord/arel 和模型对象。所以是的,我几乎总是支持规范化的数据库。非规范化将导致比它解决的更多的性能和维护问题。如果需要,请对报告数据库使用非规范化,并定期根据规范化(事务/OLTP)数据库中的内容更新它们。 ActiveRecord 和 Arel 在过去几年中出现了很多,现在处理连接和分组在 IMO 中得到了很好的处理,而不必求助于原始 SQL。我发现自己越来越少使用原始 SQL,即使对于相当复杂的报告任务也是如此。【参考方案2】:

我现在遇到了同样的问题。根据我的研究,有几种方法可以绕过它。

首先,我相信任何应用程序都会遇到这些问题。基本思想是,我们以标准化方式对数据进行建模,当有大量数据并且数据跨越多个表时,这种方式本质上会变得缓慢且繁琐。

我能想到的最佳方法如下:

    将问题建模为接近您正在处理的真实世界的事情 根据需要进行标准化

这两个应该为应用程序提供很大的灵活性并提供许多方便的方法以及解决我试图回答的大部分问题

一旦我需要做一堆连接来获得我需要的东西,并且我觉得我应该对表进行非规范化以轻松获得我需要的东西,我会考虑以下几点:

SQL 视图: 这些是预定义的 sql 语句,例如连接,我可以将模型链接到这些语句。 通常这比通过 ActiveRecord 查询要快得多 http://hashrocket.com/blog/posts/sql-views-and-activerecord

汇总表: 创建一个或多个聚合表并使用delayed_job 进行异步更新,例如resque。 例如,这些聚合可以每天更新一次,模型可以直接查询它。 请注意,这是某种非规范化表。

Couchbase (NOSQL) 我没有用过这个,但它看起来很有趣。 http://couchbaseonrails.com/understand

【讨论】:

它可以是任何一种方式,我正在考虑一个非规范化的模型开始。然后,您可以根据需要进行标准化。但是,如果您从标准化模型开始,则相反。

以上是关于ActiveRecord - 非规范化案例研究的主要内容,如果未能解决你的问题,请参考以下文章

在更新操作中调用 ActiveRecord .reload 方法导致规范对象具有 nil id

ActiveRecord 更好的、非数字和非顺序的 id

案例分享|中国移动上研院基于MeterSphere开源持续测试平台构建规范化测试体系

[深入研究4G/5G/6G专题-44]: URLLC-15-《3GPP URLLC相关协议规范技术原理深度解读》-9-低延时技术-3-非时隙调度Mini slot

酸奶饮料新产品口味测试研究案例

RSpec 失败是因为它认为 ActiveRecord 对象不相等