Loading...
正在加载...
请稍候

本站Redis全文索引构建机制详解

✨步子哥 (steper) 2025年10月09日 13:22

🏗️ 架构概述

基于我对代码的深入分析,该项目使用RediSearch模块构建了一套完整的全文索引系统。整个索引机制采用分层异步架构,确保索引构建不影响主业务流程的响应性能。

📊 索引结构设计

1. 三套独立索引体系

系统设计了三套独立的RediSearch索引,分别处理不同类型的搜索需求:

Topic索引 (idx:topic)

// 索引键格式:zhichai:search:topic:{id}
{{LATEX:1}}redisPrefix . 'search:topic:',
    'SCHEMA',
    'title', 'TEXT', 'WEIGHT', '5',        // 标题权重最高
    'content', 'TEXT', 'WEIGHT', '1',      // 内容基础权重
    'author', 'TEXT', 'WEIGHT', '1.5',     // 作者权重中等
    'created_at', 'NUMERIC', 'SORTABLE',   // 支持时间排序
    'reply_count', 'NUMERIC', 'SORTABLE',  // 支持热度排序
    'is_hidden', 'TAG',                    // 隐藏状态过滤
    'is_closed', 'TAG'                     // 关闭状态过滤
);

Reply索引 (idx:reply)

// 索引键格式:zhichai:search:reply:{id}
'SCHEMA',
'content', 'TEXT', 'WEIGHT', '2',         // 回复内容权重高
'author', 'TEXT', 'WEIGHT', '1',          // 作者权重基础
'topic_title', 'TEXT', 'NOINDEX',         // 仅显示,不参与搜索
'topic_id', 'TAG',                        // 话题关联
'created_at', 'NUMERIC', 'SORTABLE',
'is_hidden', 'TAG'

User索引 (idx:user)

// 索引键格式:zhichai:search:user:{id}
'SCHEMA',
'nickname', 'TEXT', 'WEIGHT', '3',        // 昵称权重最高
'username', 'TEXT', 'WEIGHT', '2',        // 用户名权重中等
'created_at', 'NUMERIC', 'SORTABLE',
'status', 'TAG'                           // 用户状态过滤

🔄 索引构建流程

1. 自动索引确保机制

系统采用懒加载策略,在首次搜索时自动确保索引存在:

public function ensureIndexes(): void
{
    {{LATEX:2}}indexes as {{LATEX:3}}this->redis->rawCommand('FT.INFO', {{LATEX:4}}e) {
            // 索引不存在,自动创建
            error_log("[SearchService] 索引 {\(idx} 不存在,开始创建");\)this->createIndex(\(idx);
        }
    }
}
```

### 2. 实时索引更新机制

每当有新数据创建或更新时,系统会**立即**更新对应的搜索索引:

#### Topic索引更新
```php
public function indexTopic(array\)topic): void
{
    \(docKey = "search:topic:" .\)topic['id'];
    
    // 内容长度限制,防止索引过大
    \(content =\)topic['content'] ?? '';
    if (strlen(\(content) > 10000) {\)content = substr(\(content, 0, 10000);
    }\)hash = [
        'title'       => \(topic['title'] ?? '',
        'content'     =>\)content,
        'author'      => \(topic['author_nickname'] ?? '',
        'created_at'  => (string)(strtotime(\)topic['created_at'] ?? 'now')),
        'reply_count' => (string)(\(topic['reply_count'] ?? 0),
        'is_hidden'   =>\)topic['is_hidden'] ? '1' : '0',
        'is_closed'   => \(topic['is_closed'] ? '1' : '0',
    ];

    try {
        // 直接写入Redis Hash,RediSearch自动监听\)this->redis->hMSet(\(docKey,\)hash);
        error_log("[SearchService] 成功索引 Topic {{{LATEX:16}}e) {
        // 索引失败不影响主流程,仅记录日志
        error_log("[SearchService] 索引 Topic {\(topic['id']} 失败: " .\)e->getMessage());
    }
}

3. 异步队列处理

为了不影响主业务性能,索引更新通过异步队列进行处理:

// 在TopicService中的异步创建流程
public function createTopicAsync({{LATEX:18}}title, {{LATEX:19}}creationTime = null)
{
    // ... 创建Topic逻辑 ...
    
    try {
        // 1. 立即写入Redis(同步)
        {{LATEX:20}}topicKey, {{LATEX:21}}this->redis->zAdd({{LATEX:22}}timestamp, {{LATEX:23}}this->asyncWriter->enqueue('INSERT', 'topics', {{LATEX:24}}searchService = new SearchService();
        {{LATEX:25}}topicArray);
        
    } catch (\Exception {{LATEX:26}}q, string {{LATEX:27}}page = 1, int {{LATEX:28}}escapedTerm = {{LATEX:29}}clean);
    
    // 使用通配符包装,支持部分匹配
    {{LATEX:30}}escapedTerm}*";
    
    // 根据权限过滤隐藏内容
    if (!\(includeHidden) {\)topicPattern = "({{{LATEX:32}}allDocs, function({{LATEX:33}}b) {
    // 先比较相关性得分(命中次数越多得分越高)
    {{LATEX:34}}b['_score'] <=> {{LATEX:35}}scoreDiff !== 0) {
        return {{LATEX:36}}timeA = (int)({{LATEX:37}}timeB = (int)({{LATEX:38}}timeB <=> {{LATEX:39}}searchPattern = 'search:*';
do {
    {{LATEX:40}}redis->scan({{LATEX:41}}searchPattern, 200);
    foreach ({{LATEX:42}}k) {
        {{LATEX:43}}k);
        {{LATEX:44}}it !== 0);

// Step 2: 删除并重建索引
foreach (['idx:topic', 'idx:reply', 'idx:user'] as {{LATEX:45}}redis->rawCommand('FT.DROPINDEX', {{LATEX:46}}searchService->ensureIndexes();

// Step 3: 扫描并索引所有数据
{{LATEX:47}}pdo->query("SELECT * FROM topics ORDER BY created_at DESC");
foreach ({{LATEX:48}}topicData) {
    {{LATEX:49}}topicArray);
}

2. 性能优化

  • 批量处理: 每100条记录输出进度
  • 错误隔离: 单个文档失败不影响整体重建
  • 内存控制: 大内容自动截断防止内存溢出

💾 缓存策略

1. 搜索结果缓存

// 生成缓存键
{{LATEX:50}}clean . {{LATEX:51}}page . {{LATEX:52}}includeHidden ? '1' : '0'));

// 缓存搜索结果(TTL 60秒,平衡实时性与性能)
{{LATEX:53}}cacheKey, 60, json_encode({{LATEX:54}}redis->setOption(Redis::OPT_PREFIX, 'zhichai:');

🛡️ 容错处理

1. 超时保护

try {
    // Redis操作依赖连接的默认超时
    {{LATEX:55}}docKey, {{LATEX:56}}e) {
    // 索引失败不影响主流程,只记录日志
    error_log("[SearchService] 索引失败: " . $e->getMessage());
}

2. 降级策略

  • 核心功能优先: Topic/Reply创建必须成功
  • 辅助功能容错: 搜索索引失败仅记录日志
  • 异步重试: 通过异步队列支持重试机制

📈 性能特性

1. 查询性能

  • 索引查询: O(log N) 时间复杂度
  • 全文搜索: 毫秒级响应时间
  • 分页支持: 高效的LIMIT/OFFSET实现

2. 写入性能

  • 异步索引: 不阻塞主业务流程
  • 批量处理: 减少Redis往返次数
  • 内容截断: 防止大文档影响性能

🎯 总结

该Redis全文索引系统体现了以下设计亮点:

  1. 分层架构: 清晰的职责分离,便于维护和扩展
  2. 异步处理: 保证主业务性能不受索引构建影响
  3. 智能搜索: 支持中英文混合、部分匹配、权重排序
  4. 容错机制: 完善的错误处理和降级策略
  5. 运维友好: 提供完整的重建和监控工具

这套索引系统不仅满足了当前的全文搜索需求,还为未来的功能扩展预留了充分的灵活性。

讨论回复

1 条回复
✨步子哥 (steper) #1
2025-10-09 14:43

搜索流程完整技术解析

🌟 流程概览

搜索是一个涉及前端交互、路由分发、控制器处理、服务层查询、索引检索、结果聚合和视图渲染的完整技术链路。

🔥 详细流程剖析

第一阶段:用户界面交互

1. 搜索入口

用户可以通过多个入口进入搜索功能:

<!-- 导航栏搜索入口 -->
<li class="nav-item">
    <a class="nav-link" href="/search">
        <i class="bi bi-search me-1"></i>搜索
    </a>
</li>

2. 搜索表单设计

搜索界面采用Bootstrap 5响应式设计:

<form method="get" action="/search" class="row g-3">
    <!-- 关键词输入框 -->
    <div class="col-md-6">
        <input type="text" 
               name="q" 
               class="form-control" 
               value="<?= \(data['q'] ?>" 
               placeholder="搜索 主题 / 回复 / 用户"
               required>
    
``` ### 第二阶段:路由分发与控制器初始化 #### 1. 路由注册 在[`index.php`](file:///Users/linmiao/code/zhichai.php/index.php)中的路由配置: ```php // 全站搜索路由\)router->get('/search', function(RequestContext \(context) use (\)searchController) { \(searchController->handleSearch(\)context); });

2. 控制器依赖注入

SearchController通过DI容器实例化:

public function __construct()
{
    {{LATEX:4}}this->topicService = new TopicService();
    {{LATEX:5}}this->userService = new UserService();
    {{LATEX:6}}context): void
{
    // 提取并验证搜索参数
    {{LATEX:7}}_GET['q'] ?? '';                    // 搜索关键词
    {{LATEX:8}}_GET['scope'] ?? 'all';         // 搜索范围
    {{LATEX:9}}_GET['page'] ?? 1)); // 页码(最小为1)
    {{LATEX:10}}isLoggedIn = {{LATEX:11}}currentUserId = {{LATEX:12}}currentUser = null;
if ({{LATEX:13}}currentUser = {{LATEX:14}}currentUserId);
}

// 检查管理员权限(决定是否显示隐藏内容)
{{LATEX:15}}currentUser && {{LATEX:16}}cacheKey = 'search:cache:' . md5({{LATEX:17}}scope . {{LATEX:18}}pageSize . ({{LATEX:19}}cachedResult = {{LATEX:20}}cacheKey);
if ({{LATEX:21}}cachedResult !== null) {
    {{LATEX:22}}cachedResult, true);
    if ({{LATEX:23}}decoded; // 缓存命中,直接返回
    }
}

2. RediSearch索引查询

// 确保索引存在
{{LATEX:24}}escapedTerm = {{LATEX:25}}clean);
{{LATEX:26}}escapedTerm}*"; // 支持部分匹配

// 根据权限过滤隐藏内容
if (!\(includeHidden) {\)topicPattern = "({{{LATEX:28}}results = [];
if ({{LATEX:29}}scope === 'all') {
    {{LATEX:30}}this->execSearch('idx:topic', {{LATEX:31}}offset, {{LATEX:32}}scope === 'reply' || {{LATEX:33}}results['replies'] = {{LATEX:34}}replyPattern, {{LATEX:35}}pageSize);
}
if ({{LATEX:36}}scope === 'all') {
    {{LATEX:37}}this->execSearch('idx:user', {{LATEX:38}}offset, {{LATEX:39}}index, string {{LATEX:40}}offset, int {{LATEX:41}}raw = {{LATEX:42}}index,
        {{LATEX:43}}allDocs, function({{LATEX:44}}b) {
        // 1. 按相关性得分降序(命中次数越多得分越高)
        {{LATEX:45}}b['_score'] <=> {{LATEX:46}}scoreDiff !== 0) {
            return {{LATEX:47}}timeA = (int)({{LATEX:48}}timeB = (int)({{LATEX:49}}timeB <=> {{LATEX:50}}docs = array_slice({{LATEX:51}}offset, {{LATEX:52}}total, 'docs' => {{LATEX:53}}rawResults['topics'])) {
    {{LATEX:54}}rawResults['topics']['docs'] as {{LATEX:55}}key = {{LATEX:56}}/', \(key,\)matches)) {
            \(topicId = (int)\)matches[1];
            
            // 从业务缓存获取完整Topic对象
            \(topic =\)this->topicService->getTopicByID(\(topicId);
            if (\)topic) {
                \(topics[] =\)topic;
            }
        }
    }
    \(searchResults['topics'] = [
        'total' =>\)rawResults['topics']['total'],
        'items' => \(topics
    ];
}
```

#### 2. 多类型结果聚合
系统对Topic、Reply、User三种类型的搜索结果进行统一处理:

```php
// Reply结果处理
if (isset(\)rawResults['replies'])) {
    \(replies = [];
    foreach (\)rawResults['replies']['docs'] as \(doc) {\)key = \(doc['_key'];
        if (preg_match('/:(\d+)\)/', {{LATEX:67}}matches)) {
            {{LATEX:68}}matches[1];
            {{LATEX:69}}this->replyService->getReplyByID({{LATEX:70}}reply) {
                {{LATEX:71}}reply;
            }
        }
    }
    {{LATEX:72}}rawResults['replies']['total'],
        'items' => {{LATEX:73}}rawResults['users'])) {
    {{LATEX:74}}rawResults['users']['docs'] as {{LATEX:75}}key = {{LATEX:76}}/', \(key,\)matches)) {
            \(userId = (int)\)matches[1];
            \(user =\)this->userService->getUserByID(\(userId);
            if (\)user) {
                \(users[] =\)user;
            }
        }
    }
    \(searchResults['users'] = [
        'total' =>\)rawResults['users']['total'],
        'items' => \(users
    ];
}
```

### 第六阶段:视图数据准备与渲染

#### 1. 模板数据组装
```php
private function renderSearchPage(RequestContext\)context, string \(q, string\)scope, int \(page, int\)pageSize, array \(results, int\)totalResults): void
{
    // 准备完整的模板数据
    \(data = [
        'Title' => '搜索',
        'Page' => 'search',
        'IsLoggedIn' =>\)isLoggedIn,
        'Username' => \(username,
        'CurrentUserID' =>\)currentUserId,
        'IsAdmin' => \(currentUser ?\)currentUser->isAdminOrSuperAdmin() : false,
        'q' => htmlspecialchars(\(q),              // XSS防护
        'scope' =>\)scope,
        'page' => \(page,
        'pageSize' =>\)pageSize,
        'results' => \(results,
        'totalResults' =>\)totalResults,
        'currentUser' => \(currentUser,
    ];
}
```

#### 2. 分页计算逻辑
```php
// 计算总页数\)totalPages = 0;
if (\(scope === 'all') {\)totalPages = ceil(\(totalResults /\)pageSize);
} elseif (\(scope === 'topic' && isset(\)results['topics'])) {
    \(totalPages = ceil(\)results['topics']['total'] / \(pageSize);
} elseif (\)scope === 'reply' && isset(\(results['replies'])) {\)totalPages = ceil(\(results['replies']['total'] /\)pageSize);
} elseif (\(scope === 'user' && isset(\)results['users'])) {
    \(totalPages = ceil(\)results['users']['total'] / \(pageSize);
}\)data['totalPages'] = \(totalPages;
```

### 第七阶段:前端展示与用户交互

#### 1. 结果分类展示
搜索结果按类型分别展示,每种类型使用不同的颜色主题:

```html

主题 (data['results']['topics']['total'] ?>) </h5> </div> <!-- 回复结果 --> <div class="card-header bg-success text-white"> <h5 class="mb-0"> <i class="bi bi-chat-dots"></i> 回复 (<?= \(data['results']['replies']['total'] ?>)
用户 (data['results']['users']['total'] ?>) </h5> </div>

2. 详细信息卡片

每个搜索结果都展示丰富的上下文信息:

<!-- Topic结果卡片 -->
<a href="/topic/<?= \(topic->id ?>" class="list-group-item list-group-item-action">
    
topic->title) ?> <!-- 状态标签 --> <?php if (\(topic->is_closed): ?> 已关闭 topic->is_hidden): ?> <span class="badge bg-danger">已隐藏</span> <?php endif; ?> </h6> <small class="text-muted"><?= htmlspecialchars({{LATEX:108}}topic->content), 0, 150)) ?>... </p> <small class="text-muted"> 作者: <?= htmlspecialchars({{LATEX:109}}topic->author_username) ?> | 回复: <?= {{LATEX:110}}topic->view_count ?> </small> </a>

3. 智能分页导航

<nav aria-label="搜索结果分页">
    <ul class="pagination justify-content-center">
        <?php if ({{LATEX:111}}_GET['q']) ?>&scope=<?= {{LATEX:112}}data['page'] - 1 ?>">
                    上一页
                </a>
            </li>
        <?php endif; ?>

        <?php for ({{LATEX:113}}data['page'] - 2); {{LATEX:114}}data['totalPages'], {{LATEX:115}}i++): ?>
            <li class="page-item <?= \(i ===\)data['page'] ? 'active' : '' ?>">
                <a class="page-link" href="/search?q=<?= urlencode(\(_GET['q']) ?>&scope=data['scope'] ?>&page=<?= \(i ?>">
                    i ?>
                </a>
            </li>
        <?php endfor; ?>
    </ul>
</nav>

🎯 特殊场景处理

1. 空搜索状态

<div class="text-center py-5">
    <i class="bi bi-search" style="font-size: 4rem; color: #ccc;"></i>
    <h4 class="mt-3">开始搜索</h4>
    <p class="text-muted">输入关键词搜索主题、回复或用户</p>
</div>

2. 无结果状态

<div class="alert alert-info">
    <i class="bi bi-info-circle"></i> 
    没有找到与 "<strong><?= {{LATEX:119}}includeHidden) {
    {{LATEX:120}}pattern}) @is_hidden:{0}";
    \(replyPattern = "({\)pattern}) @is_hidden:{0}";
}

