系统设计实战 176:176. 设计语法检查工具(Grammarly)- Part 2:核心算法与高级功能

4 阅读8分钟

🚀 系统设计实战 176:176. 设计语法检查工具(Grammarly)- Part 2:核心算法与高级功能

摘要:本文深入剖析系统的核心架构关键算法工程实践,提供完整的设计方案和面试要点。

你是否想过,设计语法检查工具背后的技术挑战有多复杂?

本文件是 176-SystemDesign-GrammarChecker.md 的补充,详细介绍核心NLP算法和高级功能实现。

核心NLP引擎

1. 语法分析器

// 时间复杂度:O(N),空间复杂度:O(1)

type GrammarAnalyzer struct {
    tokenizer     *Tokenizer
    parser        *SyntaxParser
    ruleEngine    *GrammarRuleEngine
    mlModel       *GrammarMLModel
    cache         *AnalysisCache
}

type GrammarIssue struct {
    ID          string
    Type        IssueType
    Severity    Severity
    StartPos    int
    EndPos      int
    Message     string
    Suggestions []string
    Rule        string
    Confidence  float64
}

func (ga *GrammarAnalyzer) Analyze(text string) ([]*GrammarIssue, error) {
    // 1. 分词
    tokens := ga.tokenizer.Tokenize(text)
    
    // 2. 句法分析
    syntaxTree := ga.parser.Parse(tokens)
    
    // 3. 基于规则的检查
    ruleIssues := ga.ruleEngine.Check(syntaxTree)
    
    // 4. 基于ML的检查
    mlIssues := ga.mlModel.Predict(tokens, syntaxTree)
    
    // 5. 合并和去重
    allIssues := ga.mergeAndDeduplicate(ruleIssues, mlIssues)
    
    // 6. 按置信度排序
    sort.Slice(allIssues, func(i, j int) bool {
        return allIssues[i].Confidence > allIssues[j].Confidence
    })
    
    return allIssues, nil
}

### 2. 拼写检查器
```go
type SpellChecker struct {
    dictionary    *TrieDictionary
    editDistance   *EditDistanceCalculator
    contextModel  *ContextualSpellModel
    userDictionary map[string]map[string]bool
}

type TrieDictionary struct {
    root     *TrieNode
    wordCount int
}

type TrieNode struct {
    children map[rune]*TrieNode
    isWord   bool
    frequency int
}

func (sc *SpellChecker) Check(word string, context []string) ([]*SpellSuggestion, error) {
    // 1. 检查用户自定义词典
    if sc.isInUserDictionary(word) {
        return nil, nil
    }
    
    // 2. 检查主词典
    if sc.dictionary.Contains(strings.ToLower(word)) {
        return nil, nil
    }
    
    // 3. 生成候选词(编辑距离<=2)
    candidates := sc.editDistance.FindCandidates(word, 2)
    
    // 4. 上下文排序
    rankedCandidates := sc.contextModel.RankCandidates(candidates, context)
    
    // 5. 返回建议
    suggestions := make([]*SpellSuggestion, 0, len(rankedCandidates))
    for _, candidate := range rankedCandidates {
        suggestions = append(suggestions, &SpellSuggestion{
            Word:       candidate.Word,
            Score:      candidate.Score,
            EditDistance: candidate.Distance,
        })
    }
    
    return suggestions, nil
}

func (ed *EditDistanceCalculator) FindCandidates(word string, maxDistance int) []*Candidate {
    var candidates []*Candidate
    
    // BFS搜索编辑距离内的所有词
    queue := []struct {
        node     *TrieNode
        word     string
        pos      int
        distance int
    }{{ed.dictionary.root, "", 0, 0}}
    
    for len(queue) > 0 {
        current := queue[0]
        queue = queue[1:]
        
        if current.distance > maxDistance {
            continue
        }
        
        if current.node.isWord && current.pos == len(word) {
            candidates = append(candidates, &Candidate{
                Word:     current.word,
                Distance: current.distance,
                Frequency: current.node.frequency,
            })
        }
        
        // 插入操作
        for ch, child := range current.node.children {
            queue = append(queue, struct {
                node     *TrieNode
                word     string
                pos      int
                distance int
            }{child, current.word + string(ch), current.pos, current.distance + 1})
        }
        
        if current.pos < len(word) {
            // 匹配操作
            ch := rune(word[current.pos])
            if child, exists := current.node.children[ch]; exists {
                queue = append(queue, struct {
                    node     *TrieNode
                    word     string
                    pos      int
                    distance int
                }{child, current.word + string(ch), current.pos + 1, current.distance})
            }
            
            // 替换操作
            for ch, child := range current.node.children {
                if ch != rune(word[current.pos]) {
                    queue = append(queue, struct {
                        node     *TrieNode
                        word     string
                        pos      int
                        distance int
                    }{child, current.word + string(ch), current.pos + 1, current.distance + 1})
                }
            }
            
            // 删除操作
            queue = append(queue, struct {
                node     *TrieNode
                word     string
                pos      int
                distance int
            }{current.node, current.word, current.pos + 1, current.distance + 1})
        }
    }
    
    return candidates
}

### 3. 风格分析器
```go
type StyleAnalyzer struct {
    readabilityCalculator *ReadabilityCalculator
    toneDetector          *ToneDetector
    wordChoiceAnalyzer    *WordChoiceAnalyzer
    sentenceAnalyzer      *SentenceAnalyzer
}

