28

我知道这个问题已经在这里问过几次了,但没有一个答案让我满意。这是因为几乎所有这些都涉及与数据库相关的巨大读/写过程,我想不惜一切代价避免。

关于未读的讨论/主题/帖子,有很多要考虑的。我不知道MyBBvBulletinInvision Power BoardVanillaphpBB等论坛系统如何处理这个问题,所以我想从你们那里了解一下你们的经验。我知道为此使用数据库表是最简单的方法,但是当社区每月有超过 10,000 名成员和 1000 个新主题时,这将涉及大量的读/写。这很难,但应该有办法避免服务器超载。

那么,您认为该问题的最佳实践是什么,以及其他论坛系统如何应对呢?

4

7 回答 7

16

没有太多选择。

  1. 标记每个用户的每个读者线程。

    • 缺点:在非常活跃的论坛中有很多行
    • 优点:每个用户都知道帖子是否已阅读。
  2. 标记每个用户的每个未读线程。

    • 缺点:如果有很多用户不活动,则会有很多空间带有“未读”行
    • 解决方案:添加生命周期时间戳并使用 cron 删除旧记录
    • 优点:每个用户都知道帖子是否已阅读。
  3. 使用时间戳来确定是否将其显示为未读。

    • 缺点:用户不知道是真正的未读线程,标记仅显示自上次登录以来的“新线程”
    • 优点:节省空间

另一种选择是混合解决方案,即

1 和 3) 如果线程不超过 X 天并且没有为用户标记为已读的行,则将线程显示为“未读”。“已读”行可以在 X 天前被删除,而不会影响任何事情。

好处

  • 用于确定未读线程的间隔更小

缺点

  • 创建一个保持系统清洁的 cron
  • 用户不知道他们是否阅读了超过 x 天的线程。

好处

  • 每个用户都知道哪些“新帖子”已阅读或未阅读。
于 2010-02-22T02:38:45.217 回答
9

还有另一种。

另一种存储分层论坛结构(板>部分>线程等)的详细已读/未读数据的方法。它这样做不需要 a) 必须预先填充已读/未读信息,并且 b) 在最坏的情况下不必存储超过 U*(M/2) 行,其中 U 是用户数,M 是数据库中的帖子总数(通常比这个少得多)

不久前我研究了这个话题。我发现 SMF/phpBB 在存储用户阅读历史记录方面有点“作弊”。他们的架构支持存储在给定版块、论坛、子论坛、主题(或由浏览器直接查看)中标记为已读的最后时间戳或消息 ID,如下所示:

[ user_id, board, last_msg_id, last_timestamp ]

[ user_id, board, forum, last_msg_id, last_timestamp ]

[ user_id, board, forum, subforum, last_msg_id, last_timestamp ]

[ user_id, board, forum, subforum, topic, last_msg_id, last_timestamp ]

这让用户可以将特定的版块、论坛、主题等标记为“已读”。但是,它需要用户的任一操作(通过阅读或主动单击“标记为已读”),并且在 phpBB 的情况下,不会给您提供“我已经看到这个特定的消息,但不是那个特定的消息。” 您还会遇到这样的情况:您首先阅读了主题中的最后一条消息(查看线程中的最新活动),并且您立即被假定已阅读了线程的其余部分。

它适用于 SMF 和 phpBB 来存储这样的内容,因为您很少只查看一个帖子(默认视图设置为在主题的最后一页中包含 20 多个帖子)。但是,对于线程较多的论坛(尤其是您一次只查看一条消息的论坛),这不太理想。如果该系统的用户已经阅读了一条消息而不是另一条消息,他们可能会非常关心,并且可能会认为只能将整个部分标记为已读很麻烦,而实际上他们只是想要一些标记为已读。

您将消息存储在这样的元组中: [ user_id, lower_msg_id, upper_msg_id ]

用户历史日志的维护方式如下:

在页面查看时,函数会查看 user_id 是否有一条记录,其中 current_msg_id 介于 lower_msg_id 和 upper_msg_id 之间。如果有,则读取此页面,无需采取任何操作。如果没有,则必须发出另一个查询,这次确定 current_msg_id 是否比 lower_msg_id (current_msg_id == lower_msg_id-1) 小一,或者比 upper_msg_id (current_msg_id == upper_msg_id +1) 大一。这就是我们将“读取”或“看到”边界增加 1 的情况。如果我们与 lower_msg_id 或 uppper_msg_id 相差 1,那么我们在该方向上将元组增加 1。如果我们没有扩大我们的元组范围,那么我们插入一个新元组 [user_id, current_msg_id, current_msg_id]。

极端情况是两个元组范围相互接近时。在这种情况下,在下元组边界和上元组边界之间搜索时,通过将下元组的上边界设置为上元组的上边界来合并两个边界,并删除上元组。

PHP中的代码示例:

function seen_bounds( $usr_id, $msg_id ) {

    # mysql escape
    $usr_id = mres( $usr_id );
    $msg_id = mres( $msg_id );

    $seen_query = "
        SELECT
            msb.id,
            msb.lower_msg_id,
            msb.upper_msg_id
        FROM
            msgs_seen_bounds msb
        WHERE
            $msg_id BETWEEN msb.lower_msg_id AND msb.upper_msg_id AND
            msb.usr_id = $usr_id
        LIMIT 1;
    ";

    # See if this post already exists within a given
    # seen bound.
    $seen_row = query($seen_query, ROW);

    if($seen_row == 0) {
        # Has not been seen, try to detect if we're "near"
        # another bound (and we can grow that bound to include
        # this post).
        $lower_query = "
            SELECT
                msb.id,
                msb.lower_msg_id,
                msb.upper_msg_id
            FROM
                msgs_seen_bounds msb
            WHERE
                msb.upper_msg_id = ($msg_id - 1) AND
                msb.usr_id = $usr_id
            LIMIT 1;
        ";

        $upper_query = "
            SELECT
                msb.id,
                msb.lower_msg_id,
                msb.upper_msg_id
            FROM
                msgs_seen_bounds msb
            WHERE
                msb.lower_msg_id = ($msg_id + 1) AND
                msb.usr_id = $usr_id
            LIMIT 1;
        ";

        $lower = query($lower_query, ROW);
        $upper = query($upper_query, ROW);

        if( $lower == 0 && $upper == 0 ) {
            # No bounds exist for or near this. We'll insert a single-ID
            # bound

            $saw_query = "
                INSERT INTO
                    msgs_seen_bounds
                (usr_id, lower_msg_id, upper_msg_id)
                VALUES
                ($usr_id, $msg_id, $msg_id)
                ;
            ";

            query($saw_query, NONE);
        } else {
            if( $lower != 0 && $upper != 0 ) {
                # Found "near" bounds both on the upper
                # and lower bounds.

                $update_query = '
                    UPDATE msgs_seen_bounds
                    SET
                        upper_msg_id = ' . $upper['upper_msg_id'] . '
                    WHERE
                        msgs_seen_bounds.id = ' . $lower['id'] . '
                    ;
                ';

                $delete_query = '
                    DELETE FROM msgs_seen_bounds
                    WHERE
                        msgs_seen_bounds.id = ' . $upper['id'] . '
                    ;
                ';

                query($update_query, NONE);
                query($delete_query, NONE);
            } else {
                if( $lower != 0 ) {
                    # Only found lower bound, update accordingly.
                    $update_query = '
                        UPDATE msgs_seen_bounds
                        SET
                            upper_msg_id = ' . $msg_id . '
                        WHERE
                            msgs_seen_bounds.id = ' . $lower['id'] . '
                        ;
                    ';

                    query($update_query, NONE);
                }

                if( $upper != 0 ) {
                    # Only found upper bound, update accordingly.
                    $update_query = '
                        UPDATE msgs_seen_bounds
                        SET
                            lower_msg_id = ' . $msg_id . '
                        WHERE
                            msgs_seen_bounds.id = ' . $upper['id'] . '
                        ;
                    ';

                    query($update_query, NONE);
                }
            }
        }
    } else {
        # Do nothing, already seen.
    }

}

搜索未读帖子是查找给定用户的任何lower_msg_id 和upper_msg_id 之间不存在current_msg_id 的位置(SQL 术语中的NOT EXISTS 查询)。在关系数据库中实现时,这不是最有效的查询,但可以通过积极的索引来解决。例如,以下是一个 SQL 查询,用于计算给定用户的未读帖子,按帖子所在的讨论区(“项目”)分组:

$count_unseen_query = "
    SELECT 
        msgs.item as id,
        count(1) as the_count
    FROM msgs
    WHERE
    msgs.usr != " . $usr_id . " AND
    msgs.state != 'deleted' AND
    NOT EXISTS (
       SELECT 1 
       FROM 
          msgs_seen_bounds msb
       WHERE 
          msgs.id BETWEEN msb.lower_msg_id AND msb.upper_msg_id
          AND msb.usr_id = " . $usr_id . "
    )
    GROUP BY msgs.item
    ;

在论坛上阅读的用户越多,每个元组标记为已读的范围越广,需要存储的元组就越少。用户可以获得已读与未读的准确计数,并且可以很容易地汇总以查看每个论坛、子论坛、主题等中的已读与未读。

给定一个大约 2000 多个帖子的小型论坛,以下是有关存储的元组数量的使用统计信息,按用户登录次数(近似用户活动)排序。“num_bounds”列是存储用户“num_posts_read”查看历史所需的元组数。

