TL;DR使用SQLend_date2 >= start_date1 and end_date1 >= start_date2 。
问题
想象一下。一个房地产网站的客人想预订一个特定日期的酒店。系统应该检查这些日期是否可用,也就是说,是否与其他现有的预订重叠。比方说,软件工程师用Rails编写了这个假设的网站,并提出了Booking 模型。它表示bookings 表,有两列:start_date 和end_date 的date 类型。另外,假设有一个验证的地方,检查start_date <= end_date 。下面的解决方案描述了如何应对这种特殊情况。类似的数据模型也可以遵循它。
解决方案
也许,这个问题最简单的解决方案可以用Rails的方式来处理。只需在Booking 模型中定义一个自定义验证,在每次创建新的预订或更新现有预订时执行:
class Booking < ApplicationRecord
# ... some code is skipped here for simplicity's sake
validate :validate_other_booking_overlap
def period
start_date..end_date
end
private
def validate_other_booking_overlap
other_bookings = Booking.all
is_overlapping = other_bookings.any? do |other_booking|
period.overlaps?(other_booking.period)
end
errors.add(:overlaps_with_other) if is_overlapping
end
end
但不幸的是,这里有一个性能瓶颈。请记住,所有的预订都是先从数据库中获取的。然后他们被反序列化到Booking 模型实例中。之后,根据创建/更新的Booking 实例检查每个人的时期。乍一看--如此简单的代码,但它实际上做了多少复杂的事情!它创建了这么多的对象,消耗了这么多的资源。它创建了这么多的对象,在运行这段代码的机器上消耗了大量的内存。这实际上是任何软件变慢的主要原因。然而,有时这种尝试是可行的,也就是说,当从数据库中获取的对象数量不多。是否采用这种方法由开发者决定,并应考虑到可能的缺点而明智地选择。
如果这个方法不起作用,应该寻找新的方法。怎样才能改进这个问题呢?为了回答这个问题,应该了解问题的根本原因。而它实际上是在上面强调的--分配的对象的数量是巨大的。因此,我们需要减少它。一个可能的方法是将循环转移到数据库中,幸运的是ActiveRecord接受SQL。这就是使用PostgreSQL的人可能最终得到的代码:
def validate_other_booking_overlap
sql = "daterange(start_date, end_date, '[]') && daterange(:start_date, :end_date, '[]')"
is_overlapping = Booking.where(sql, start_date: c.start_date, end_date: c.end_date).exists?
errors.add(:overlaps_with_other) if is_overlapping
end
将语句
daterange(start_date, end_date, '[]')解释为 "创建一个从start_date到end_date的日期范围,包括右边和左边"。第三个参数[]指向包容性的属性。关于这个的更多信息可以在这里找到。
这里使用的
&&操作符,用于检查范围重叠。如果出现任何问题,请查看文档。
这个尝试有什么问题呢?好吧,这段代码与第一段相比,效率高多了。但是仍然为日期范围创建了对象,不过这次是在数据库层面。记住,不必要的对象数量是导致程序缓慢的原因。这就是为什么,如果可能的话,应该减少分配的数量。这段代码是从以前的版本中翻译过来的,强调了可读性。因此,即使在转换为SQL后,它也是可读的。但如何提高它的速度呢?这一次,强调可读性是关键。通常,为了解决性能问题,当前的解决方案可能会以一种更有效的方式重写。但这通常会牺牲掉清晰度。尝试这种方式,人们可能会结束下一段SQL:
sql = <<~SQL
(
(start_date <= :start_date and :start_date <= end_date) or
(start_date <= :end_date and :end_date <= end_date)
) or (
(:start_date <= start_date and start_date <= :end_date) or
(:start_dae <= end_date and end_date <= :end_date)
)
SQL
其余的代码被省略了,因为它仍然是一样的。从现在开始,只有定义
sql变量的验证方法的那一行发生了变化。
它只是检查第一个范围的任何边缘是否在第二个范围内。或者第二个范围的任何边缘是否在第一个范围内。这个选择分配的对象更少,所以它一定比之前的快。但是看看这个--它有点麻烦了。它能更好吗?事实证明它可以:
sql = ":end_date >= start_date and end_date >= :start_date"
这个公式背后的逻辑是什么?当且仅当它们不是从左边重叠,也不是从右边重叠的情况下,区间才会重合。或者以下情况不发生:
start_date end_date
|--------------------|
:start_date :end_date
|-------------------|
或
start_date end_date
|--------------------|
:start_date :end_date
|-------------------|
这一点的证明是相当明显的:所有可能的情况都可以画出来并检查。之后就会发现,所有其他情况都会相交。
将此语句转化为布尔公式:
not (:end_date < start_date or end_date < :start_date)
去掉前面的否定词,用括号内的所有语句替换它们的否定词:
=>
not (:end_date < start_date) and not (end_date < :start_date)
=>
:end_date >= start_date and end_date >= :start_date
如果这个解释不清楚,请检查。
最后的公式就得出了。但是有什么缺点吗?嗯,这是一个品味的问题。一方面,它的可读性比Rails方式的解决方案差。另一方面,这也是我们目前想到的最有效的方法。如果有人认为这个技巧不清楚,可以提供文档。这样,每个人在阅读这段代码时都能理解它背后隐藏的东西。
结语
这篇文章提供了一个相当普遍的问题的解决方案,特别是日期范围的重叠。有时候,要解决一个特定的问题,在理解和效率之间取得平衡是很难的。这段旅程是为了展示它,并引导到一个解决方案。
这要归功于那些审查了我解决类似问题的拉动请求的同事。建议的最终方法对我来说并不明确,甚至看起来不可行。但经过一番思考后,我改变了看法。这个思考的过程和证明是非常有趣的。它让我写下了这一点。
永远不要放弃为你的问题找到一个好的解决方案。总是有机会改进的。编码愉快!