type StyleReport struct {
    ReadabilityScore  float64
    ReadabilityGrade  string
    Tone              string
    ToneConfidence    float64
    WordChoiceIssues  []*WordChoiceIssue
    SentenceIssues    []*SentenceIssue
    OverallScore      float64
}

func (sa *StyleAnalyzer) Analyze(text string, documentType string) (*StyleReport, error) {
    report := &StyleReport{}
    
    // 1. 可读性分析
    report.ReadabilityScore = sa.readabilityCalculator.Calculate(text)
    report.ReadabilityGrade = sa.readabilityCalculator.GetGrade(report.ReadabilityScore)
    
    // 2. 语调检测
    tone, confidence := sa.toneDetector.Detect(text)
    report.Tone = tone
    report.ToneConfidence = confidence
    
    // 3. 词汇选择分析
    report.WordChoiceIssues = sa.wordChoiceAnalyzer.Analyze(text, documentType)
    
    // 4. 句子结构分析
    report.SentenceIssues = sa.sentenceAnalyzer.Analyze(text)
    
    // 5. 综合评分
    report.OverallScore = sa.calculateOverallScore(report)
    
    return report, nil
}

type ReadabilityCalculator struct{}

func (rc *ReadabilityCalculator) Calculate(text string) float64 {
    sentences := rc.countSentences(text)
    words := rc.countWords(text)
    syllables := rc.countSyllables(text)
    
    if sentences == 0 || words == 0 {
        return 0
    }
    
    // Flesch-Kincaid可读性公式
    score := 206.835 - 
             1.015*(float64(words)/float64(sentences)) - 
             84.6*(float64(syllables)/float64(words))
    
    // 限制在0-100范围内
    if score < 0 {
        score = 0
    }
    if score > 100 {
        score = 100
    }
    
    return score
}

### 4. 实时检查引擎
```go
type RealTimeCheckEngine struct {
    grammarAnalyzer *GrammarAnalyzer
    spellChecker    *SpellChecker
    styleAnalyzer   *StyleAnalyzer
    debouncer       *Debouncer
    diffCalculator  *DiffCalculator
    cache           *CheckCache
}

type CheckRequest struct {
    DocumentID  string
    Text        string
    CursorPos   int
    Language    string
    DocType     string
    UserID      string
}

type CheckResponse struct {
    Issues      []*GrammarIssue
    Suggestions []*SpellSuggestion
    StyleReport *StyleReport
    ProcessTime time.Duration
}

func (rtce *RealTimeCheckEngine) Check(req *CheckRequest) (*CheckResponse, error) {
    startTime := time.Now()
    
    // 1. 计算文本差异(增量检查)
    diff := rtce.diffCalculator.CalculateDiff(req.DocumentID, req.Text)
    
    // 2. 确定需要检查的范围
    checkRange := rtce.determineCheckRange(diff, req.CursorPos)
    textToCheck := req.Text[checkRange.Start:checkRange.End]
    
    // 3. 检查缓存
    cacheKey := rtce.generateCacheKey(textToCheck, req.Language)
    if cached := rtce.cache.Get(cacheKey); cached != nil {
        return cached.(*CheckResponse), nil
    }
    
    // 4. 并行执行检查
    var wg sync.WaitGroup
    var grammarIssues []*GrammarIssue
    var spellSuggestions []*SpellSuggestion
    var styleReport *StyleReport
    
    wg.Add(3)
    
    go func() {
        defer wg.Done()
        grammarIssues, _ = rtce.grammarAnalyzer.Analyze(textToCheck)
    }()
    
    go func() {
        defer wg.Done()
        words := strings.Fields(textToCheck)
        for _, word := range words {
            suggestions, _ := rtce.spellChecker.Check(word, words)
            if len(suggestions) > 0 {
                spellSuggestions = append(spellSuggestions, suggestions...)
            }
        }
    }()
    
    go func() {
        defer wg.Done()
        styleReport, _ = rtce.styleAnalyzer.Analyze(textToCheck, req.DocType)
    }()
    
    wg.Wait()
    
    // 5. 调整位置偏移
    rtce.adjustPositions(grammarIssues, checkRange.Start)
    
    response := &CheckResponse{
        Issues:      grammarIssues,
        Suggestions: spellSuggestions,
        StyleReport: styleReport,
        ProcessTime: time.Since(startTime),
    }
    
    // 6. 缓存结果
    rtce.cache.Set(cacheKey, response, time.Minute*5)
    
    return response, nil
}

### 5. 浏览器插件架构
```go
type BrowserExtension struct {
    contentScript  *ContentScript
    backgroundWorker *BackgroundWorker
    apiClient      *APIClient
    localCache     *LocalCache
    config         *ExtensionConfig
}