id  num_log_entries num_bounds num_posts_read num_posts
479             584         11           2161       228
118             461          6           2167       724
487             119         34           2093       199
499              97          6           2090       309
476              71        139            481        82
480              33         92            167        26
486              33        256            757       154
496              31        108            193        51
490              31         80            179        61
475              28        129            226        47
491              22         22           1207        24
502              20        100            232        65
493              14         73            141         5
489              14         12           1517        22
498              10         72            132        17

除了我自己的自定义论坛,我还没有在任何论坛中看到过这个特定的实现,而且它只是一个很小的实现。如果其他人已经实施或看到在其他地方实施,特别是在大型和/或活跃的论坛中,我会很感兴趣。

问候,

凯登

于 2011-02-18T19:46:30.840 回答
3

不完全是 PHP 答案,但这是我们在基于 asp.net 的论坛中的做法(我隶属于这个产品,由于规则而披露)

  1. 我们使用 cookie,而不是数据库。
    • cookie 的缺点- 不是“跨设备”(从另一台计算机访问显示所有内容为未读)
    • 优势- 没有巨大的数据库读取/写入。并且跟踪也适用于“访客”用户!这太棒了。
  2. 我们为用户访问的每个主题存储一个带有{ topicID, lastReadMessageID }对的 cookie。
  3. 如果在 cookie中找不到特定主题的数据,我们假设该主题是:
    • 完全未读(如果主题的最后一条消息大于lastReadMessageID来自 (2)的 MAX
    • 完整阅读(否则)

这有一些小缺陷,但它可以完成工作。

PS。另外,有些人可能会说使用 cookie 会在用户的计算机上留下垃圾(我个人讨厌这个),但我们发现普通用户跟踪大约 20 个主题顶部,因此每个主题大约需要 10 个字节,因此它需要不到 200 个字节在用户的硬盘上。

于 2013-12-15T11:56:23.663 回答
1

你为什么担心?

我没有看到任何获取未读线程的 I/O 问题。它不必是现场的。基于缓存值的 15 分钟延迟将起作用。

因此,对于未读线程,您只需

伪代码..

$result = SELECT id,viewcount from my_forum_threads

$cache->setThreads($result['id'],$result['viewcount']);

然后在页面加载时,您只需获取缓存值,而不是再次查询数据库。它真的不是一个大问题。

我网站上的平均页面需要 20 个 mysql 查询。当我缓存时,它只有两到四个查询。

于 2010-02-18T13:29:14.270 回答
1

我知道的几乎所有论坛都会使用某种参考时间戳来确定线程/消息是否应被视为“未读”。此时间戳通常是您上次访问论坛时执行的最后一次操作的日期/时间。

所以你保持ie。用户表中的 previous_last_action 和 last_action 时间戳,last_action 在每个用户操作上更新,previous_last_action 列在登录时(或在创建新会话时 - 如果您具有“记住我”功能)设置一次为 last_action。要确定线程/消息是否未读,您可以将该线程/消息创建(或更新)时间戳与当前登录用户的 previous_last_action 中的值进行比较。

于 2010-02-18T16:16:23.507 回答
1

关于(我认为)IPB 如何做到这一点的快速回答:

所有早于配置数量(默认 30 天)的帖子都会自动标记为已读。一个 cronjob 会从每个用户那里删除这些以保持大小可管理。

所有少于 30 天的帖子都作为每个用户 ID + 类别的 JSON 条目进行跟踪。例如:12 个类别,1000 个活跃用户 = 最多 12,000 行。

有一个“未读计数”字段用于快速查找,例如,论坛主页或其他任何需要数字的地方。

我可能完全不使用实际的 MySQL 存储。我找不到这方面的文档,但我翻阅了数据库并看到了一个/看起来/像已读/未读线程的表(表:core_item_markers,供参考)。但我对混合年龄/mysql 模型持肯定态度。

于 2013-07-10T17:19:57.020 回答
0

我已经阅读了所有的答案,我想出了一个可能是这个主题的最佳组合的想法(虽然没有代码)。
这个想法融合了您的所有想法和我在编程方面的一点经验
Aprox 95% 的用户(从论坛管理员和他的论坛日志中获得的统计数据)直接阅读论坛的主题到最后一篇文章(或页面)并且不要回去,阅读第一页的帖子(或只是第一页)然后转到最后一页,或者他们从头到尾阅读整个线程,如果他们回头,他们已经阅读了部分。所以一个好的解决方案应该是这样的:
我认为如果我们为每个用户、每个线程创建一个商店,用户查看的最后一个帖子的时间戳(如果适用,用户查看的第一个帖子,即使这可能没有用),我们可以获得某处有这个。该系统非常简单,几乎就像 phpbb 的。将我们看到的最后一篇文章标记为稍后继续(而不是被迫将所有该页面视为已读)也是有用的。而且,因为每个线程都有自己的 id。没有必要像 phpbb 那样组织。

于 2011-02-24T13:41:18.150 回答