← Back to Blog
EN中文

索引合并时的并发锁策略博弈

在搜索引擎的生命周期中,"合并"(Merge)是一个持续发生的后台过程。当合并完成时,系统需要将旧的索引段替换为新的、合并后的段。这个切换过程看似简单,但在高并发读取的场景下却暗藏杀机。

如果不加锁,可能导致读取到不一致的数据或段文件被过早删除;如果锁的粒度太大或时间太长,又会造成查询请求的 "抖动"(Jitter)甚至停顿。

今天我们来探讨几种常见的锁策略,并用现代 C++ (C++20) 模拟一种 "Late-Locking"(延迟加锁/OnSwitch)策略,展示如何在保证数据安全的前提下最大化系统吞吐量。

策略博弈:OnStart vs OnSwitch

在设计合并锁时,我们通常有两种主要选择:

  1. OnStart 策略(悲观锁)

    • 在合并任务开始时就获取锁,防止其他进程或线程修改这些段。
    • 优点:实现简单,安全性高。
    • 缺点:并发度极低。合并是一个耗时过程(可能持续几分钟),长时间持有锁会阻塞所有的结构变更操作。
  2. OnSwitch 策略(乐观/延迟锁)

    • 合并的大部分时间(读取旧段、写入新段到临时目录)不持有锁。
    • 仅在最后一步 "切换指针"(Flip)的瞬间获取写锁。
    • 优点:极大地减少了临界区时间,几乎不影响查询。
    • 缺点:实现复杂。如果在合并过程中旧段被其他任务删除了怎么办?需要复杂的引用计数或版本检查机制。

现代 C++ 实现演示:RAII 与 延迟加锁

我们将使用 C++20 的 std::shared_mutex 来模拟读写锁,并演示如何实现一个安全的 OnSwitch 切换逻辑。

#include <iostream>
#include <vector>
#include <string>
#include <thread>
#include <mutex>
#include <shared_mutex>
#include <chrono>
#include <atomic>
#include <memory>

// 模拟一个索引段
struct Segment {
    std::string id;
    size_t doc_count;
};

// 搜索系统的核心状态
class SearchIndex {
    std::vector<Segment> active_segments;
    mutable std::shared_mutex index_mutex; // 读写锁

public:
    SearchIndex() {
        active_segments = {{"seg_1", 100}, {"seg_2", 200}};
    }

    // 模拟查询操作(读者):需要共享锁
    void search(const std::string& query) const {
        std::shared_lock lock(index_mutex);
        std::cout << "[Query] Searching for '" << query << "' in segments: ";
        for (const auto& seg : active_segments) {
            std::cout << seg.id << " ";
        }
        std::cout << std::endl;
        // 模拟耗时查询
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }

    // 获取当前段的快照(为了合并准备)
    std::vector<Segment> get_segments_snapshot() const {
        std::shared_lock lock(index_mutex);
        return active_segments;
    }

    // 关键:原子切换(OnSwitch 策略)
    // 只在最后这一步加独占锁
    bool apply_merge_result(const std::vector<std::string>& old_ids, const Segment& new_merged_segment) {
        std::unique_lock lock(index_mutex); // 获取写锁

        // 再次检查(Double-Checked Locking 的变体):
        // 确保旧段依然存在,没有被其他线程删除
        for (const auto& old_id : old_ids) {
            bool found = false;
            for (const auto& seg : active_segments) {
                if (seg.id == old_id) {
                    found = true;
                    break;
                }
            }
            if (!found) {
                std::cerr << "[Merge] Failed! Target segments disappeared." << std::endl;
                return false;
            }
        }

        // 执行切换:移除旧段,添加新段
        std::cout << "[Merge] Critical Section: Swapping segments..." << std::endl;
        auto it = active_segments.begin();
        while (it != active_segments.end()) {
            bool is_old = false;
            for (const auto& old_id : old_ids) {
                if (it->id == old_id) {
                    is_old = true;
                    break;
                }
            }
            if (is_old) {
                it = active_segments.erase(it);
            } else {
                ++it;
            }
        }
        active_segments.push_back(new_merged_segment);
        return true;
    }
};

// 模拟后台合并任务
void background_merge_task(SearchIndex& index) {
    // 1. 准备阶段(无锁或短锁)
    auto snapshot = index.get_segments_snapshot();
    std::vector<std::string> target_ids;
    for (const auto& seg : snapshot) target_ids.push_back(seg.id);

    std::cout << "[Merge] Background merging started for " << target_ids.size() << " segments..." << std::endl;
    
    // 模拟耗时的合并计算(不持有任何锁!)
    std::this_thread::sleep_for(std::chrono::milliseconds(200)); 
    Segment new_seg{"seg_merged_v1", 300};

    // 2. 切换阶段(OnSwitch - 短暂写锁)
    if (index.apply_merge_result(target_ids, new_seg)) {
        std::cout << "[Merge] Success! Index updated." << std::endl;
    }
}

int main() {
    SearchIndex index;

    // 启动后台合并线程
    std::jthread merger(background_merge_task, std::ref(index));

    // 模拟前台高并发查询
    for (int i = 0; i < 5; ++i) {
        index.search("user_query_" + std::to_string(i));
        std::this_thread::sleep_for(std::chrono::milliseconds(60));
    }

    return 0;
}

代码解读

在这个 C++ 示例中,我们展示了 OnSwitch 策略的精髓:

  1. 无锁计算background_merge_task 中的大部分时间(模拟的 sleep_for)是不持有锁的。这意味着在合并进行时,查询线程(search)可以畅通无阻地运行。
  2. 原子切换apply_merge_result 方法中使用了 std::unique_lock。这是整个过程中唯一会阻塞读取的地方,但它执行的操作非常快(仅涉及内存中的 vector 操作),通常在微秒级别。
  3. 安全性检查:在获取写锁后,我们必须再次验证环境状态("Double-Check")。因为在合并计算的这段无锁时间内,外部世界可能已经变了(例如管理员可能删除了某个旧索引)。

总结

在高并发系统的设计中,锁不仅是为了安全,更是关于性能的艺术。OnSwitch (Late-Locking) 策略通过最小化临界区范围,极大地提升了系统的并发能力。

虽然实现起来比全局大锁要复杂得多——你需要处理状态失效、版本冲突等问题——但在追求极致低延迟的搜索系统中,这种复杂性是值得的。现代 C++ 提供的 shared_mutex 和 RAII 机制,让我们可以更优雅、更安全地表达这些复杂的同步逻辑。