使用 chDB 分析温布尔登网球数据

图片

本文字数:6865;估计阅读时间:18 分钟

作者:Mark Needham

本文在公众号【ClickHouseInc】首发

图片

2025 年在罗兰加洛斯(Roland Garros)举行的 Sinner 对阵 Alcaraz 的决赛,充分体现了网球比赛那种细微的胜负边界。Sinner 在第四盘曾一度距离胜利只差一分,手握三个冠军点,但最终却被 Alcaraz 逆转,如 Andy Marshall 的精彩动画中所呈现的那样[https://x.com/AndyMarshall86/status/1931993645378658533]。

当我观看 Carlos Alcaraz 在温布尔登首轮比赛时,又回想起了那段动画。我当时直觉上觉得,虽然比赛打到了五盘,但 Alcaraz 并没有真正面临输球的危险。不过,我还是想看看数据能否支持这种直觉。

起初我打算借助 AI 编码助手(AI coding assistants)来完成一些分析,但很快就遇到了障碍。Claude 和 ChatGPT 似乎都无法理解网球独特的计分方式,它们生成的代码无法直接运行,即便我提供了额外提示,依然让我越做越挫败,效率极低。

原本打算用 Python 写写看,但后来转念一想,为什么不直接用 ClickHouse 来分析呢?以下就是我最终做出的成果:

图片

结论:Alcaraz 实际上并没有真正处于输球的危险中!

网球计分规则详解

在开始介绍我是如何实现数据分析之前,我们先快速回顾一下网球的计分规则。

一场比赛采用三盘两胜或五盘三胜的赛制,也就是必须赢下 2 盘或 3 盘才能获胜。在大满贯赛事中,女子通常打三盘两胜,男子则是五盘三胜。

每一盘先赢下 6 局的选手获胜。但如果双方都打到 5–5,就需要拿到 7 局才能赢盘。如果双方都来到 6–6,就会进入抢七局。抢七时,先拿到 7 分就能赢,除非是决胜盘,那就需要先拿 10 分,并且在这两种情况下都必须领先对手至少 2 分。

接下来说说稍微复杂一点的单局计分:

每局从 0–0 开始,由一名球员发球。比分的递增顺序是:

  • 第一分:15

  • 第二分:30

  • 第三分:40

在 40 之后再赢下一分就可以赢下该局,但前提是领先 2 分。

待翻译:What Happens at 40–40 (Deuce)?

如果比分来到 40–40(称为平分,Deuce),那么需要连续赢下 2 分才能拿下该局:

  • 赢一分 → 获得占先(Ad)

  • 再赢一分 → 赢得该局

  • 输一分 → 回到平分
     

这个过程可能会多次反复,直到有人连续拿下两分。

clickhouse-local 简要介绍

了解完计分规则后,就可以开始在 ClickHouse 中搭建分析。ClickHouse 最常见的运行方式是通过 ClickHouse Server(标准客户端/服务器架构)。

但如果只是做这样一个小项目,还要运行服务器再连接客户端就显得过于繁琐。好在 ClickHouse 提供了 clickhouse-local,这是一款独立的命令行工具,不需要单独部署服务端,就能具备 ClickHouse 的完整功能。

你可以像平时一样下载 ClickHouse:

curl https://clickhouse.com  | sh

然后直接启动 clickhouse-local:

./clickhouse -mn --path wimbledon.chdb

再通过指定 path(文件路径)参数,就能把插入的数据持久化到本地磁盘。

编写一个函数来计算赢得一局所需的分数

当 ClickHouse 配置完成后,就可以着手编写一个函数,基于当前比分计算一名选手距离赢下该局还差多少分。

这个函数的最终版本如下:

CREATE OR REPLACE FUNCTION pointsToWinGame AS (p1Score, p2Score) -> 
  multiIf(
    p1Score = '40' AND p2Score = 'AD', 3,
    p1Score = '40' AND p2Score = '40', 2,
    p1Score = '40' AND (p2Score = '0' OR p2Score = '15' OR p2Score = '30'), 1,
    p1Score = '30' AND (p2Score = '0' OR p2Score = '15' OR p2Score = '30'), 2,
    p1Score = '30' AND p2Score = '40', 3,
    p1Score = '15' AND (p2Score = '0' OR p2Score = '15' OR p2Score = '30'), 3,
    p1Score = '15' AND p2Score = '40', 4,
    p1Score = '0' AND (p2Score = '0' OR p2Score = '15' OR p2Score = '30'), 4,
    p1Score = '0' AND p2Score = '40', 5,
    p1Score = 'AD', 1,
    0
    );

它会接收双方当前的单局得分,返回第一位选手离赢局还需要再拿下多少分。

举个例子,如果比分是 15–40,那么他需要连赢 4 分——先把比分追平到 40–40,然后再连下两分拿下该局。

这个函数可以像普通内置函数一样直接调用。我最初用不同的比分组合手动测试它:

SELECT
    p1 as player1_score,
    p2 as player2_score,
    pointsToWinGame(p1, p2) as points_to_win
FROM VALUES(
    'p1 String, p2 String',
    ('0', '0'), ('0', '15'), ('0', '30'),
    ('15', '30'),
    ('30', '15'),
    ('30', '40'),
    ('40', '30'),
    ('40', '40'),
    ('AD', '40'),
    ('0', '40'), ('15', '40'), ('30', '40'),
    ('40', 'AD')
);

对于这个简单函数来说问题不大,但当我后面编写更复杂的函数时,因为不同的计分情况,往往会不断出现 bug,需要调试修复。

所以我决定编写一些可以自动运行的测试用例,来保证函数的正确性。

在 chDB 中测试自定义函数

有一种方式是切换到 ClickHouse Server,并通过 ClickHouse 的客户端库去执行函数进行测试。

另一种方案是使用 chDB,这是一款由 ClickHouse 驱动、支持 Python、Go、Rust、NodeJS、Bun 等语言的内嵌式 SQL OLAP 引擎。

chDB 的一个亮点在于,它可以直接与 clickhouse-local 创建的数据库配合使用。配置整体也比较方便。

考虑到可测试的比分组合并不算太多,我就选择写一个完整的覆盖测试。我对 Python 的高级测试框架并不是特别熟悉,所以这里依旧选择了经典的 pytest。

下面是一个我编写的参数化测试,用来验证 pointsToWinGame 函数是否按照预期工作:

from chdb import session as chs
import pytest

sess = chs.Session("wimbledon.chdb")

@pytest.mark.parametrize("p1,p2,expected", [
  ("'0'", "'40'", 5),
  ("'0'", "'0'", 4),
  ("'0'", "'15'", 4),
  ("'0'", "'30'", 4),
  ("'15'", "'40'", 4),
  ("'15'", "'15'", 3),  
  ("'15'", "'30'", 3),
  ("'30'", "'40'", 3),
  ("'40'", "'AD'", 3),
  ("'30'", "'30'", 2),
  ("'40'", "'40'", 2),
  ("'40'", "'30'", 1),
  ("'40'", "'15'", 1),
  ("'40'", "'0'", 1),
  ("'AD'", "'40'", 1),
  
])
def test_points_to_win_normal_game(p1, p2, expected):
    result = sess.query(f"""
    SELECT pointsToWinGame({p1}, {p2}) as points
    """, "DataFrame")

    assert result["points"].values[0] == expected

在脚本的顶部,我初始化了 chDB 数据库,指向 wimbledon.chdb 目录,其中已经加载好所有的自定义函数。

再往下看,测试会接收三个参数:

  • p1:第一名选手当前的单局得分

  • p2:另一名选手当前的单局得分

  • expected:第一名选手距离赢局还需要的分数

随后就可以通过 uv(一个现代 Python 包管理器)来运行这些测试:

uv run --with chdb pytest test_game.py
test_game.py .......................                                                                                                                                                                                                                                                   [100%]

===================================================================================================================================== 23 passed in 0.97s =====================================================================================================================================

 我反复使用这样的编写函数加测试的工作流,来判断每盘以及剩余盘数中赢得比赛需要多少分。

除此之外还包含其他一些函数,但这里就不一一展开介绍了——你可以在 wimbledon-chdb 的 GitHub 仓库中查阅它们的定义和相应的测试代码[https://github.com/mneedham/wimbledon-chdb]。

如果需要查看这些函数的完整列表,我们可以写如下查询:

SELECT name
FROM system.functions
WHERE origin = 'SQLUserDefined';
┌─name─────────────────────┐
│ pointsToWinTiebreak      │
│ pointsToWinMatch         │
│ pointsToWinFinalSet      │
│ pointsToWinGame          │
│ pointsToWinSet           │
│ pointsToWinOtherSetsBO3  │
│ pointsToWinOtherSetsBO5  │
│ pointsToWinMatchTiebreak │
└──────────────────────────┘

在 ClickHouse 中存储网球数据

温布尔登的官方网站提供了逐分数据(point-by-point),非常适合拿来探索比赛走势,并分析 Alcaraz 在首轮比赛中是否真的接近被淘汰。

我们首先会创建一个 matches 表,用来存储一些比赛的元数据信息:

CREATE TABLE matches
(
    p1Name String,
    p2Name String,
    match String,
    event String
)
ENGINE = MergeTree
ORDER BY match;

其中 event 列标识这是一场男子比赛还是女子比赛,以便确定使用三盘两胜还是五盘三胜的计分规则。

SELECT * FROM matches LIMIT 3;
┌─p1Name─────────┬─p2Name───────────┬─match─┬─event─┐
│ Jannik Sinner  │ Luca Nardi       │ 1101  │ Men   │
│ Pedro Martinez │ George Loffhagen │ 1103  │ Men   │
│ Mariano Navone │ Denis Shapovalov │ 1104  │ Men   │
└────────────────┴──────────────────┴───────┴───────┘

    接下来,points 表会保存所有比赛的逐分数据。

    CREATE TABLE points
    (
        MatchWinner String,
        SetWinner String,
        GameWinner String,
        p1 Tuple(setsWon UInt8, gamesWon UInt8, score String),
        p2 Tuple(setsWon UInt8, gamesWon UInt8, score String),
        ElapsedTime String,
        PointNumber UInt16,
        match String
    )
    ORDER BY match;

    每一行都包含足够的信息,来判断某位选手距离胜利还有多远——不需要结合其他行就可以得出结论。下面就是 Alcaraz 的比赛关键分信息:

    SELECT * 
    FROM points 
    WHERE match = '1164' AND MatchWinner <> '0
    FORMAT Vertical;
    Row 1:
    ──────
    MatchWinner: 2
    SetWinner:   2
    GameWinner:  2
    p1:          (2,1,'0')
    p2:          (3,6,'0')
    ElapsedTime: 4:36:56
    PointNumber: 357
    match:       1164

    计算胜利所需分数

    在把数据加载进来之后,我们需要编写一个查询,用来计算一名选手距离胜利还需要再拿下多少分。下面就是 Alcaraz 首场比赛中使用的查询:

    WITH
      pointsToWinMatch(
        matches.event = 'Men', MatchWinner, GameWinner, SetWinner, '1', p1.setsWon, p2.setsWon, p1.gamesWon, p2.gamesWon, p1.score, p2.score
      ) AS p1PointsToWin,
      pointsToWinMatch(
        matches.event = 'Men', MatchWinner, GameWinner, SetWinner, '2', p2.setsWon, p1.setsWon, p2.gamesWon, p1.gamesWon, p2.score, p1.score
      ) AS p2PointsToWin
    select PointNumber, p1Name, p1PointsToWin AS p1, p2PointsToWin AS p2, p2Name
    FROM points
    JOIN matches ON matches.match = points.match
    WHERE match = '1164'
    ORDER BY PointNumber;

    查询结果的最后五行如下:

    ┌─PointNumber─┬─p1Name────────┬─p1─┬─p2─┬─p2Name─────────┐
    │         353 │ Fabio Fognini │ 24 │  2 │ Carlos Alcaraz │
    │         354 │ Fabio Fognini │ 23 │  2 │ Carlos Alcaraz │
    │         355 │ Fabio Fognini │ 22 │  2 │ Carlos Alcaraz │
    │         356 │ Fabio Fognini │ 23 │  1 │ Carlos Alcaraz │
    │         357 │ Fabio Fognini │ 72 │  0 │ Carlos Alcaraz │
    └─────────────┴───────────────┴────┴────┴────────────────┘

    可以看到 Alcaraz 离胜利越来越近,直到最后不再需要任何分数,而 Fognini 则几乎需要从头打过整场比赛才能赢。

    使用 Streamlit 和 plot.ly 可视化胜利距离

    最后,我想把这些分析打包成一个小型可视化应用,方便去探索不同比赛。我使用了 Streamlit 和 plot.ly 来实现这个需求。完整的代码都在仓库中,并且大部分由 AI(ChatGPT)生成,我做了一些细节调整。

    你可以使用 uv(Python 包管理工具)在本地运行:

    uv run --with chdb --with plotly --with streamlit \
    streamlit run app.py  --server.headless True

    下面这段动画可以展示该应用的效果:

    图片

    你也可以访问 wimbledon.streamlit.app/,在浏览器中亲自体验一下。

    征稿启示

    面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com

     

    <think>好的,我现在需要解决用户在Vue中组件未正确更新的问题,尤其是在点击“复制”按钮后弹窗内容没有及时显示,而是延迟加载的情况。用户提到希望通过修改key值或其他方法强制重渲染子组件。让我先理清楚可能的原因和解决方案。 首先,根据用户提供的引用内容,特别是引用[1]中的例子,他们之前通过设置key为时间戳来强制子组件刷新。这似乎是一个有效的方法,因为Vue的响应式系统在key改变时会重新创建组件实例。不过用户现在遇到了类似的问题,可能需要同样的方法,但需要确认是否正确应用。 然后,引用[2]提到Vue的异步更新队列,可能导致状态变化不会立即触发DOM更新。如果在同一个事件循环中多次修改状态,Vue可能只会处理最后一次的变更。这可能意味着用户在点击“复制”按钮后,某些状态没有被及时更新,导致弹窗内容没有正确显示。 接下来,我需要考虑可能的解决方案。除了修改key值之外,还有其他方法可以强制组件重新渲染,比如使用this.$forceUpdate()或者改变组件的某个prop。但修改key值通常是推荐的方法,因为它更直接地触发组件的销毁和重新创建,确保状态完全重置。 用户的问题具体是在点击“复制”按钮时弹窗内容没有正确显示。可能的情况是,弹窗组件依赖于某些数据,而这些数据在按钮点击后没有及时触发更新。例如,可能在打开弹窗时,数据还未被正确赋值,或者由于Vue的异步更新机制,DOM没有及时渲染。 我需要检查用户可能的代码结构。假设弹窗组件是一个子组件,当点击“复制”按钮时,父组件会修改某些数据,然后显示弹窗。如果弹窗的内容依赖于这些数据,但数据的变化没有被及时检测到,或者弹窗组件没有重新渲染,就会导致内容显示不正确。 根据引用[1]中的例子,用户之前通过给子组件设置:key="timer",并在打开弹窗时更新timer的值,从而强制子组件重新渲染。同样的方法可能适用于当前的问题。即在点击“复制”按钮时,更新key的值,确保子组件每次打开时都重新创建,而不是复用之前的实例。 另外,需要注意在关闭弹窗时,是否正确地重置了相关的数据和key的值。例如,在弹窗关闭事件中,将timer置为null或新的时间戳,以确保下次打开时key值不同,触发重新渲染。 还有可能涉及到Vue的响应式数据更新问题。如果用户的数据结构较为复杂,或者使用了嵌套对象,Vue可能无法检测到数据的变化。这时候可能需要使用Vue.set或this.$set来确保属性的响应式更新。 此外,引用[3]中提到的主进程和渲染进程之间的通信机制,可能不直接相关,但如果用户的应用涉及Electron或其他类似框架,可能需要考虑跨进程通信对状态更新的影响,不过用户当前的问题集中在Vue组件本身,暂时可以排除这方面的因素。 总结可能的解决步骤: 1. 在弹窗子组件上使用:key属性,并将其绑定到一个响应式的变量(如timer)。 2. 在点击“复制”按钮时,更新timer的值,通常使用时间戳,确保每次点击时key值不同。 3. 在弹窗关闭时,重置timer或其他相关状态,确保下次打开时能再次触发更新。 4. 检查数据更新的时机,确保在打开弹窗前数据已经正确赋值,可能需要使用this.$nextTick来确保DOM更新。 需要验证用户是否正确地应用了这些步骤。例如,是否在点击按钮时更新了key的值,是否在关闭时重置,是否有其他因素导致数据未及时更新。 另外,如果用户的数据更新存在异步操作,比如从API获取数据,可能需要确保在数据加载完成后再打开弹窗,或者使用v-if指令控制弹窗的显示,结合key的变化来强制重渲染。 最后,还要考虑是否有其他优化手段,比如使用计算属性或侦听器来响应数据变化,但关键点还是通过修改key来强制组件重新渲染,确保内容正确显示。</think>在Vue中强制子组件重渲染的解决方案如下: ### 一、通过修改key值强制重渲染 1. **绑定动态key属性** 在弹窗子组件上绑定`:key`属性,值为响应式变量: ```vue <codemirror-editor ref="editor" :key="componentKey" v-model="content" ></codemirror-editor> ``` 2. **点击操作时更新key值** 在"复制"按钮事件中修改key值触发重建: ```javascript handleCopy() { this.componentKey = new Date().getTime(); // 时间戳更新key this.showModal = true; // 显示弹窗 } ``` 3. **关闭弹窗时重置状态** 弹窗关闭时清空关键数据: ```javascript closeModal() { this.componentKey = null; // 可选重置操作 this.content = ''; this.showModal = false; } ``` ### 二、其他辅助优化方法 1. **使用$nextTick确保DOM更新** 在异步操作后强制更新: ```javascript this.$nextTick(() => { this.componentKey = new Date().getTime(); }); ``` 2. **通过v-if控制生命周期** 结合`v-if`与`key`双重保障: ```vue <codemirror-editor v-if="showModal" :key="componentKey" ></codemirror-editor> ``` 3. **使用Vue.set处理深层次对象** 当数据层级较深时: ```javascript Vue.set(this.dataObj, 'nestedProp', newValue); ``` ### 三、原理说明 Vue通过虚拟DOM的diff算法判断组件是否需要更新。当`key`值改变时,会销毁旧组件实例并创建新实例,达到强制重渲染的目的[^1]。这种方法比`$forceUpdate()`更彻底,后者仅触发重新渲染但不重置组件状态[^2]。
    评论
    成就一亿技术人!
    拼手气红包6.0元
    还能输入1000个字符
     
    红包 添加红包
    表情包 插入表情
     条评论被折叠 查看
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值