您正在查看静态缓存页面 · 查看完整动态版本 · 登录 参与讨论

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

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

🏗️ 架构概述

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

📊 索引结构设计

1. 三套独立索引体系

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

Topic索引 (idx:topic)

// 索引键格式:zhichai:search:topic:{id}
$this->redis->rawCommand(
    'FT.CREATE', 'idx:topic',
    'ON', 'HASH',
    'PREFIX', '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
{
    $indexes = ['idx:topic', 'idx:reply', 'idx:user'];
    
    foreach ($indexes as $idx) {
        try {
            // 尝试获取索引信息
            $this->redis->rawCommand('FT.INFO', $idx);
        } catch (\RedisException $e) {
            // 索引不存在,自动创建
            error_log("[SearchService] 索引 {$idx} 不存在,开始创建");
            $this->createIndex($idx);
        }
    }
}

2. 实时索引更新机制

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

Topic索引更新

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 {$topic['id']}");
    } catch (\RedisException $e) {
        // 索引失败不影响主流程,仅记录日志
        error_log("[SearchService] 索引 Topic {$topic['id']} 失败: " . $e->getMessage());
    }
}

3. 异步队列处理

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

// 在TopicService中的异步创建流程
public function createTopicAsync($userID, $title, $content, $creationTime = null)
{
    // ... 创建Topic逻辑 ...
    
    try {
        // 1. 立即写入Redis(同步)
        $this->redis->hMSet($topicKey, $topicData);
        $this->redis->zAdd($this->topicIndexKey, $timestamp, $topicID);
        
        // 2. 异步写入SQLite队列
        $this->asyncWriter->enqueue('INSERT', 'topics', $topicArray);
        
        // 3. 异步更新搜索索引
        $searchService = new SearchService();
        $searchService->indexTopic($topicArray);
        
    } catch (\Exception $e) {
        // 错误处理和回滚机制
    }
}

🔍 搜索查询机制

1. 智能搜索策略

系统使用通配符模式支持中文和部分匹配:

public function search(string $q, string $scope = 'all', int $page = 1, int $pageSize = 200): array
{
    // 转义特殊字符
    $escapedTerm = $this->escapeSearchTerm($clean);
    
    // 使用通配符包装,支持部分匹配
    $pattern = "*{$escapedTerm}*";
    
    // 根据权限过滤隐藏内容
    if (!$includeHidden) {
        $topicPattern = "({$pattern}) @is_hidden:{0}";
    }
}

2. 多维度排序算法

搜索结果采用智能排序策略:

// 1. 按相关性得分排序(TF-IDF算法)
// 2. 相同得分时按创建时间倒序
usort($allDocs, function($a, $b) {
    // 先比较相关性得分(命中次数越多得分越高)
    $scoreDiff = $b['_score'] <=> $a['_score'];
    if ($scoreDiff !== 0) {
        return $scoreDiff;
    }
    
    // 得分相同时,按创建时间降序(最新的在前)
    $timeA = (int)($a['created_at'] ?? 0);
    $timeB = (int)($b['created_at'] ?? 0);
    return $timeB <=> $timeA;
});

3. 权重分配策略

不同字段的权重设计体现了业务优先级:

  • Topic: title(5) > author(1.5) > content(1)
  • Reply: content(2) > author(1)
  • User: nickname(3) > username(2)

🚀 全量重建机制

系统提供了完整的索引重建脚本 rebuildsearchindex.php

1. 重建流程

// Step 1: 清理旧文档
$searchPattern = 'search:*';
do {
    $keys = $redis->scan($it, $searchPattern, 200);
    foreach ($keys as $k) {
        $redis->del($k);
        $countDel++;
    }
} while ($it !== 0);

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

// Step 3: 扫描并索引所有数据
$stmt = $pdo->query("SELECT * FROM topics ORDER BY created_at DESC");
foreach ($stmt->fetchAll() as $topicData) {
    $searchService->indexTopic($topicArray);
}

2. 性能优化

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

💾 缓存策略

1. 搜索结果缓存

// 生成缓存键
$cacheKey = 'search:cache:' . md5($clean . $scope . $page . $pageSize . ($includeHidden ? '1' : '0'));

// 缓存搜索结果(TTL 60秒,平衡实时性与性能)
$this->redis->setex($cacheKey, 60, json_encode($results));

2. 前缀统一管理

所有Redis键都通过RedisManager统一管理前缀:

// config.php中的配置
'redis' => [
    'prefix' => 'zhichai:'
]

// RedisManager自动应用前缀
$redis->setOption(Redis::OPT_PREFIX, 'zhichai:');