🚀 性能优化亮点

1. 分层缓存策略

  • L1缓存: RediSearch查询结果缓存(TTL 60秒)
  • L2缓存: 业务对象缓存(TopicService、ReplyService、UserService)
  • 智能缓存键: 包含所有查询参数,确保缓存准确性

2. 查询优化

  • 大批量获取: 先从RediSearch获取1000条结果
  • 应用层排序: 结合相关性得分和时间戳的智能排序
  • 精确分页: 在排序后应用分页,确保结果准确性

3. 安全防护

  • XSS防护: 所有输出使用htmlspecialchars()
  • 参数验证: 页码强制为正整数
  • 权限过滤: 基于用户角色的内容访问控制

📊 总结

整个用户搜索流程体现了以下设计精髓:

  1. 用户体验优先: 响应式设计、智能提示、丰富的结果展示
  2. 性能为王: 多层缓存、索引优化、智能排序
  3. 安全可靠: 权限控制、XSS防护、参数验证
  4. 架构清晰: 分层设计、职责分离、易于维护
  5. 功能完备: 支持多类型搜索、分页、状态过滤

这套搜索系统不仅满足了当前的功能需求,还为未来的扩展(如高级搜索语法、搜索建议、搜索历史等)预留了充分的架构空间。

推荐
智谱 GLM-5 已上线

我正在智谱大模型开放平台 BigModel.cn 上打造 AI 应用,智谱新一代旗舰模型 GLM-5 已上线,在推理、代码、智能体综合能力达到开源模型 SOTA 水平。

领取 2000万 Tokens 通过邀请链接注册即可获得大礼包,期待和你一起在 BigModel 上畅享卓越模型能力
登录