Posted on ::

在构建大型电商或内容平台时,商品评价、多级评论以及点赞系统是极为核心的交互模块。本文将深入剖析针对商品评价、多级评论与点赞等高频交互场景,采用模板方法与策略模式抽象评价处理流程,解耦差异化业务规则,并结合 Redis 实现点赞去重与计数缓存,支持高并发访问与平滑扩展,最终实现一个高性能、可扩展的评论系统。

一、 业务场景与复杂度分析

业务需求

  1. 商品主评价

    这是业务的根节点,通常发生在订单完成后。用户对商品进行评分、上传图片,并撰写文字评价。此类数据写入频率相对较低,但读取频率极高,且对数据的一致性要求最严,通常需要经过风控系统的文本与图片审核。

  2. 互动回复

    这包括商家对用户的解释性回复,以及用户之间的互动讨论。这里涉及到了层级关系,即回复的回复。

  3. 点赞与互动

    这是最高频的写操作。用户对评价、追评或回复进行点赞。点赞具有瞬时并发极高的特点,且要求即时反馈,不能接受明显的延迟。

痛点分析

随着业务迭代,最初的简单逻辑会迅速恶化。例如,商品评价允许上传图片,而回复不允许;主评价需要计算综合评分,而回复不需要;商家回复有特殊的UI标识,而普通用户没有。如果所有的逻辑都堆砌在同一个服务方法中,代码将充斥着大量的条件判断语句。此外,深层递归的评论结构会导致数据库查询性能急剧下降,造成严重的慢查询事故。

二、 系统调用链路设计

系统整体采用分层架构,从网关到存储层层递进。从请求发起到数据落盘的完整链路:

  graph TD
    Client[客户端] --> Gateway[网关层]
    Gateway --> |鉴权/限流| Controller[业务接口层]
    
    subgraph 核心业务域
    Controller --> |封装Command| StrategyFactory[策略工厂]
    StrategyFactory --> |路由分发| Processor[评价处理器]
    
    Processor --> |1.参数校验| Validator[校验逻辑]
    Processor --> |2.业务处理| DomainService[领域服务]
    Processor --> |3.持久化| DataLayer[数据层]
    end
    
    subgraph 数据与缓存
    DataLayer --> |主数据| MySQL[MySQL数据库]
    DataLayer --> |热点缓存| Redis[Redis缓存]
    DataLayer --> |异步消息| MQ[消息队列]
    MQ --> |削峰落库| Consumer[异步消费者]
    end
  1. 接入层

客户端发起请求,经过网关进行统一的鉴权与限流。对于点赞这类超高频请求,网关层可配置更激进的限流策略,甚至在活动高峰期进行降级处理。

  1. 业务编排层

Controller 接收请求后,并不直接处理业务,而是将参数封装为命令对象。这一层主要负责参数的基本校验,然后根据请求的类型(如发布主评、回复、点赞),将命令分发给对应的处理器。

  1. 策略分发层

这是核心业务逻辑的入口。利用工厂模式,根据传入的业务类型枚举,从 Spring 容器中获取对应的策略实现类。此时,调用链路进入了模板方法定义的标准流程中。

  1. 领域服务层

在具体的策略实现类中,完成领域逻辑的校验。例如,检查用户是否购买过该商品、是否触发了敏感词风控、是否超过了当天的发布限制等。

  1. 数据持久层与缓存层

校验通过后,数据一方面写入 MySQL 数据库作为持久化存储,另一方面同步或异步地更新 Redis 缓存,以支撑后续的高并发读取。对于点赞数据,则是先写入 Redis,再通过异步任务落库。

三、 数据库架构设计的深度考量

针对评论系统多读少写、数据量大、层级关系复杂的特点,表结构设计必须在规范化与性能之间寻找平衡,重点在于如何高效地存储和查询树状数据。

1. 评论主表设计

我们采用闭包表、邻接表与路径枚举的混合折中方案,更偏向于带根节点的邻接表设计。这种设计是为了避免数据库层面的递归查询。

为了更直观地展示方案选择的依据,我们对比几种主流的树形数据存储方案:

方案描述优点缺点适用场景
邻接表仅存储 parent_id写入简单,结构清晰查询子树需要递归,性能差简单的单级回复
路径枚举存储路径如 1/5/12易于查询整棵树,易于排序路径长度受限,更新父节点成本高论坛楼层
嵌套集左右值编码 lft, rgt查询子树极快插入和移动节点代价极大静态分类树
本案方案邻接表 + root_id查询整树只需一次索引查找数据有极少量冗余电商评论、社交动态

核心表结构 SQL 设计如下:

CREATE TABLE comment (
  id BIGINT UNSIGNED NOT NULL COMMENT '主键ID',
  biz_type VARCHAR(32) NOT NULL COMMENT '业务类型: PRODUCT/ARTICLE',
  biz_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID: 商品ID/文章ID',
  user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
  parent_id BIGINT UNSIGNED DEFAULT 0 COMMENT '父评论ID',
  root_id BIGINT UNSIGNED DEFAULT 0 COMMENT '根评论ID',
  content TEXT COMMENT '评论内容',
  images VARCHAR(1024) DEFAULT '' COMMENT '图片地址,JSON数组',
  like_count INT UNSIGNED DEFAULT 0 COMMENT '点赞数快照',
  status TINYINT DEFAULT 0 COMMENT '状态',
  create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
  update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  KEY idx_biz_root (biz_id, root_id, status) COMMENT '核心查询索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论主表';

id:主键 ID,建议使用雪花算法生成,保证全局唯一且按时间有序,有利于索引性能。

biz_type:业务类型字段,用于区分是商品评价、追评还是普通回复。该字段配合策略模式使用。

biz_id:业务归属 ID。如果是商品评价,这里存储商品 ID;如果是文章评论,这里存储文章 ID。该字段必须建立索引,是查询列表的主要入口。

user_id:发布者 ID,用于关联用户信息。

content:评论内容。对于长文本,建议垂直分表,将大字段单独存储,避免影响主表的扫描性能。

parent_id:直接父节点 ID。如果是一级评论,此值为 0。这是构建树形结构的直接依据。

root_id:根节点 ID。这是性能优化的关键字段。无论评论处于第几层级,该字段永远存储其所属的最顶层主评价的 ID。

(在查询某条主评价下的所有回复时,如果没有 root_id,数据库需要进行递归查询(Oracle 的 CONNECT BY 或 MySQL 8.0 的 CTE),性能极差。有了 root_id,我们只需执行 WHERE root_id = ? 即可一次性拉取整棵树的所有节点,将树形组装的压力转移到应用内存中,极大减轻了数据库负担。)

path:路径字段。存储如 1001/1005/1008 的字符串。这个字段虽然存在数据冗余,但对于需要展示楼层关系或进行特殊排序的场景非常有用,它可以快速判断两个节点的从属关系。

like_count:点赞数快照。虽然点赞数在 Redis 中维护,但数据库中仍需保留一份快照,用于缓存失效时的兜底展示以及数据分析。

status:状态机字段。包含 待审核、审核通过、审核拒绝、逻辑删除 等状态。

2. 点赞关系表设计

CREATE TABLE comment_like (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  comment_id BIGINT UNSIGNED NOT NULL COMMENT '评论ID',
  user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
  create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_comment_user (comment_id, user_id) COMMENT '防重复点赞唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点赞记录表';

comment_id: 被点赞的评论 ID。

user_id: 点赞用户 ID。

create_time: 点赞时间。

(comment_id 和 user_id 必须建立联合唯一索引。这不仅是为了加速查询,更是为了在数据库层面利用唯一约束防止重复点赞。尽管 Redis 会做前置校验,但数据库的唯一索引是数据一致性的最后一道防线。)

四、 核心代码模式:模板方法与策略模式的深度结合

为了解决业务逻辑膨胀和 if-else 泛滥的问题,我们采用模板方法定义骨架,策略模式实现差异。

1. 模板方法的抽象设计

我们定义一个抽象类 AbstractCommentProcessor,它规定了评论处理的标准生命周期。这个生命周期是不可变的,所有类型的评论都必须遵守。

public abstract class AbstractCommentProcessor {

    /**
     * 模板方法,定义标准处理流程,使用 final 禁止重写
     */
    public final void process(CommentCommand cmd) {
        // 1. 基础参数校验
        validateBasic(cmd);
        
        // 2. 业务权限校验,由子类实现
        checkPermission(cmd);
        
        // 3. 构建领域模型
        Comment comment = buildComment(cmd);
        
        // 4. 前置处理,如敏感词过滤
        preHandle(comment);
        
        // 5. 数据持久化
        saveToDb(comment);
        
        // 6. 后置处理,如发消息、加积分
        afterHandle(comment);
    }

    protected abstract void checkPermission(CommentCommand cmd);
    
    protected abstract Comment buildComment(CommentCommand cmd);

    private void saveToDb(Comment comment) {
        // 通用的落库逻辑
    }
    
    // 其他通用方法实现...
}

生命周期包含以下步骤:

参数校验:检查输入内容的合法性。

权限校验:检查当前用户是否有权执行此操作。

前置处理:如敏感词过滤、风控调用。

模型构建:将 DTO 转换为数据库实体对象。

数据持久化:写入数据库。

后置处理:如发送消息通知、增加积分、更新统计数据。

在抽象类中,我们将核心流程 process 方法定义为 final,防止子类篡改执行顺序。而将 validatecheckPermission 等方法定义为 abstractprotected,留给子类去实现具体逻辑。

2. 策略模式的差异化实现

针对不同的业务场景,创建具体的实现类。

商品评价策略类

@Component("PRODUCT_COMMENT")
public class ProductCommentProcessor extends AbstractCommentProcessor {

    @Resource
    private OrderService orderService;

    @Override
    protected void checkPermission(CommentCommand cmd) {
        // 校验用户是否购买过该商品且订单已完成
        boolean bought = orderService.hasFinishedOrder(cmd.getUserId(), cmd.getBizId());
        if (!bought) {
            throw new BizException("只有购买过商品才能评价");
        }
    }

    @Override
    protected Comment buildComment(CommentCommand cmd) {
        Comment comment = new Comment();
        // 处理商品评价特有的评分字段
        comment.setScore(cmd.getScore());
        // 处理图片上传逻辑
        comment.setImages(cmd.getImages());
        return comment;
    }
}

在权限校验步骤,该类会调用订单服务,查询用户是否确实购买了该商品且订单状态为已完成。在模型构建步骤,它会处理上传的图片和评分数据。

追评策略类:在权限校验步骤,它会检查当前时间是否超出了允许追评的期限,同时校验用户是否已经发布过追评(通常限制只能追评一次)。

回复策略类:它不需要校验订单,但需要校验被回复的评论是否依然存在且状态正常。

3. 策略工厂的路由机制

@Component
public class CommentProcessorFactory {

    private final Map<String, AbstractCommentProcessor> processorMap = new ConcurrentHashMap<>();

    // 利用 Spring 的自动注入将所有实现类放入 Map
    public CommentProcessorFactory(Map<String, AbstractCommentProcessor> processorMap) {
        this.processorMap.putAll(processorMap);
    }

    public AbstractCommentProcessor getProcessor(String bizType) {
        AbstractCommentProcessor processor = processorMap.get(bizType);
        if (processor == null) {
            throw new BizException("不支持的评价类型");
        }
        return processor;
    }
}

使用 Spring 的依赖注入功能,将所有实现了抽象类的 Bean 注入到一个 Map 中,Key 为业务类型枚举,Value 为对应的处理器实例。在业务入口处,根据前端传递的类型参数,直接从 Map 中获取对应的处理器对象并调用 process 方法。这种设计完全遵循了开闭原则,当需要新增一种评价类型时,只需新增一个类,而无需修改现有代码。

五、 Redis 高并发点赞系统的详细设计

点赞功能看似简单,却是系统并发的最高点。直接操作数据库会导致严重的锁竞争和 CPU 飙升。因此,设计必须基于 Redis 进行抗压。

1. Redis 数据结构选型

我们需要存储两类数据:某对象的总点赞数,以及某个用户是否对某对象点过赞。

方案一:Hash + Set

使用 String 或 Hash 存储计数,使用 Set 存储点赞的用户 ID 列表。

优点:实现简单,Set 天然去重,可以方便地查询某用户是否点赞。

缺点:当点赞数达到百万千万级时,Set 集合会变得极其巨大,造成大 Key 问题,读取和序列化会阻塞 Redis 线程。

方案二:Bitmap(位图)

使用 Bitmap 存储用户点赞状态,Offset 为用户 ID,Value 为 0 或 1。

优点:极致的存储空间压缩。

缺点:需要用户 ID 必须是连续整数,且数据稀疏时(如总用户 1 亿,只有 1 个点赞)反而浪费空间。

最终方案:Hash 计数 + 定长 Set 缓冲 + 数据库持久化

考虑到通用性,我们采用复合方案。

Redis 中使用 Hash 结构存储计数:Key: comment_like_count, Field: comment_id, Value: count。

Redis 中使用 Set 结构存储最近点赞的用户:Key: comment_liked_users:{comment_id}。注意,这个 Set 我们不存全量,只用于高频访问的去重判断,或者通过过期时间控制生命周期。

全量的点赞记录,依靠异步写入数据库保存。

2. 高并发点赞流程详解

第一步:Lua 脚本保证原子性

点赞操作包含三个逻辑:检查是否已赞、写入点赞记录、计数器加一。这三个步骤必须是原子的。我们编写 Lua 脚本提交给 Redis 执行。

-- KEYS[1]: 点赞用户Set集合 Key
-- KEYS[2]: 点赞计数器 Key
-- ARGV[1]: 用户ID
-- ARGV[2]: 过期时间(秒)

-- 1. 检查用户是否已点赞
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
    return 0 -- 重复点赞
end

-- 2. 添加用户到 Set
redis.call('SADD', KEYS[1], ARGV[1])

-- 3. 增加计数
redis.call('INCR', KEYS[2])

-- 4. 刷新过期时间,防止冷数据常驻
redis.call('EXPIRE', KEYS[1], ARGV[2])

return 1 -- 点赞成功

脚本逻辑如下:

首先检查 Set 中是否存在该用户 ID。如果存在,直接返回重复点赞。

如果不存在,执行 SADD 写入用户 ID,同时执行 HINCRBY 对计数器加一。

设置 Key 的过期时间,防止冷数据长期占用内存。

第二步:异步削峰落库

Redis 更新成功后,业务直接返回成功给前端。

随后,系统将点赞事件(包含评论 ID、用户 ID、时间)发送到消息队列(如 Kafka 或 RocketMQ)。

消费者服务订阅队列,以批量的方式将点赞记录插入 MySQL 的 comment_like 表。

  sequenceDiagram
    participant User as 用户
    participant API as 后端服务
    participant Redis as Redis缓存
    participant MQ as 消息队列
    participant DB as 数据库

    User->>API: 发起点赞请求
    API->>Redis: 执行 Lua 脚本
    alt 脚本返回 1 (成功)
        Redis-->>API: 更新缓存成功
        API->>MQ: 发送点赞事件消息
        API-->>User: 返回点赞成功
    else 脚本返回 0 (重复)
        API-->>User: 提示已点赞
    end
    
    loop 异步消费
        MQ->>DB: 消费者批量获取消息
        DB->>DB: 插入点赞记录 (忽略唯一索引冲突)
    end

技术细节: 消费者在入库时,如果遇到数据库唯一索引冲突,说明是重复消息或极端的并发情况,此时应忽略异常,确保幂等性。

第三步:取消点赞

取消点赞是点赞的逆操作。同样通过 Lua 脚本原子性地从 Set 中移除用户 ID 并减少计数。同时发送取消点赞的消息到 MQ,消费者在数据库中执行物理删除或逻辑删除。

3. 读写一致性与缓存穿透处理

查询点赞状态

用户查看评论列表时,需要知道自己是否给这些评论点过赞。

首先查询 Redis 的 Set。如果 Set 中有数据,直接判定。

如果 Redis 中 Key 不存在(过期或未命中),则利用 Bloom Filter(布隆过滤器)快速判断。如果布隆过滤器认为可能存在,再回查数据库,并将结果回填到 Redis。这能有效防止大量未点赞的用户请求穿透到数据库。

缓存与数据库的最终一致性

由于采用异步落库,Redis 的计数可能与数据库记录数短暂不一致。我们并不追求强一致性,而是通过定时任务(如 T+1 对账)校准。每天低峰期,扫描热门评论的数据库记录数,强制覆盖 Redis 的计数,消除累积误差。

六、 树形结构与扁平化处理

多级评论在数据库中是扁平存储的,但在前端展示时需要还原为树形或嵌套结构。如何在内存中高效地完成这一转换是关键。

1. 为什么要扁平化查询

传统的做法是先查一级评论,对每个一级评论再循环查二级评论。这是典型的 N+1 查询问题,效率极低。

我们的做法是:根据 root_id 一次性查出该主评下的所有回复(包括二级、三级等)。假设查出了 100 条数据,这是一个扁平的 List 集合。

2. 内存中构建树的算法

我们需要将这个扁平 List 转换为层级结构。即使前端只需要两层结构(主评 + 列表形式的子评),后端最好也具备构建完整树的能力以适应不同端的需求。

public List<CommentVO> buildTree(List<Comment> flatList) {
    if (CollectionUtils.isEmpty(flatList)) {
        return Collections.emptyList();
    }

    List<CommentVO> rootNodes = new ArrayList<>();
    // 1. 转换 VO 并建立 ID 映射 Map
    Map<Long, CommentVO> nodeMap = flatList.stream()
            .map(CommentVO::fromEntity)
            .collect(Collectors.toMap(CommentVO::getId, Function.identity()));

    // 2. 遍历组装
    for (CommentVO node : nodeMap.values()) {
        Long parentId = node.getParentId();
        if (parentId == null || parentId == 0) {
            // 是根节点
            rootNodes.add(node);
        } else {
            // 是子节点,找到父节点并挂载
            CommentVO parent = nodeMap.get(parentId);
            if (parent != null) {
                if (parent.getChildren() == null) {
                    parent.setChildren(new ArrayList<>());
                }
                parent.getChildren().add(node);
            }
        }
    }
    
    // 3. 排序处理 (根据时间倒序或正序)
    rootNodes.sort((a, b) -> b.getCreateTime().compareTo(a.getCreateTime()));
    return rootNodes;
}

具体步骤:

第一步:建立映射

遍历 List,将所有 Comment 对象放入一个 Map 中,Key 为 id,Value 为 Comment 对象本身。这一步是为了通过 ID 快速找到引用,时间复杂度为 O(N)。

第二步:组装层级

再次遍历 List。对于每一个 Comment 对象,获取其 parent_id。

如果 parent_id 为 0 或空,说明它是根节点,将其加入结果集列表。

如果 parent_id 不为空,则从 Map 中获取父对象。如果父对象存在,将当前对象添加到父对象的 children 列表中。

第三步:处理排序

在组装完成后,对每一个节点的 children 列表进行排序。通常按照时间正序排列(最早回复在最前)。

3. 前端展示的优化策略

虽然算法可以构建无限层级,但现代移动端 UI 设计通常不再推荐无限缩进的盖楼模式。

主流做法是:两层结构。

第一层:主评论。

第二层:该主评论下的所有回复,按时间流平铺展示。如果回复是针对某人的,则在文案中显示 回复 某某用户。

这种展示方式下,后端只需要将数据按照 root_id 分组,主评论单独返回,子评论作为一个扁平列表返回即可,大大简化了前端的渲染逻辑,也降低了数据传输的体积。

七、 总结

本系统设计通过分层架构清晰地隔离了关注点。利用模板方法模式,我们规范了评论处理的标准化流程,杜绝了逻辑遗漏;利用策略模式,我们实现了业务规则的解耦,使得系统面对新增评价类型时具备极强的扩展性。

在数据存储上,通过 root_id 的设计巧妙解决了树形数据的查询难题。最关键的是,通过 Redis 的原子操作与异步消息队列的配合,我们将高频的点赞写操作完全从数据库解耦,实现了系统在高并发场景下的高性能与最终一致性。

这是一套经过实战检验、既符合软件工程原则又能应对复杂业务挑战的解决方案。

Table of Contents