🛡️ 容错处理

1. 超时保护

try {
    // Redis操作依赖连接的默认超时
    $this->redis->hMSet($docKey, $hash);
} catch (\RedisException $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
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>
    </div>
    
    <!-- 搜索范围选择 -->
    <div class="col-md-3">
        <select name="scope" class="form-select">
            <option value="all">全部</option>
            <option value="topic">主题</option>
            <option value="reply">回复</option>
            <option value="user">用户</option>
        </select>
    </div>
    
    <!-- 搜索按钮 -->
    <div class="col-md-3">
        <button type="submit" class="btn btn-primary w-100">
            <i class="bi bi-search"></i> 搜索
        </button>
    </div>
</form>

第二阶段:路由分发与控制器初始化

1. 路由注册

index.php中的路由配置:
// 全站搜索路由
$router->get('/search', function(RequestContext $context) use ($searchController) {
    $searchController->handleSearch($context);
});

2. 控制器依赖注入

SearchController通过DI容器实例化:
public function __construct()
{
    $this->searchService = new SearchService();
    $this->topicService = new TopicService();
    $this->replyService = new ReplyService(); 
    $this->userService = new UserService();
    $this->sessionManager = SessionManager::getInstance();
}

第三阶段:请求参数处理与权限验证

1. 参数解析与验证

public function handleSearch(RequestContext $context): void
{
    // 提取并验证搜索参数
    $q = $_GET['q'] ?? '';                    // 搜索关键词
    $scope = $_GET['scope'] ?? 'all';         // 搜索范围
    $page = max(1, (int)($_GET['page'] ?? 1)); // 页码(最小为1)
    $pageSize = 200;                          // 每页结果数(符合项目配置)
}

2. 用户身份与权限验证

// 获取当前用户
$isLoggedIn = $this->sessionManager->isLoggedIn();
$currentUserId = $this->sessionManager->getUserId();
$currentUser = null;
if ($isLoggedIn) {
    $currentUser = $this->userService->getUserByID($currentUserId);
}

// 检查管理员权限(决定是否显示隐藏内容)
$includeHidden = $currentUser && $currentUser->isAdminOrSuperAdmin();

第四阶段:核心搜索服务处理

1. 缓存策略检查

SearchService首先检查结果缓存:
// 生成缓存键
$cacheKey = 'search:cache:' . md5($clean . $scope . $page . $pageSize . ($includeHidden ? '1' : '0'));

// 尝试从缓存获取结果
$cachedResult = $this->redis->get($cacheKey);
if ($cachedResult !== false && $cachedResult !== null) {
    $decoded = json_decode($cachedResult, true);
    if ($decoded !== null) {
        return $decoded; // 缓存命中,直接返回
    }
}

2. RediSearch索引查询

// 确保索引存在
$this->ensureIndexes();

// 构建搜索模式
$escapedTerm = $this->escapeSearchTerm($clean);
$pattern = "*{$escapedTerm}*"; // 支持部分匹配

// 根据权限过滤隐藏内容
if (!$includeHidden) {
    $topicPattern = "({$pattern}) @is_hidden:{0}";
}

// 执行多索引搜索
$results = [];
if ($scope === 'topic' || $scope === 'all') {
    $results['topics'] = $this->execSearch('idx:topic', $topicPattern, $offset, $pageSize);
}
if ($scope === 'reply' || $scope === 'all') {
    $results['replies'] = $this->execSearch('idx:reply', $replyPattern, $offset, $pageSize);
}
if ($scope === 'user' || $scope === 'all') {
    $results['users'] = $this->execSearch('idx:user', $pattern, $offset, $pageSize);
}

3. 智能排序算法

private function execSearch(string $index, string $pattern, int $offset, int $limit): array
{
    // 执行RediSearch查询并获取相关性得分
    $raw = $this->redis->rawCommand(
        'FT.SEARCH',
        $index,
        $pattern,
        'WITHSCORES',         // 返回相关性得分
        'LIMIT', 0, 1000      // 先获取更多结果用于排序
    );
    
    // 智能排序:相关性得分 + 时间排序
    usort($allDocs, function($a, $b) {
        // 1. 按相关性得分降序(命中次数越多得分越高)
        $scoreDiff = $b['_score'] <=> $a['_score'];
        if ($scoreDiff !== 0) {
            return $scoreDiff;
        }
        
        // 2. 得分相同时按创建时间降序(最新的在前)
        $timeA = (int)($a['created_at'] ?? 0);
        $timeB = (int)($b['created_at'] ?? 0);
        return $timeB <=> $timeA;
    });
    
    // 应用分页
    $docs = array_slice($allDocs, $offset, $limit);
    return ['total' => $total, 'docs' => $docs];
}

第五阶段:数据重构与业务对象获取

1. ID提取与验证

由于RediSearch返回的是索引键,需要提取实际的业务ID:
// 处理Topic结果
if (isset($rawResults['topics'])) {
    $topics = [];
    foreach ($rawResults['topics']['docs'] as $doc) {
        // 从键中提取ID(格式:zhichai:search:topic:ID)
        $key = $doc['_key'];
        if (preg_match('/:(\d+)$/', $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三种类型的搜索结果进行统一处理:
// Reply结果处理
if (isset($rawResults['replies'])) {
    $replies = [];
    foreach ($rawResults['replies']['docs'] as $doc) {
        $key = $doc['_key'];
        if (preg_match('/:(\d+)$/', $key, $matches)) {
            $replyId = (int)$matches[1];
            $reply = $this->replyService->getReplyByID($replyId);
            if ($reply) {
                $replies[] = $reply;
            }
        }
    }
    $searchResults['replies'] = [
        'total' => $rawResults['replies']['total'],
        'items' => $replies
    ];
}

// User结果处理
if (isset($rawResults['users'])) {
    $users = [];
    foreach ($rawResults['users']['docs'] as $doc) {
        $key = $doc['_key'];
        if (preg_match('/:(\d+)$/', $key, $matches)) {
            $userId = (int)$matches[1];
            $user = $this->userService->getUserByID($userId);
            if ($user) {
                $users[] = $user;
            }
        }
    }
    $searchResults['users'] = [
        'total' => $rawResults['users']['total'],
        'items' => $users
    ];
}

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

1. 模板数据组装

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. 分页计算逻辑

// 计算总页数
$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. 结果分类展示

搜索结果按类型分别展示,每种类型使用不同的颜色主题:
<!-- 主题结果 -->
<div class="card-header bg-primary text-white">
    <h5 class="mb-0">
        <i class="bi bi-chat-square-text"></i> 
        主题 (<?= $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'] ?>)
    </h5>
</div>

<!-- 用户结果 -->
<div class="card-header bg-info text-white">
    <h5 class="mb-0">
        <i class="bi bi-person"></i> 
        用户 (<?= $data['results']['users']['total'] ?>)
    </h5>
</div>

2. 详细信息卡片

每个搜索结果都展示丰富的上下文信息:
<!-- Topic结果卡片 -->
<a href="/topic/<?= $topic->id ?>" class="list-group-item list-group-item-action">
    <div class="d-flex w-100 justify-content-between">
        <h6 class="mb-1">
            <?= htmlspecialchars($topic->title) ?>
            <!-- 状态标签 -->
            <?php if ($topic->is_closed): ?>
                <span class="badge bg-warning">已关闭</span>
            <?php endif; ?>
            <?php if ($topic->is_hidden): ?>
                <span class="badge bg-danger">已隐藏</span>
            <?php endif; ?>
        </h6>
        <small class="text-muted"><?= htmlspecialchars($topic->created_at) ?></small>
    </div>
    <p class="mb-1 text-muted">
        <?= htmlspecialchars(mb_substr(strip_tags($topic->content), 0, 150)) ?>...
    </p>
    <small class="text-muted">
        作者: <?= htmlspecialchars($topic->author_nickname ?: $topic->author_username) ?> | 
        回复: <?= $topic->reply_count ?> | 
        浏览: <?= $topic->view_count ?>
    </small>
</a>

3. 智能分页导航

<nav aria-label="搜索结果分页">
    <ul class="pagination justify-content-center">
        <?php if ($data['page'] > 1): ?>
            <li class="page-item">
                <a class="page-link" href="/search?q=<?= urlencode($_GET['q']) ?>&scope=<?= $data['scope'] ?>&page=<?= $data['page'] - 1 ?>">
                    上一页
                </a>
            </li>
        <?php endif; ?>

        <?php for ($i = max(1, $data['page'] - 2); $i <= min($data['totalPages'], $data['page'] + 2); $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><?= $data['q'] ?></strong>" 相关的结果
</div>

3. 管理员特殊权限

管理员用户可以搜索到隐藏的内容,普通用户则会被自动过滤:
// 根据权限过滤隐藏内容
if (!$includeHidden) {
    $topicPattern = "({$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. 功能完备: 支持多类型搜索、分页、状态过滤
这套搜索系统不仅满足了当前的功能需求,还为未来的扩展(如高级搜索语法、搜索建议、搜索历史等)预留了充分的架构空间。