type ContentScript struct {
    observer       *MutationObserver
    highlighter    *IssueHighlighter
    popupManager   *SuggestionPopupManager
    debounceTimer  *time.Timer
    debounceDelay  time.Duration
}

func (cs *ContentScript) OnTextChange(element *DOMElement, newText string) {
    // 防抖处理
    if cs.debounceTimer != nil {
        cs.debounceTimer.Stop()
    }
    
    cs.debounceTimer = time.AfterFunc(cs.debounceDelay, func() {
        // 发送检查请求到后台工作线程
        cs.backgroundWorker.SendMessage(&CheckMessage{
            ElementID: element.ID,
            Text:      newText,
            URL:       getCurrentURL(),
        })
    })
}

func (cs *ContentScript) OnCheckResult(result *CheckResponse) {
    // 高亮显示问题
    for _, issue := range result.Issues {
        cs.highlighter.Highlight(issue)
    }
    
    // 更新建议弹窗
    cs.popupManager.UpdateSuggestions(result)
}

type BackgroundWorker struct {
    apiClient   *APIClient
    localModel  *LocalGrammarModel
    offlineMode bool
    queue       chan *CheckMessage
}

func (bw *BackgroundWorker) ProcessMessage(msg *CheckMessage) {
    var result *CheckResponse
    var err error
    
    if bw.offlineMode {
        // 离线模式使用本地模型
        result, err = bw.localModel.Check(msg.Text)
    } else {
        // 在线模式调用API
        result, err = bw.apiClient.Check(msg.Text)
        if err != nil {
            // 降级到本地模型
            result, err = bw.localModel.Check(msg.Text)
        }
    }
    
    if err == nil {
        // 发送结果到内容脚本
        bw.sendToContentScript(msg.ElementID, result)
    }
}

🏗️ 系统架构

整体架构图

┌──────────┐     ┌─────────────────────────────────────────┐
│          │     │              API 网关层                   │
│  客户端   │────→│  认证鉴权 → 限流熔断 → 路由转发 → 负载均衡  │
│          │     └──────────────────┬──────────────────────┘
└──────────┘                       │
                    ┌──────────────┼──────────────┐
                    ▼              ▼              ▼
             ┌───────────┐  ┌───────────┐  ┌───────────┐
             │  核心服务   │  │  业务服务   │  │  基础服务   │
             │           │  │           │  │           │
             │ • 核心逻辑 │  │ • 业务流程 │  │ • 用户管理 │
             │ • 数据处理 │  │ • 规则引擎 │  │ • 通知推送 │
             └─────┬─────┘  └─────┬─────┘  └─────┬─────┘
                   │              │              │
         ┌─────────┴──────────────┴──────────────┴─────────┐
         │                    数据层                         │
         │  ┌─────────┐  ┌─────────┐  ┌─────────┐         │
         │  │  MySQL   │  │  Redis  │  │  MQ     │         │
         │  │  主从集群 │  │  集群   │  │  Kafka  │         │
         │  └─────────┘  └─────────┘  └─────────┘         │
         └─────────────────────────────────────────────────┘

数据流说明

  1. 客户端请求经 API 网关统一入口,完成认证、限流、路由
  2. 请求分发到对应的微服务处理业务逻辑
  3. 服务间通过 RPC 同步调用或 MQ 异步通信
  4. 数据持久化到 MySQL,热点数据缓存到 Redis

性能优化

增量检查

  • 只检查用户修改的文本段落,而非整篇文档
  • 使用diff算法计算变更范围
  • 缓存未变更部分的检查结果

模型优化

  • 使用量化模型减少推理时间
  • 本地轻量模型处理常见错误
  • 复杂问题异步发送到云端处理

缓存策略

  • 常见短语和句式的检查结果缓存
  • 用户个人词典的本地缓存
  • 规则引擎结果的LRU缓存

语法检查工具通过NLP引擎、机器学习模型和规则引擎的协同工作,为用户提供实时、准确的写作辅助。

🎯 场景引入

你打开App,

你打开手机准备使用设计语法检查工具服务。看似简单的操作背后,系统面临三大核心挑战:

  • 挑战一:高并发——如何在百万级 QPS 下保持低延迟?
  • 挑战二:高可用——如何在节点故障时保证服务不中断?
  • 挑战三:数据一致性——如何在分布式环境下保证数据正确?


📈 容量估算

假设 DAU 1000 万,人均日请求 50 次

指标数值
数据总量10 TB+
日写入量~100 GB
写入 TPS~5 万/秒
读取 QPS~20 万/秒
P99 读延迟< 10ms
节点数10-50
副本因子3

❓ 高频面试问题

Q1:语法检查工具的核心设计原则是什么?

参考正文中的架构设计部分,核心原则包括:高可用(故障自动恢复)、高性能(低延迟高吞吐)、可扩展(水平扩展能力)、一致性(数据正确性保证)。面试时需结合具体场景展开。

Q2:语法检查工具在大规模场景下的主要挑战是什么?

  1. 性能瓶颈:随着数据量和请求量增长,单节点无法承载;2) 一致性:分布式环境下的数据一致性保证;3) 故障恢复:节点故障时的自动切换和数据恢复;4) 运维复杂度:集群管理、监控、升级。

Q3:如何保证语法检查工具的高可用?

  1. 多副本冗余(至少 3 副本);2) 自动故障检测和切换(心跳 + 选主);3) 数据持久化和备份;4) 限流降级(防止雪崩);5) 多机房/多活部署。

Q4:语法检查工具的性能优化有哪些关键手段?

  1. 缓存(减少重复计算和 IO);2) 异步处理(非关键路径异步化);3) 批量操作(减少网络往返);4) 数据分片(并行处理);5) 连接池复用。

Q5:语法检查工具与同类方案相比有什么优劣势?

参考方案对比表格。选型时需考虑:团队技术栈、数据规模、延迟要求、一致性需求、运维成本。没有银弹,需根据业务场景权衡取舍。



| 方案一 | 简单实现 | 低 | 适合小规模 | | 方案二 | 中等复杂度 | 中 | 适合中等规模 | | 方案三 | 高复杂度 ⭐推荐 | 高 | 适合大规模生产环境 |

🚀 架构演进路径

阶段一:单机版 MVP(用户量 < 10 万)

  • 单体应用 + 单机数据库
  • 功能验证优先,快速迭代
  • 适用场景:产品早期验证

阶段二:基础版分布式(用户量 10 万 - 100 万)

  • 应用层水平扩展(无状态服务 + 负载均衡)
  • 数据库主从分离(读写分离)
  • 引入 Redis 缓存热点数据
  • 适用场景:业务增长期

阶段三:生产级高可用(用户量 > 100 万)

  • 微服务拆分,独立部署和扩缩容
  • 数据库分库分表(按业务维度分片)
  • 引入消息队列解耦异步流程
  • 多机房部署,异地容灾
  • 全链路监控 + 自动化运维

✅ 架构设计检查清单

检查项状态说明
高可用多副本部署,自动故障转移,99.9% SLA
可扩展无状态服务水平扩展,数据层分片
数据一致性核心路径强一致,非核心最终一致
安全防护认证授权 + 加密 + 审计日志
监控告警Metrics + Logging + Tracing 三支柱
容灾备份多机房部署,定期备份,RPO < 1 分钟
性能优化多级缓存 + 异步处理 + 连接池
灰度发布支持按用户/地域灰度,快速回滚

⚖️ 关键 Trade-off 分析

🔴 Trade-off 1:一致性 vs 可用性

  • 强一致(CP):适用于金融交易等不能出错的场景
  • 高可用(AP):适用于社交动态等允许短暂不一致的场景
  • 本系统选择:核心路径强一致,非核心路径最终一致

🔴 Trade-off 2:同步 vs 异步

  • 同步处理:延迟低但吞吐受限,适用于核心交互路径
  • 异步处理:吞吐高但增加延迟,适用于后台计算
  • 本系统选择:核心路径同步,非核心路径异步