MoreRSS

site iconluozhiyun修改

93年,非科班程序员,喜欢健身、读书、编程。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

luozhiyun的 RSS 预览

2024沉寂积蓄新的力量

2025-01-26 17:08:29

今年本来元旦的时候可以抽时间写一下这篇文章,但是临近元旦竟然生病了,然后在床上躺了两天。竟然已经过了,那就不急了,慢慢写了。

新学习了啥?

文章

毫无疑问,我觉得现在的生活节奏是越来越快了,特别是在AI的加持之下,掌握AI并利用它进行终生学习已经是必然趋势了,所以我今年没有像往年一样输出很多技术类的文章,因为我觉得没什么必要了,很多知识直接问一下 AI 再结合自己过往的经验很快就能掌握。话虽然这么说,但是还是写了一些文章:

作为开发需要了解 SSD 的一切

透过ClickHouse学习列式存储数据库

深入 RocksDB 高性能的技术关键

构建属于自己的云游戏服务器

Yolov5物体识别与应用

C++ 中到底什么是”&&“ ?

相比以往动则十几篇,量已经少了很多,不出意外的话明年会更少(拼多多财报式发言,笑~)。

英语

从去年开始要学习突破英语,已经过去了一年,我其实还是蛮想去考个雅思,告诉大家自己的英语成绩有哪些突破,自己的方法有多么牛逼。

但是很可惜的是,我没有这么做,因为去年一年因为兴趣的原因,不太想花很多时间在应付考试上面,做的更多的是口语练习以及泛听磨耳朵训练,所以到后面写作以及雅思的考试范式练习我是一样没做。

不过还是讲一下,过去一年我是怎么优化学习英语的:

口语

首先是随便找一篇文章,或者一个感兴趣的事件,比如我经常不知道找什么话题,就会去 https://engoo.com/app/daily-news 上面找一篇文章看完,然后用对着 AI 复述一遍,让 AI 帮我找出我说的句子或语法有什么问题,或者在复述的时候,就发现了很多内容不会表达,全部都记下来,句子我会手写出来,单词我会记到 flomo 里面作为卡片,方便背诵:

Frame 1

然后,每次在开始每天的英语学习之前,把自己的笔记拿出来,不熟的可以多背背。

到了周末,有比较多的时间,我会和外教上一节自由交流课,其实大多时候没有任何主题,随便聊50分钟。但即使这样,很多时候也可以检验自己的英语到底怎样,因为在说的时候,你会发现自己对有些主题很熟悉,对有些主题很陌生。比如我对工作学习相关的主题就比较熟悉,对美食相关的就不怎么会说,这是因为各种食材,酱料之类的名词记的比较少。

听力

我把听力分为泛听和精听两部份:

我现在洗澡做饭,包括早上健身的时候都会经常泛听一些英语材料,泛听就比较发散了,随便什么都听,下面推荐几个感觉还不错的:

Round Table China:里面有几个人应该是中国人,会用英语和老外聊一些中国的实事,非常好懂,我是比较强烈推荐的;

Think Fast, Talk Smart Podcast:这个播客是斯坦福大学运营的一档节目,优点是发音比较清晰,主题偏向商务,工作,学习等;

Vinh Giang:他的播客主要聚焦于教人怎么表达,随便听听还行,因为主题关系,听多了有点单一;

Marques Brownlee:油管最大的电子区up主,发音清晰,用词也比较简单,主要是讲数码,讲电子产品功能啥的,没事可以听听;

Ariannita la Gringa:她的节目比较简单,多以生活场景为主,人也很上镜,学起来会比较愉快;

EnglishAnyone:这个人的英语会让你听起来很有信心,因为他会用简单的句子和单词告诉你该用什么方式学英语,但是场景比较单一;

Long Beach City College的LBCC Study Skills 课程还挺好听的,可以精听和泛听,这个课程讲的一点都不枯燥,强烈推荐!

对于精听,我一般需要抽一整块时间来听,需要配合插件 《Netflix和YouTube-AFL语言学习》一句句听了:

Tom Bilyeu:专门会找一些领域的大佬来访谈,我蛮喜欢的,比如最近找了 Ray Dalio ,Michael Saylor,

Answer in Progress:我非常喜欢他们的节目,各种小东西的科普,探究其原理,社会现象,每个视频十几分钟,但是完全不枯燥,强烈推荐。

AI带给我的学习上的改变

以前我学习效率都是比较低的,很多时候看到一本书的知识点遇到不懂的地方,也只能网上搜一下,但是目前网上不是没有知识,而是太多了,运气好的话,可以很快得出结果,运气不好得看很多篇同样的的文章才找到自己想要的。

但是现在有了AI之后学习方式可以说完全发生了改变,有些章节可以先问问AI,有个大概的了解再读书这样可以加速理解。对于不懂的可以问AI,然后让AI step by step的告诉你这是为什么,甚至可以给你举一反三,可以说AI成为了世上最好的老师。

还有就是 AI 帮我们做了知识的收口,现在互联网的知识太多了,随便查一个东西不是苦于没有,而是苦于不知道该看哪个,但是 AI 可以只吐给我们有效的知识,帮我过滤无用东西。

所以可以看到,合理的使用AI,是可以加速我们掌握新的技能,从另一个角度上讲,AI可能会让人们在未来更方便的转型,从A跨越到B不会太困难,所以我觉得在未来依旧充满了各种机会。

健康 & 健身

过去一年,我还是一样保持了高频的健身习惯,和过往一样,基本上每周至少 4次力量训练和至少一次的有氧训练,天气不冷的时候有氧训练会去游泳,天气太冷了就会去跑步。

Frame 2

近些年来,我一直保持了良好的健身习惯和饮食,要说今年有什么变化,就是在10月体检报告出来之后发现尿酸超标了一些,虽然没有超标很多,但是还是引起了很我重视。其实我本人也不胖,也不抽烟不喝酒,每天差不多11点就睡了,睡眠时间大概在7.5小时以上,之所以会有高尿酸究其原因我觉得应该是我常年以来的力量训练加上高蛋白饮食导致的。因为无氧训练和高蛋白饮食是容易对肾脏产生不小的压力的,再加上慢慢的年纪也大了,肾脏功能衰退也是意料之中的事情。

为了应付高尿酸,我主要做的一件事情就是利用控制变量观察我的尿酸变化情况,以及平时在饮食中减少对高嘌呤食物,含果糖食物的摄入。后面经过为期两个月的观察,发现其实还是喝水最能解决我的问题,因为人体中的尿酸基本是通过体液排出,所以按理来说只要喝水就能解决。然后我通过控制变量观察到基本上只要一天喝够4000ml的水我的尿酸就能稳定在380左右,其实问题就还好。

最后就是在12月某天训练的时候竟然扭伤了手腕,这也导致了12月我基本上就是腿部训练以及跑步为主,运动量没少,应该还大了,毕竟练腿是很累的 ;)。所以截止到1月我的手腕还没好,连我这个训练老手都会受伤,各位在训练的时候一定要注意安全。

Frame 3

学会如何投资

上面讲的几part都是自我价值的投资,这一part主要是想要分享一下我在股市中学到什么。

为什么要进行投资

对于我来说,这也是尝试一个新方向的机会,我想着把我以前学习的能力看看能不能迁移到做投资上。再来就是现在消息面实在是太多了,什么牛鬼蛇神都跑出来教人做投资,如果自己在这方面没有认知,那很容易吃亏的,所以与其被人带着跑,不如自己领跑。

10月的教训

10月那时候,天真的以为中概股会在沉寂几年之后会迎来新的机会,那时候我也受了很多情绪上面的感染,然后入股了一些中概,结果肯定是在不断追高之下输的很惨。后面也让我意识到了,在下行市场上,基本面没有变化的情况下,怎么挣扎力度是有限的。

后面我也去看了《巨债危机》,学习了一些周期方面的知识,让我体会到什么是周期。里面提到一点让我很受用,就是在经济下行的时候,政府会通过一些政策进行经济刺激,每次的刺激都会带来一些上涨,但是总体上来说是跌的,《巨债危机》里面例举了2008年美国的大萧条中,股市有六次大幅上涨,但是总共下跌了 89%。

image-20250126163244520

所以不要跟风,不要抛开基本面进行投资,其实大多数亏损都是在牛市中产生的。

不要被资讯所干扰

现在我们所处的世界,是信息太多了,连巴菲特也这么认为,所以他没有把公司设立在华尔街,他认为远离华尔街的喧嚣能够帮助他避免受到短期市场情绪和集体思维的干扰。在华尔街,投资者很容易陷入信息过载和盲从心理。

举个例子就是,我记得当时,我有个朋友在tesla发布了robotaxi之后很激动,以为这就是future,结果特斯拉第二天股价直接跳水十个点,并且持续低迷了一段时间。后来加上他又看了一些新闻,认为川普可能会选不上,所以就把tesla割肉抛掉了。

image-20250126164518304

这个事情也告诉我,不要被资讯所要干扰,如果当时真是相信马斯克的故事,认为他真的能做成,那就一直持有,否则就不要买入。

其实不只是股市,其他事情也是,现在社交媒体这么发达,很多时候看了一条新闻义愤填膺,然后过几天发现是 fake news,如果没有独立思考的能力就会被玩弄在鼓掌之中。

相信长期的力量

这件事情不只是反映在我对生活的态度,比如坚持写文章,坚持健身,学英语,到了投资这里也是一样的,巴菲特有句话说的好,如果你不愿意持有一只股票十年,那就不要考虑持有它十分钟。

一方面我们都是普通人,做短期很容易亏,看不准形式,并且短期中掺杂了很多噪音会影响判断,另一方面我也没这么多时间盯盘,整天看盘比较影响心情。

所以在过去一年我几乎所有的收益都是由长期持有的股票贡献的,反倒是因为一些小道消息跟风买的一些股票让我亏了一些。

弄了哪些有趣的玩意

游戏

其实整个24年让我印象深刻,并值得说的游戏并不多,玩的很多,但是能回忆起来的没有几个。

《寂静岭2》重制版:玩完《寂静岭2》真的给我一种淡淡的伤感,又是一个艺术与游戏结合的游戏,上次玩到这样的游戏还是 《最后生还者 1》。这个作品真的做到了久久的留存在我的脑海里,无论是背景音乐还是叙事手法,都趋近完美。

《暗黑4》:这游戏首发的时候我就发了600买了豪华版,玩了一阵之后感觉不太行就放下了。今年在第四赛季的时候风评好了不少,于是我又回归玩了一阵,总体来说还是不错的,但是暴雪总归是暴雪,在失望的路上从来不会让人失望,后面整出个300的DLC,我直接就没有太大的兴趣了,直接和暴雪say goodbye。

《流放之路2》:这游戏其实是免费游戏,只是现在还在内测,可以充30刀开启内测资格,还挺良心的,说实话,这游戏是真的做的不错,精良程度,游戏深度都做的不错;

买过哪些玩意

一加ACE3 pro

买这个手机是因为我很久没有体验过安卓机了,像搞个来看看目前主流安卓机有啥可玩的。

优点:

  1. 硬盘价格是真的便宜,16+512+8gen3只要3k多;
  2. 沙盒系统真好用,真的完全隔离,赖皮应用全丢到沙盒里,什么信息都获取不到;
  3. 侧滑返回真的爽,任何应用都可以左右侧滑返回,反观ios的垃圾返回操作万年不改;
  4. 小窗是很方便的,挂机打游戏,看视频回信息;
  5. 分屏很爽,有时候上下分两个屏处理信息快不少;
  6. 屏下指纹比faceid好用一万倍;
  7. 可玩性很高,可以玩各种 galgame ,switch模拟器,王国之泪也能在安卓机上玩;
  8. 100w充电真的很快,早上洗漱的时间,充好就可以拿走了;

缺点:

  1. 广告太多,虽然可以关,但是什么类似快应用这样的app仍然在后台收集数据;
  2. AI应用虽然好,但是打开后台应用的权限管理,可以看到AI的数据收集工作一刻不停,一天收集几百次,并且各种权限都要;
  3. 相册权限管理很差,适配的应用可以只给部分照片,没适配只能全给或者不给。像小红书这种,给了权限,几乎一天要读几十次相册

iPhone16P:没错,最后我还是换到了苹果,其实也没有什么特别的原因,感觉能满足我的需求,不需要折腾,没广告,偶尔去旅旅游录个视频效果也还挺好,那就足够了。没有买pro max是因为觉得太重了,太撑手了,现在市面上手机一个比一个宽,一个比一个大,感觉都要双手捧着才能用了;

iPad mini7:这个平板应该是我使用频率最高的电子产品之一,可以用来看电子书,看视频,玩游戏,串流,因为小巧轻便有时候还可以用笔记一点东西,由于很便携,我基本走哪里都带上;

macbook air m3:这台电脑是我五一的时候去冲绳买的,那时候日元汇率很不错,然后就入手了。它目前来看基本上能满足所有的日常使用需求,并且还非常的轻薄,后面出去玩再也不用带着我16寸的MacBook pro了,瞬间解放我的背包;

xiaomi buds5:这个产品刚使用的时候确实很惊艳,因为一个半入耳式的耳机竟然可以有不错的降噪效果,并且音效我听起来也很棒,感觉比我原来的 airpods2 好太多了。

去过哪些地方

北海道之行

平时雪都很少见的我,去小樽滑的第一场雪,我觉得是非常好玩的,一开始的时候我甚至都不能起来,后来还是放弃了,找了个教练,教练竟然是台湾人,带我练了几个小时后我基本上就可以自己入门了。

Frame 4

我去小樽的那天,他们也在搞灯节,还挺漂亮的,旁边有工作人员,可以100日元买个小杯子,里面有个小蜡烛,我写的是:for tomorrow

Frame 5

漫步在小樽的街上你可以感受到,他们的生活过的很悠闲,下午3点就没什么人了,以至于想找个吃饭的地方都没几个店。

Frame 6

很有意思的一点是,悠闲的逛公园的时候,你会发现很多牌子,告诉你小心乌鸦,因为一不小心你买的饼就没了。。。

Frame 7 (3)

冲绳

然后就是五一的时候去了冲绳,那边的沙滩还挺美的。

Frame 8

冲绳的海水真的好清澈,第一次看到玻璃一样的海水。

Frame 9

冲绳还有必去海洋馆。

Frame 10

总体来说,我觉得冲绳的旅游体验很好的,有点不太好的是交通不是很方便,经常需要打车。

亚庇

亚庇这个小城市,还挺有意思的,好几面墙上有巨大的壁画,可惜我去的是十月,天气不是很好,总是在下雨。

Frame 11

亚庇的主要旅游景点也是去各种小岛上面玩,但是我感觉其实体验很差,比如我去的这个环滩岛,据说是最好的岛之一了,但是各种服务还是很差,吃的也不好,洗浴间排队很长时间。所谓的游玩项目也就是浮潜一个多小时,然后回岛上吃个饭,然后就回去了。

并且浮潜如果没有自己带装备的话,他们提供的装备是真的差,并且漏水,然后一个一次性嘴塞10马币。

Frame 12

亚庇的消费其实挺高的,随便点个菜也要几十,并且当地的菜都是类似糊糊一样的东西,我是吃不太习惯,最好吃的感觉还是当地的华人菜馆。

然后就是亚庇的榴莲是真的好吃,我基本上每天都会去开两个榴莲吃,不必吃什么猫山王,就本地的白榴莲就可以了,一个就十几马币。

Frame 13

总结

总体来说,旅游体验来说:北海道>冲绳>亚庇,但是价格来说,也是北海道>冲绳>亚庇。

新的展望

技术上,我希望能学点不一样的东西,最近接触到了一点 ue 相关的开发,希望能在新的一年往这方面点一下技能树。

然后就是希望能更加深度挖掘一下已经掌握的技能,不是说学了就能放着不管,时间久了很慢慢遗忘,没什么想学的时候多问问自己哪些掌握了,哪些没有,比如:c++是真的熟悉了吗?c++17的特性有哪些?等等。

对于英语,还是希望能坚持,语言性的东西要学起来其实是需要漫长的过程,不是一朝一夕就可以练成,不过现在很多 AI 工具已经大大的简化了学习流程了,我觉得掌握起来不算太难,还是继续每天花至少半个小时多多练习比较重要。

对于健身,除了身形以外,更加希望是朝着健康的方向发展,更加需要注意怎样不让自己受伤的情况下,承受最大的重量,然后把身体锻炼好,适当的加入有氧运动,而不是全都是无氧运动,这样更能激活自己身体机能。

2024沉寂积蓄新的力量最先出现在luozhiyun`s Blog

C++ 中到底什么是”&&“ ?

2024-12-26 18:58:57

最近《Effective Modern C++ 》看到了第五章,感觉这章挺有趣的,所以单独拿出来总结一下,主要是想对通用引用和右值引用相关的东西总结补充一下,感兴趣的不妨看看。

区分通用引用和右值引用

T&&”有两种不同的意思。第一种,当然是右值引用。“T&&”的另一种意思是,它既可以是右值引用,也可以是左值引用,被称为通用引用universal references)。

比如下面的例子中

void f(Widget&& param);             //右值引用
Widget&& var1 = Widget();           //右值引用
auto&& var2 = var1;                 //通用引用,auto&& 是通用引用,它的类型根据 var1 的类型来推导,var1 是一个右值引用,var2 也是一个右值引用,绑定到 var1。

template<typename T>
void f(std::vector<T>&& param);     //右值引用

template<typename T>
void f(T&& param);                  //通用引用            

上面的例子中:

auto&& var2 = var1;这是一个 通用引用(完美转发引用)。

  • auto&&通用引用,它的类型根据 var1 的类型来推导。
  • 由于 var1 是一个右值引用,auto&& 会推导为 Widget&&。因此,var2 也是一个右值引用,绑定到 var1

template<typename T> void f(T&& param);这是一个 通用引用(完美转发引用)。

  • T&& param 是一个 通用引用,它的类型根据传递给模板函数的实际类型来推导。
  • 如果传入的是右值,param 会被推导为右值引用 (T&&)。
  • 如果传入的是左值,param 会被推导为左值引用 (T&),这是通过引用折叠规则完成的。

对一个通用引用而言,类型推导是必要的,它必须恰好为“T&&”。下面几种情况都可能使一个引用失去成为通用引用的资格。

template<typename T> void f(std::vector<T>&& param);这是一个 右值引用

  • std::vector<T>&& param 是一个 右值引用,它接受一个 std::vector<T> 类型的右值。
  • 函数 f 只能接受右值类型的参数。

除此之外,即使一个简单的const修饰符的出现,也足以使一个引用失去成为通用引用的资格:

template <typename T>
void f(const T&& param);        //param是一个右值引用

对右值引用使用std::move,对通用引用使用std::forward

std::move 是一个类型转换工具,它将左值转换为右值引用,允许通过移动语义来避免不必要的对象复制。比如有时候我们希望将其资源转移给另一个对象时,你应该使用 std::move。这样可以避免复制操作,提高效率:

std::vector<int> createVector() {
    std::vector<int> v = {1, 2, 3};
    return std::move(v);  // 使用 std::move 防止复制,转移资源
}

std::forward 它通常用于完美转发(perfect forwarding)的场景中,保留了传入参数的值类别(即,是否是左值或右值)。这使得它特别适用于在函数模板中转发参数,而不改变它们的值类别。

完美转发:指的是保持参数的左值或右值性质,以便在转发时能够选择正确的构造(复制或移动)。比如下面:

template <typename T>
void wrapper(T&& arg) {
    // 使用 std::forward 保留 T&& 参数的左值或右值性质
    someFunction(std::forward<T>(arg));
}

在这个例子中,std::forward<T>(arg) 将确保传入的参数 arg 被正确地转发到 someFunction,而不引入不必要的复制或移动。

所以 move 和 forward 使用场景是不一样的,如果在通用引用上使用std::move,这可能会意外改变左值:

class Widget {
public:
    template<typename T>
    void setName(T&& newName)       //通用引用可以编译,
    { name = std::move(newName); }  //但是代码太太太差了!
    …

private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName();        //工厂函数

Widget w;

auto n = getWidgetName();           //n是局部变量

w.setName(n);                       //把n移动进w!

…                                   //现在n的值未知

上面的例子,局部变量n被传递给w.setNamen的值被移动进w.name,调用setName返回时n最终变为未定义的值。

所以对于 move 和 forward 我们尽量不要用错,但是也不要不用,否则可能会有性能上的损失,比如下面:

Matrix                              //按值返回
operator+(Matrix&& lhs, const Matrix& rhs)
{
    lhs += rhs;
    return std::move(lhs);          //移动lhs到返回值中
}

如果在最后 return的时候直接返回 lhs,那么其实是拷贝lhs到返回值中,拷贝比移动效率低一些。

forward情况也类似,如下:

template<typename T>
Fraction                            //按值返回
reduceAndCopy(T&& frac)             //通用引用的形参
{
    frac.reduce();
    return std::forward<T>(frac);     //移动右值,或拷贝左值到返回值中
}

如果std::forward被忽略,frac就被无条件复制到reduceAndCopy的返回值内存空间。

最后提一下,由于 RVO 的存在,我们也不要以为使用 move 操作就可以提升性能,RVO 主要解决的是在函数返回一个局部对象时,编译器可能会为该对象创建一个临时副本,然后将其复制到返回值中,从而引入了不必要的复制开销。

所以当我们想要“优化”代码,把“拷贝”变为移动:

Widget makeWidget()                 //makeWidget的移动版本
{
    Widget w;
    …
    return std::move(w);            //移动w到返回值中(不要这样做!)
}

通过返回 std::move(w),你实际上告诉编译器“我想要移动这个对象”,从而避免了 RVO 的优化,可能会导致不必要的资源转移。

所以你看,在c++中,为了做性能优化其实是一件挺复杂的事情。

避免在通用引用上重载

为什么要避免使用

通用引用可以绑定到几乎所有类型的实参,包括左值和右值。这种特性使得编译器在选择重载时可能会优先选择通用引用版本,即使这并不是开发者所期望的行为。并且由于通用引用的匹配规则,编译器在解析重载时可能会产生意外的结果。举个例子,假设有以下两个函数:

template<typename T>
void foo(T&& t) { /* 通用引用 */ }

void foo(int& t) { /* 左值引用 */ }

当调用foo(5)时,编译器会选择第一个函数,因为5是一个右值,而对于左值int a; foo(a);则会选择第二个函数。

但是假如将short a; foo(a);则会选择第一个函数。有两个重载的foo。使用通用引用的那个推导出T的类型是short,因此可以精确匹配。对于int类型参数的重载也可以在short类型提升后匹配成功。根据正常的重载解决规则,精确匹配优先于类型提升的匹配,所以被调用的是通用引用的重载。

如果想要使用重载该怎么办

最简单的方式当然是放弃了,比如 go 里面就没有重载,比如我们重载了logAndAdd函数,那么可以换成logAndAddName和logAndAddNameIdx来避免重载。

第二种方法就是将通用引用改写成 const T&,因为通用引用带来的复杂推导、重载歧义等问题反而会增加维护成本。此时,直接使用const 的左值引用即可满足需求。

改写之后 const T& 可以绑定到左值和右值,并且所有实参都被看作不可修改的“左值”,所以无需区分值类别。

比如原始版本(使用通用引用):

#include <utility> // std::forward

template<typename T>
void process(T&& param) {
    // 这里可能会做完美转发
    someFunction(std::forward<T>(param));
}

改用 const T& 后:

template<typename T>
void process(const T& param) {
    // 现在 param 总是 const 引用,不再做完美转发
    // 如果只是读 param 或传入其他函数当 const 引用使用就可以
    someFunction(param); // 直接传参即可
}

这种转变缺点是效率不高,因为放弃了移动语义,但是为了避免通用引用重载歧义、易读性优先,所以放弃一些效率来确保行为正确简单可能也是一种不错的折中。

再来就是使用tag dispatch,比如下面我们有两个重载函数,但是由于通用引用的存在,会发生引用重载歧义,所以我们想要正确的让函数实现最优匹配,可以使用std::is_integral 是一个类型特征(type trait),用来在编译期判定某个类型是否是整型

template<typename T>
void logAndAdd(T&& name)
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

void logAndAdd(int idx)             //新的重载
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(nameFromIdx(idx));
}

比如我们可以改写成这样:

template<typename T>
void logAndAdd(T&& name)
{
    logAndAddImpl(
        std::forward<T>(name),
        std::is_integral<typename std::remove_reference<T>::type>()
    );
}

template<typename T>                            //非整型实参:添加到全局数据结构中
void logAndAddImpl(T&& name, std::false_type)    
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

std::string nameFromIdx(int idx);           //与条款26一样,整型实参:查找名字并用它调用logAndAdd
void logAndAddImpl(int idx, std::true_type) //译者注:高亮std::true_type
{
  logAndAdd(nameFromIdx(idx)); 
}

logAndAdd传递一个布尔值给logAndAddImpl表明是否传入了一个整型类型,通过 std::true_typestd::false_type 来判断应该调用哪个函数。上面的例子中还用到了std::remove_reference,它的作用是去掉类型中的引用修饰符得到正确的类型:若 TU&U&&,则 std::remove_reference<T>::type 得到 U;如果 T 本身不是引用类型,则结果还是 T 本身。

在这个设计中,类型std::true_typestd::false_type是“标签”(tag),在logAndAdd内部将重载实现函数的调用“分发”(dispatch)给正确的重载。

再来就是基于 std::enable_if 的重载,如下实现了两个版本的重载函数:

// ----------------------
// (A) 针对整型参数的重载
// ----------------------
template<
    typename T,
    // 使用 std::enable_if 使该重载仅在 T 是整型时有效
    typename = std::enable_if_t<std::is_integral_v<std::remove_reference_t<T>>>
>
void logAndAdd(T&& idx)
{
    cout << "for int" << endl;
}

// ------------------------
// (B) 针对非整型参数的重载
// ------------------------
template<
    typename T,
    // 使用 std::enable_if 使该重载仅在 T 不是整型时有效
    typename = std::enable_if_t<!std::is_integral_v<std::remove_reference_t<T>>>
>
void logAndAdd(T&& name)
{
    cout << "for universal reference" << endl;
}

int main()
{
    logAndAdd("Alice");             // 非整型,调用重载 (B)
    std::string bob = "Bob";
    logAndAdd(bob);                 // 非整型,调用重载 (B)

    logAndAdd(42);                   // 整型,调用重载 (A)  

    return 0;
}

通过 SFINAE 条件:

std::enable_if_t<std::is_integral_v<std::remove_reference_t<T>>>

如果形参是整型类型(如 int, long,无论左值还是右值),匹配这个重载。否则就调用另外一个。

关于引用折叠reference collapsing

在C++中引用的引用是非法的,比如下面的写法,编译器会报错:

int x;
…
auto& & rx = x; //error: 'rx' declared as a reference to a reference

但是我们上面用了很多这样的例子:

template<typename T>
void func(T&& param);       //同之前一样

Widget w;                   //一个变量(左值)
func(w);                                    //用左值调用func;T被推导为Widget&

它并没有因为被推导成了 Widget& && param编译器报错。因为编译器会通过引用折叠把T推导的类型带入模板变成Widget& param,也就是说这时候 func 里面传入的是一个左值引用。

存在引用折叠是为了适配“完美转发”这种灵活的泛型编程需求。在模板中使用如 T&&(或者 auto&&)这类通用引用的时候,我们把左值传给 T&& 时,需要推导出的类型为 T&(左值引用);把一个右值传给 T&& 时,需要推导出的类型为 T&&(右值引用)。

由于这个推导会自动在类型上再套一层引用,所以不可避免会产生 T& &T&& &T& &&T&& && 这类“引用的引用”。若没有引用折叠规则,这些“引用的引用”将无法在语言中被直接表示。

C++ 标准中定义的引用折叠规则可总结为以下四条(这里的 & 代表左值引用,&& 代表右值引用):

  1. T& & 折叠为 T&
  2. T& && 折叠为 T&
  3. T&& & 折叠为 T&
  4. T&& && 折叠为 T&&

其中可以看出,只要有一个左值引用(&)参与,就会最终折叠成左值引用;只有当纯右值引用(T&& &&)相叠时,才会保留为右值引用。

可以用简单的测试代码来查看引用折叠的结果:

#include <type_traits>
#include <iostream>
#include <string>

template <typename T>
void testReference(T&& x) {
    using XType = decltype(x); // 注意这里的 x 是函数形参
    // 现在 XType 才是真正的形参类型,比如 int&、int&&

    std::cout << "XType is "
              << (std::is_reference<XType>::value ? "reference " : "non-reference ")
              << (std::is_lvalue_reference<XType>::value ? "&" : "")
              << (std::is_rvalue_reference<XType>::value ? "&&" : "")
              << std::endl;
}

int main() {
    int i = 0;
    const int ci = 0;
    // 1) 左值 int
    testReference(i);       // T 推断为 int&,故 T&& 折叠为 int&
    // 2) 右值 int
    testReference(10);      // T 推断为 int,  故 T&& 折叠为 int&&
    // 3) 左值 const int
    testReference(ci);      // T 推断为 const int&, T&& -> const int&
    // 4) 右值 const int
    testReference(std::move(ci)); // T 推断为 const int, T&& -> const int&&
    return 0;
}

完美转发失效的情况

花括号

比如我们有 fwd 的函数,利用fwd模板,接受任何类型的实参,并转发得到的任何东西。

template<typename... Ts>
void fwd(Ts&&... params)            //接受任何实参
{
    f(std::forward<Ts>(params)...); //转发给f
}

假定f这样声明:

void f(const std::vector<int>& v);

在这个例子中,用花括号初始化调用f通过编译,

f({ 1, 2, 3 });         //可以,“{1, 2, 3}”隐式转换为std::vector<int>

但是传递相同的列表初始化给fwd不能编译

fwd({ 1, 2, 3 });       //错误!不能编译

因为对f的直接调用(例如f({ 1, 2, 3 })),编译器看看调用地传入的实参,看看f声明的形参类型。它们把调用地的实参和声明的实参进行比较,看看是否匹配,并且必要时执行隐式转换操作使得调用成功。但是 fwd 是个模版,所以不能这样调用。

0或者NULL作为空指针

传递0或者NULL作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int)而不是指针类型。结果就是不管是0还是NULL都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr而不是0或者NULL

仅有声明的整型static const数据成员

当一个编译时常量被用作 纯值,如传递给函数、用于模板参数等,编译器可能不会为其分配实际的存储空间,只在需要时进行内联优化。这意味着这些常量没有独立的地址可供引用。

然而,当你尝试取这些常量的地址时,编译器需要一个实际的存储位置。如果该常量没有被定义在某个存储位置(例如,静态成员变量在类外未定义),链接器将找不到该符号,从而产生链接错误。

比如在类中定义整型static const数据成员:

class Widget {
public:
    static const std::size_t MinVals = 28;  //MinVal的声明
    …
};

想象下ffwd要转发实参给它的那个函数)这样声明:

void f(std::size_t val);

我们尝试通过fwd调用f会报错:

fwd(Widget::MinVals);       //ld: symbol(s) not found for architecture arm64
c++: error: linker command failed with exit code 1

要能够安全地取编译时常量的地址,需要确保这些常量有实际的存储空间。一种方式是提供类外定义:

#include <cstddef>

class Widget {
public:
    static const std::size_t MinVals = 28; // 类内声明和初始化
};

// 类外定义
const std::size_t Widget::MinVals;

void f(std::size_t val) {}

template<typename T>
void fwd(T&& param) {
    f(std::forward<T>(param));
}

int main() {
    fwd(Widget::MinVals);
    const std::size_t* ptr = &Widget::MinVals; // 现在可以安全取地址
}

还有就是从 C++11 开始,使用 constexpr 可以在一定条件下避免需要类外定义,因为 constexpr 变量默认具有内联性质,编译器会为其分配存储空间:

#include <cstddef>

class Widget {
public:
    static constexpr std::size_t MinVals = 28; // 使用 constexpr
};

再来就是从 C++17 开始,可以使用 inline 关键字:

class Widget {
public:
    inline static const std::size_t MinVals = 28; // 使用 inline
};

重载函数

如果我们试图使用函数模板转发一个有重载的函数,也是会报错的,译器不知道哪个函数应被传递。如下f被定义为可以传递函数指针:

void f(int (*pf)(int));

现在假设我们有了一个重载函数,processVal

int processVal(int value);
int processVal(int value, int priority);

报错:

fwd(processVal);                    //错误!那个processVal?

但是我们可以给函数重命名来解决这个问题:

using ProcessFuncType =                          
    int (*)(int);

ProcessFuncType processValPtr = processVal;     //指定所需的processVal签名

fwd(processValPtr);                             //可以
fwd(static_cast<ProcessFuncType>(workOnVal));   //也可以

Reference

《Effective Modern C++ 》

C++ 中到底什么是”&&“ ?最先出现在luozhiyun`s Blog

Yolov5物体识别与应用

2024-09-29 10:24:37

本篇文章只用于技术分享,用dnfm作为例子来展示应用,不提供完整源码。大家只讨论技术就好了,dnf检测还是很强的,我被封了好几次了。。。

我们先来看看效果怎样,大家可以二倍速观看,我视频里没有快进:

去年的时候写过两篇文章,如何学习强化学习,以及如果用AI玩 FlappyBird:

[长文]写给开发同学AI强化学习入门指南

如何用 PPO 算法让 AI 学会玩 FlappyBird

最近后开始玩DNFM,但是经过了几个月的搬砖实在是有点乏了,那么我们怎么用AI来代替我们在DNFM里面搬砖呢?

我们知道AI在游戏领域其实有很多的应用了,比如 MaaAssistantArknights:明日方舟游戏助手。因为它的最主要的功能都是静态的,并且位置固定,不存在需要移动的场景,那么基于静态的图像识别技术 OpenCV,就实现一键完成明日方舟游戏的全部日常任务。

那么 DNFM 的搬砖 AI 应该要用什么算法来做呢?首先要分析一下在 DNFM 手游中搬砖这个任务包含了哪些行为:

  1. 需要识别到我们人物角色的位置,以及怪物的位置;
  2. 移动到怪物的位置,释放普通攻击或者技能攻击;
  3. 因为地图中还有些假的怪物躺着不能被攻击,也不会掉落,所以需要识别这部分怪物,避免无效攻击;
  4. 完事了,还需要捡起怪物掉落的碳(我们的砖);
  5. 清完这个图之后要能认识什么是门,并且能够进入到下一张地图;
  6. 还需要识别狮子头这个怪物,它会掉落大量的碳;
  7. boss房打完之后,还需要自动进行下一局,直到消耗完所有疲劳;

从上面的分析,我们知道,要完成这个任务其实远比我们想象中要复杂,并且上面即使实现了,也只是半自动,还有多角色自动切换,角色自动移动到指定地图等等。

那么通过上面的“需求”,我们应该大体知道,对于静态图标文字之类的,我们可以使用 OpenCV 来解决,因为这些东西的相对位置不会变动,比如识别是否应该进入下一张图,我们直接识别这张图片即可:

again

其他动态信息都需要用到深度学习来实现,识别什么是怪物,什么是角色,什么是物品等等。这类的算法有很多,在 《动手学深度学习》 中第七章和第八章里面就讲到了如何用卷积神经网络 CNN 来进行图像识别。我们这次也是要使用大名鼎鼎的 YOLO 算法来实现我们的动态的图像识别。

那么除了图像识别以外,接下来需要解决如何玩游戏的问题了,那么就需要对手机进行控制,这类的解决方式有很多,但是对于DNFM来说,游戏里面是有反作弊系统的,所以要在不修改数据包,不root手机的情况下完成这个任务。我的解决方案是用 ADB 连接电脑,然后通过软件映射的方式来在电脑上控制手机玩游戏,几乎不需要任何权限,只需要一台安卓机即可。

好了,分析完之后来总结一下,我们的技术实现方案:

  1. 用 OpenCV 实现静态图像识别;
  2. 用 YOLO 实现动态图像识别;
  3. 用 ADB 控制手机实现角色控制。

下面我们进行挨个的技术爆破。

YOLO 算法

YOLO算法是one-stage目标检测算法最典型的代表,其基于深度神经网络进行对象的识别和定位,运行速度很快,可以用于实时系统。one-stage 直接从图像中生成类别和边界框位置预测,即网络一次性完成目标位置预测和分类任务,这个特性正是符合我们实时的游戏操作。

相对来说 two-stage 它是分成两步的,需要把任务细化为目标定位目标识别两个任务,简单来说,找到图片中存在某个对象的区域,然后识别出该区域的具体对象是什么。这种算法的缺点是识别比较慢,但是小物体检测好,精度高,这类的代表算法有 RCNN 系列。

我们可以简单对比一下 RCNN 和 YOLO 的区别,YOLO的mAP要低于Fast-rcnn,但是FPS却远高于Fast-rcnn:

image-20240920115738762

而我们只是用来玩一个游戏, 不需要这么高的精度,实时性更重要,这也是为什么选用YOLO算法。

说点题外话,由于YOLO算法的实时性和准确性,所以YOLO也被用于一些军事领域上,YOLO算法的原作者Joseph Redmon于2020年宣布退出计算机视觉领域,他在社交媒体上表示,他不想看到自己的工作被用于可能造成伤害的用途,因此选择退出这一研究领域。

基本原理

现在 YOLO 已经出到了 V9 版本,前3个版本是Joseph Redmon开发的,先来看看 V1 版的论文。

YOLO将输入图像划分为 S×S 网格,如果物体的中心落入网格单元,则该网格单元负责检测该物体。我们以下面 7×7 的格子为例:

image-20240920145926089

具体实现过程如下:

  • YOLO首先将图像分为7×7的格子。如果一个目标的中心落入格子,该格子就负责检测该目标。每一个网格中预测B个box 和置信值(confidence score)。这些置信度分数反映了该模型对盒子是否包含目标的信心,以及它预测盒子的准确程度,如果没有目标,置信值为零;
  • 每一个box包含5个值:x,y,w,h和confidence score,(x,y)坐标表示边界框相对于网格单元边界框的中心,w 宽度和 h 高度是相对于整张图像预测的;
  • 每个网格还要预测一个类别信息,记为 C 个类,比如上图就要预测到狗,自行车,汽车;
  • 在得到所有边界框和类概率后,应用非算法来消除重叠的边界框,保留具有最高置信度分数的框,并去除与其重叠度超过设定阈值的其他框,从而减少冗余检测结果;
  • 输出最终的边界框、类别标签和置信度分数;

局限性

因为每个网格单元只会预测两个boxes,然后从中选出最高的IOU的box作为结果,也就是最终一个网格只能预测一个物体,那么这种空间约束限制了YOLO模型可以预测的附近对象的数量。如果要预测的多个物体小于网格的大小,那么将识别不出来,比如远处的鸟群。

还有就是由于输出层为全连接层,因此在检测时,YOLO 训练模型只支持与训练图像相同的输入分辨率,yolo-v1的输入是448×448×3的彩色图片。其它分辨率需要缩放成此固定分辨率;

YOLO训练部署实战

这里我选用Ultralytics的 YOLOv5 版本 https://github.com/ultralytics/yolov5 来完成本次任务,一来因为是需要快速响应的场景,并且游戏场景里面也没有很小的物体或密集的场景需要识别。并且提供多个模型尺寸(如s、m、l、x),适应不同的应用场景。活跃的社区和易用性,也适合初学者和快速开发。

对于一个YOLOv5 这样的监督学习框架,要使用总共要经历以下这么几步:

  1. 给图片打标签,这里我们选用 Label Studio 来做;
  2. 训练;
  3. 测试验证;
  4. 导出部署,这里我们使用 ONNX 来部署;

标签

  1. 安装

    pip install label-studio
  2. 启动

    label-studio start

然后就可以在浏览器访问 http://localhost:8080 打开打标签的界面了。然后我们需要打开游戏,玩一局并录像,然后对视频进行抽帧生成游戏内的图片,代码如下:

def main(source: str, s: int = 60) -> None:
    """
    :param source:  视频文件
    :param s:       抽帧间隔, 默认每隔60帧保存一帧
    :return:
    """
    video = cv2.VideoCapture(source)
    frame_num = 0
    success, frame = video.read()
    while success:
        if frame_num % s == 0:
            cv2.imwrite(f"./images/{frame_num // s}.png", frame)
        success, frame = video.read()
        frame_num += 1
    video.release()
    cv2.destroyAllWindows()

setting 设置 box 检测任务:

image-20240920191733738

设置label:

我们这里主要标记这么几个物体:

'Gate' # 门, 'Hero' # 玩家人物, 'Item' # 掉落物品, 'Mark' # 箭头标记, 'Monster' # 怪物, 'Monster_Fake' # 怪物尸体 

image-20240920191827549

然后将我们抽好帧的图片 import 进入到工程里面。

然后就一张张的图片进行手动的标记:

image-20240920192147025

这里你有可能要问了,如果我的角色换了一套衣服,那我的模型是不是就不认识了呀。确实是这样,会不认识,并且不同角色要多次标记,每个角色差不多标记个50张图片就好了(这个过程真累啊)。

导出为YOLO:

image-20240917004920301

预测

到这里之后,我们进入到YOLOv5 https://github.com/ultralytics/yolov5 工程里面,我们先在 data 目录下面创建 mydata.yaml,配置好要训练的数据集:

# 训练和验证的数据集
train: ../train_data/project-1-at-2024-09-17-00-48-5c375b91/images
val: ../train_data/project-1-at-2024-09-17-00-48-5c375b91/images

# number of classes 表示有多少分类,等于 names 数量就行了
nc: 6

# class names 分类的名字
names: ['Gate', 'Hero','Item','Mark','Monster','Monster_Fake']

然后打开 models 里面的 yolov5s.yaml 文件,只需要把 nc 改成 6就好了。

然后直接执行训练:

python train.py --epochs 100 --batch 8 --data data/mydata.yaml --cfg yolov5s.yaml --weights yolov5s.pt --device 0 

epochs 和 batch 会影响最后的收敛效果以及速度,根据自己的显卡来调试就好了,我的4090 100张图片大概训练了10分钟左右。

检验

训练完之后可以用我们刚刚录好的视频做验证:

python detect.py  --source D:\document\dnfm-auto\video\Record_2024-09-14-21-29-31.mp4 --weights best.pt

我这里把我跑完的视频上传了,可以看到即使是 YOLOv5s 效果也足够好了:

导出模型

然后我们把 pt 模型导出成 onnx模型:

python export.py --weights best.pt --img 640 --batch 1 --device 0 --include onnx

主要因为使用ONNX(Open Neural Network Exchange)来部署模型相比于直接使用PyTorch(.pt格式)有几个显著的优点:

  1. ONNX提供了一个开放标准,允许在不同的深度学习框架之间共享和转移模型;
  2. 推理时通常比原始PyTorch模型更快,尤其是在专用硬件(如GPU、TPU等)上;
  3. 通过使用ONNX,可以简化模型部署流程;

部署

对于 ONNX 的部署我们使用 ONNXRuntime 来进行,它几乎可以在不修改的源码的基础上进行部署它的整个架构就像Java的JVM机制一样。具体可以参考onnxruntime.ai的具体介绍。

Python部署yolov5模型几乎就是参照了源码的流程,主要分为以下几步:

  1. 图片前处理阶段
  2. 模型推理
  3. 推理结果后处理

具体,我们可以参考这个项目 https://github.com/iwanggp/yolov5_onnxruntime_deploy 把推理的 demo 给写出来,然后尝试导入图片看是不是能生成这样的 anchor 图片:

image-20240920194257700

对于上面的的图片检测算法最后可以为每个anchor是可以生成:[centerX,centerY,width,height,label,BoxConfidence ],分别表示中心点坐标,宽和高,标签索引,置信度,我们只需要前5个数据即可。

最后

这个项目做的过程并不是这么轻松,本来想要让它代替我肝游戏的,但是目前来看只完成了第一版就懒得再动了,当然能够自动打完全图还是激动的,我也曾想向 MaaAssistantArknights 这个明日方舟工具一样开源,让大家一起来共建,但是感觉避免不了会有黑产影响dnfm项目组业绩,还是作罢。

勇士在怎样热爱这个游戏终究不是年轻时的勇士,没时间继续迭代更新这个工程,也没时间继续每天花1小时自己肝,让我觉得我是时候该放下了。

Reference

https://blog.csdn.net/Deaohst/article/details/127835507

https://www.bilibili.com/video/BV1XW4y1a7f4/?spm_id_from=333.337.search-card.all.click&vd_source=f482469b15d60c5c26eb4833c6698cd5

https://github.com/iwanggp/yolov5_onnxruntime_deploy

https://github.com/luanshiyinyang/YOLO

https://arxiv.org/abs/1506.02640

https://www.datacamp.com/blog/yolo-object-detection-explained

Yolov5物体识别与应用最先出现在luozhiyun`s Blog

构建属于自己的云游戏服务器

2024-06-29 18:03:28

最近沉迷于暗黑4第四赛季,所以就在倒腾,怎样才能随时随地玩到暗黑4,掌机steam deck 我试过了,太重并且性能很差,已经被我卖了,于是折腾起了云游戏。

先来看看我的折腾成果:https://www.bilibili.com/video/BV1Z93TeuEQ4/

其实效果我没想到有这么好,在远程串流的情况,可以 1080p 60hz 几乎无卡顿的玩暗黑4,延迟只有20ms左右,配上我的手柄简直就是一个强大的掌机。

各种平台云游戏怎样了?

有了上面的需求之后,我就去试了以下几个平台:GeForce Now、Xbox Game Cloud、Start云游戏、网易云游戏。

但是遗憾的是,几乎每个平台都有自己的问题。首先上面列举到的所有平台,IOS 都只有网页版,因为苹果不让上架。

Start云游戏:我检查了一下,只有少数的几个单机游戏,大多是网游手游,并没有暗黑4;

网易云游戏:无论是快速启动还是普通启动,都非常的慢,估计要3分钟左右才能启动。并且我充值了一下玩了一下,网络倒是很流畅,延迟只有15ms左右,但是非常的卡,并且经常进入游戏界面死机,估计是机器性能不行,说实话我有点心疼我充值的10块钱了。

所谓的高配,其实性能很低:

image-20240623180606792

Xbox Game Cloud:微软自家的平台,好处是有了XGPU之后可以畅玩所有云游戏,最大的问题是服务器不在国内,所以连接延迟其实很高,并且常有波动,如果要玩Xbox Game Cloud 那么还需要充值XGPU才行;

Xbox Game Cloud 强大的游戏阵容:

image-20240623180512545

GeForce Now:这个云游戏平台可以说只能用无敌两个字来形容,每次登录都可以免费半个小时,即使服务器在海外,但是开了加速器也可以很稳定,画面有720P,在手机上玩还可以,主机性能也很好,经常分配到2080以上的机器。但是唯一不爽得是,免费用户每次都要排队挺长时间的,并且如果要付费,其实挺贵的,基本要100元每个月了。

GeForce Now价格表:

image-20240623180446219

开源解决方案

所以尝试了这么的云游戏发现都不好用之后,为什么不可以自己弄个呢?其实所谓的云游戏,无非是用客户端连接到主机端而已。那么我们实际上也可以把自己家里运行的PC或者主机,变成了由云游戏服务商提供的云上服务,这种行为就叫做串流游戏。

所以我们要做的是怎样把我们的私有网络的PC或者主机做成云服务提供给外网访问,让我们可以随时随地,只要有网就可以使用。

memory12

那么我的要求主要有这么几点:

  1. 要有跨平台的客户端,保证mac、iphone、android、win 都能用;
  2. 延迟要足够低,支持的可配置项要足够多;

正好自己有台闲置的 4090 的机器,那我就可以用它来作为主机端,我的安卓机作为客户端进行串流云游戏。

服务端 & 客户端

目前服务端主要有以下几个实现方案:

  1. N卡GeForce Experience
  2. Sunshine

客户端主要有:Moonlight

N卡GeForce Experience

如果你使用N卡,并且是GTX960以上可以通过GeForce Experience进行串流。只需要打开GeForce Experience在设置里找到SHELD这个串流配置,并添加游戏或者应用程序即可。

Featured image of post Nvidia Gamestream + Moonlight 如何串流桌面畫面

但是我现在不是很推荐这个方案,因为NV说过他们要把这个功能去掉,只是现在没有去掉而已。

Sunshine

它是一个开源推流方案 https://github.com/LizardByte/Sunshine , 属于通用串流方案,支持Nvidia、AMD、Intel。尤其适合核显串流(如果你用的是P106这类显卡没有视频编码器,只能使用Sunshine串流方案)。

下载Sunshine后首先需要运行服务端安装脚本install-service.bat,然后再运行sunshie。sunshine没有UI界面,设置需要通过网页端。运行sunshine后访问https://127.0.0.1:47990进行设置。设置里最重要的是进行PIN码配对,设备之间PIN匹配之后就可以进行串流了。

image-20230314210726196

Moonlight

Moonlight 以方便的将Windows电脑画面传输到各主流操作系统的客户端软件上,甚至可以直接传输至谷歌浏览器。画面方面,移动端最高支持4K120帧,且支持HDR(需要显卡支持),而桌面端甚至可以直接自定义分辨率和帧数;交互方面支持键鼠/手柄/触摸屏/触控板/触控笔,就像用自己的电脑一样使用远程电脑。该方案无广告,完全免费。

手机端可以在各大商店下载,也可以去 Moonlight官网地址:https://moonlight-stream.org 下载。如果使用iphone作为客户端,直接在App store下载Moonlight即可。

保持主机和客户端在同一局域网内,打开客户端软件,应该能够看到主机的计算机名。点击会弹出4位PIN码,需要在Sunshine配置网页 https://localhost:47990/pin 中输入PIN码。建立连接后,点击桌面(DESKTOP)将启动桌面串流。

网络配置好之后,在局域网内串流延迟通常相当的底,我经常躺床上用 pad 串流我书房的 pc 玩游戏,延迟只有几毫秒。

远程串流

由于Geforce Experience和Sunshine默认只在本地网络监听端口,客户端和主机位于同一局域网内才能连接成功,如果要真正实现远程连接,最简单稳定的方法是公网直连。

独一无二的IP地址使得主机能够在互联网中被识别,但是由于IPv4地址匮乏,大多数家庭网络并不具备公网IPv4地址。

所以我这里采用内网穿透的方式来构建我们的云服务:

Frame 2

内网穿透的核心思想就是“映射”和“转发”,把私有网络的设备的端口映射到公网设备的端口上,来进行流量转发。思想其实很简单,由于内网设备没有ip,那么我们通过一台有公网ip的机器来代替把流量做一层转发。比如上图,

我们在外网设置的用手机访问云服务器的 7000 端口,实际上云服务器会接收到之后通过47900进行转发到我们私有网络的pc机器,然后pc机器处理完之后再通过46900 端口转发给云服务器,上面所提到的端口都是可以自定义的。

那么对于做内网穿透一般现在流行两种做法:

  1. 直接 p2p 点对点的进行传输,流行的方案有 zerotier;
  2. 基于服务器的流量转发,流行的方案有 frp;

为什么会有内网穿透?

其实在互联网的世界中,如果每个用户都有真实的IP情况下,那么我们可以通过源IP+源端口+目标IP+目标端口+协议类型很容易的找到对方,是根本不需要P2P的,因为本来任何对象都可以作为Server或者Client来提供服务,彼此之间是可以互联。

但是IP和端口,是有限的,最初设计者也是没想到发展如此迅速,整个IPv4的地址范围,完全不够互联网设备来分配,那为了解决地址不够用的问题,就引入了NAT。

Frame 3

NAT(Net Address Translate,网络地址转换)是一种IP复用的一种技术,将有限的IP扩展成无限,由于IPv4地址资源有限,而NAT将网络划分成了公有网络和私有网络,允许多个设备使用一个公共IP地址访问互联网。路由器会将内部网络中的私有IP地址转换为公共IP地址,从而节省了IPv4地址资源。

所以我们在用 WIFI 的时候可以看到我们手机或PC上的IP地址通常是:192.168.x.xxx,这其实就是由路由器分配的地址,并不是真的地址。

另外,在 IPv4 地址资源越来越紧张的今天,很多电信运营商,已经不再为用户分配公网 IP;而是直接在运营商自己的路由器上运营 NAT,所以会出现甚至一整个小区共用一个 IP 出口的情况。

通过NAT技术的公私网络隔离,可以实现IP复用,解决了IPv4不够用的问题,但是也同时带来了新问题,那就是直接导致通信困难,由于NAT导致IP成为虚拟IP,外网无法针对内网某台主机进行直连通信,因为没有真实地址可用。

所以为了将NAT设备内外通信打通,就有了内网穿透技术。

zerotier

zerotier 是一个开源的内网穿透软件 https://github.com/zerotier/ZeroTierOne ,有社区版本和商业版本,唯一的区别是社区版本有 25台连接数量的限制,但对普通用户足够了,用它可以虚拟出一组网络,让节点之间的连接就像是在局域网内连接一样。

zerotier 底层是通过一个加密的p2p网络来实现连接。由于节点之间通常存在NAT隔离,无法直接通信,所以 zerotier 存在一个根服务器来帮助通路建设,所谓通路建设俗称打洞(hole punching),也就是穿透NAT隔离实现两个节点的连接。打洞也是区分 UDP 和 TCP 的,由于 zerotier 用的是 UDP,所以这里以 UDP 讲解打洞原理。

假设clientA 想要直接与clientB 建立UDP会话,用S表示根服务器:

A最初不知道如何到达B,因此A请求S帮助与B建立UDP会话,S会记录下他们各自的内外网IP端口:

Frame 4

打洞中:

S用包含B的内外网IP端口的消息回复A。同时,S使用其与B的UDP会话发送B包含A的内外网IP端口的连接请求消息。一旦收到这些消息,A和B就知道彼此的内外网IP端口;

当A从S接收到B内外网IP端口信息后,A始向这两个端点发送UDP数据包,并且A会自动锁定第一个给出响应的B的IP和端口;

B开始向A的内外网地址二元组发送UDP数据包,并且B会自动锁定第一个给出相应的A的IP和端口;

Frame 4

打洞后:

A和B直接利用内网地址通信

Frame 4

zerotier 的根服务实际上是部署在海外的,如果我们直接使用,很可能连不上,并且延迟基本在200ms以上,我们可以通过 zerotier-cli listpeers 查看根服务器:

# ./zerotier-cli listpeers
200 listpeers <ztaddr> <path> <latency> <version> <role>
200 listpeers 62f865ae71 50.7.252.138/9993;24574;69283 341 - PLANET
200 listpeers 778cde7190 103.195.103.66/9993;24574;69408 213 - PLANET  
200 listpeers cafe9efeb9 104.194.8.134/9993;4552;69462 159 - PLANET

上面的 PLANET 节点就是是ZeroTier网络中的根服务器。它们负责在对等点之间中继初始流量,帮助对等点建立对等连接,并充当身份和相关公钥的缓存。

我们随便 ping一下它的延迟:

# ping 50.7.252.138
PING 50.7.252.138 (50.7.252.138) 56(84) bytes of data.
64 bytes from 50.7.252.138: icmp_seq=1 ttl=46 time=347 ms
64 bytes from 50.7.252.138: icmp_seq=2 ttl=46 time=347 ms
64 bytes from 50.7.252.138: icmp_seq=3 ttl=46 time=354 ms

这样的游戏明显是玩不了云游戏的,zerotier 也考虑到这种延迟的情况,所以可以让有需要的用户自建 MOON 服务器。

ZeroTier中的MOON节点是用户定义的根服务器,可以添加到ZeroTier网络中。它的行为类似于ZeroTier的默认根服务器(称为PLANET节点),但由用户控制,我们可以把 MOON 服务器部署在离自己更近的地方,比如我就部署在广州,可这样以通过提供更近或更快的根服务器来提高网络性能。

怎样部署我这里就不贴教程了,可以自己去search一下,很简单。 但是现实场景中的网络要复杂的多,远远不是部署一个 MOON 节点就可以解决延迟的问题。通常我们的网络会涉及到防火墙限制、运营商级NAT、路由器兼容问题,还有就是 ZeroTier 走的是 UDP, 在国内的网络环境下一些运营商会对UDP流量实施QoS(服务质量)策略,,丢包可能会比较严重。

所以总之ZeroTier这条路并不是这么好走,看起来 p2p 直连貌似可以很美好,理论上可以不受根服务器的影响,两端直连跑满所有带宽,但实际上当不能打洞成功的时候那么就会退化成根服务器转发,那么实际的速率就取决于你自建的 MOON 节点的转发带宽了。

并且还有一个问题是,ZeroTier 是需要客户端的,到目前为止移动端的 app 是不支持添加自建 MOON 节点信息的,也就是说只能在电脑上进行串流,这实用性还是下降了不少。

所以总结一下优缺点:

优点:

  1. 组网非常方便,可以像局域网一样连接ZeroTier组网内的节点;
  2. 连接以及数据传输都是加密,所以比较安全;

缺点:

  1. 根服务器在海外,需要自建MOON,否则延迟很高;
  2. 依赖服务端,并且移动端app功能不完善;
  3. 受制于网络环境,p2p 打洞成功率低;

frp

frp 也是一个开源软件 https://github.com/fatedier/frp ,实际上它没有这么多花哨的功能,就是帮我们做了一个流量的转发。它的客户端连接不需要app,所以用来串流的话直接用moonlight直接连接frp远程转发服务器即可,可以说很方便了。

它的架构如下:

Frame 5

在安装frp远程转发服务的时候,我这里给一下配置,因为现在网上找的教程都是老的 ini 配置,现在新版本用的是 toml配置。

服务端的配置:

#frps服务监听的本机端口
bindPort = 9200
bindAddr = "0.0.0.0"
# frpc客户端连接鉴权token,默认为token模式
auth.token="xxxx" 

#日志打印配置
log.to = "./log"
log.level = "debug"
log.maxDays = 7

allowPorts = [
    // 远程连接需要用的端口
  { start = 47000, end = 48010 } 
]

sunshine主要连接的端口是这几个:

TCP 47984, 47989, 48010
UDP 47998, 47999, 48000, 48002, 48010

所以我们需要给这几个端口都加上防火墙,服务器和pc都要开放相应的端口,在测试的时候可以先全打开,测试完了再挨个加上,免得莫名其妙的问题。

pc端的配置:

#token需要与服务端的token一致
auth.token = "xxxxx"
# 服务端的公网ip
serverAddr = "1xx.xxx.xx.xx"
# 服务端的监听端口
serverPort = xxx

[[proxies]]
name = "47984"
type = "tcp"
localIP = "127.0.0.1"
localPort = 47984
remotePort = 47984

[[proxies]]
name = "47989"
type = "tcp"
localIP = "127.0.0.1"
localPort = 47989
remotePort = 47989

[[proxies]]
name = "47990"
type = "tcp"
localIP = "127.0.0.1"
localPort = 47990
remotePort = 47990

[[proxies]]
name = "48010"
type = "tcp"
localIP = "127.0.0.1"
localPort = 48010
remotePort = 48010

[[proxies]]
name = "47998"
type = "udp"
localIP = "127.0.0.1"
localPort = 47998
remotePort = 47998

[[proxies]]
name = "47999"
type = "udp"
localIP = "127.0.0.1"
localPort = 47999
remotePort = 47999

[[proxies]]
name = "48000"
type = "udp"
localIP = "127.0.0.1"
localPort = 48000
remotePort = 48000

[[proxies]]
name = "48002"
type = "udp"
localIP = "127.0.0.1"
localPort = 48002
remotePort = 48002

[[proxies]]
name = "48010"
type = "udp"
localIP = "127.0.0.1"
localPort = 48010
remotePort = 48010

pc端frp自动启动

windows系统开机自启比较麻烦,不像linux简单,所以为了保证windows后台运行 frpc,创建脚本 frpc.vbs,将以下内容粘贴进去:

set ws=WScript.CreateObject(“WScript.Shell”)
ws.Run “[frpc执行文件] -c [frpc配置]”,0

注意可能需要修改路径(默认路径是放C盘目录下)

将 frpc.vbs 放入 C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp 目录内,即可实现开机自启动。

远程唤醒(Wake On LAN)pc

家里的电脑如果经常开机的话很费电,所以按需开机是最佳办法,那么就需要远程登陆开机。远程唤醒需要主板的支持,现在的主板基本都支持。

首先我们要进入到主板的 BIOS 设置选项里面把 WOL 功能打开,具体方法视厂商而定,可以参考的关键词包括:

  • Automatic Power On
  • Wake on LAN/WLAN
  • Power Management
  • Power On by Onboard LAN
  • Power On by PCI-E Devices

然后在我们被唤醒的电脑里面找到网卡设置:

img

img

然后我们可以在内网尝试一下,是否可以唤醒成功,在应用市场随便找个WOL软件,填上内网被唤醒机器的IP地址和MAC地址即可:

img

外网唤醒,我们需要一个中间设备来中转我们的流量,因为我们需要被唤醒的机器已经被休眠了,是无法接收到请求的,所以我这里内网用我的软路由进行转发:

首先我们要做的就是 DHCP固定住自己内网PC的内网IP,要不然无法转发唤醒,通常可以在路由器里面设置:

image-20240629175131874

然后我是通过 OpenWrt 来和我远端服务器建立好 frp通信,监听转发端口,到时候外面的请求会先到 OpenWrt ,然后由它再转发给我的内网PC:

image-20240629175315251

最后如果觉得麻烦,其实可以用远程物理按键解决,一劳永逸:

image-20240626145854217

隐私屏 / 作为副屏

在用 sunlight 串流的时候由于显示的是桌面的,,因为串流软件会捕捉屏幕上的内容并编码成视频流。如果关闭屏幕,编码器将无法获取到需要的画面信息,导致串流中断。

那么如果我们想要关闭屏幕串流,那么可以用这个工具 https://github.com/VergilGao/vddswitcher ,通过 vdd 创建一个虚拟屏幕可以实现即使主屏关闭也能串流。

最后

游戏串流最后不仅满足了我在外网想要随时随地玩游戏的想法,并且还拯救了我的腰椎,在家里玩游戏现在基本是用平板串流到我的电脑上面,然后买个支架夹着我的平板,然后躺着玩,但愿各位游戏佬都能找到属于自己的游戏环境。

image-20240626152310201

Reference

https://github.com/VergilGao/vddswitcher

https://github.com/LizardByte/Sunshine

https://github.com/moonlight-stream/moonlight-qt

https://keenjin.github.io/2021/04/p2p/

https://sspai.com/post/68037

https://bford.info/pub/net/p2pnat/

扫码_搜索联合传播样式-白色版 1

构建属于自己的云游戏服务器最先出现在luozhiyun`s Blog

深入 RocksDB 高性能的技术关键

2024-06-01 18:03:14

本文从 RocksDB 基本架构入手介绍它是怎么运作的,以及从它的操作方式解释为什么这么快,然后探讨RocksDB 所遇到的性能挑战,各种放大问题是如何解决的,最后讨论一些新的 LSM 树优化方法,希望能对大家有所启发。

什么是RocksDB?

RocksDB 是一个高性能的 KV 数据库,它是由 Facebook 基于 Google 的 LevelDB 1.5构建的。RocksDB 被设计为特别适用于在闪存驱动(如 SSD)和 RAM 上运行,主要用于处理海量数据检索,以及需要高速存取的场景。 Facebook在 Messenger 上使用RocksDB,用户可以在其中体验快速消息发送和接收功能,同时确保其消息数据的持久性。

RocksDB 是一款内嵌式数据库使用 C++ 编写而成,因此除了支持 C 和 C++ 之外,还能通过 С binding 的形式嵌入到使用其他语言编写的应用中,如 https://github.com/linxGnu/grocksdb 。由于它是内嵌的数据库,所以它是没有独立进程的,它需要被集成进应用,和应用共享内存等资源,也没有跨进程通信的开销,也无法网络通信,也不是分布式的。

RocksDB 的设计目标是主要有以下几点:

  • 性能:高性能是RocksDB的主要设计点,它能提供快速存储和服务器工作负载的高性能,支持高效的点查找和范围扫描;
  • 生产支持:它内置了对工具和实用程序的支持,这些工具和实用程序有助于在生产环境中进行部署和调试;
  • 兼容性:此软件的较新版本应该向下兼容,以便现有应用程序在升级到较新版本的RocksDB时不需要更改;

基于以上这几点,如今很多分布式存储用它来做内部存储组建之一,如Apache Flink流处理框架中用作状态存储,它为维护流应用程序的状态提供快速高效的存储;TiDB 用它来构建存储引擎 TiKV,来支持大量的数据读写。

RocksDB architecture

RocksDB 基础的组件是MemTable、SSTable和 预写日志(WAL)日志。每当数据被写入RocksDB时,它会被添加到一个内存中的写缓冲区称为MemTable,同时也支持配置是否同步记录在磁盘上的预写日志(WAL)中,WAL主要用来做数据持久性和系统故障时的崩溃恢复使用,MemTable 默认是用跳表实现的,因此能保持数据有序,插入和搜索开销为 O(log n)。

img

MemTable 会根据配置的大小和数量来决定什么时候 flush 到磁盘上。一旦 MemTable 达到配置的大小,旧的 MemTable 和 WAL 都会变成不可变的状态,称为 immutable MemTable,在任何时间点,都只有一个活跃的 MemTable 和零个或多个 immutable MemTable。然后会重新分配新的 MemTable 和 WAL 用来写入数据,旧的 MemTable 会被 flush 到SSTable文件中称为L0层的数据。

被 flush 到磁盘上的 SSTable 会按照层级一层层存放,如上图从 L0 到 Ln,在每层中(级别0除外),数据被范围分区为多个 SSTable 文件。

这些 SSTables 都是是不可变的和有序的,每一层SSTable被组织成固定大小的块存放,每个SSTable都包含一个数据段和一个索引,可以通过二分查找快速查找数据,并且还可以通过布隆过滤器过滤无效数据,这种不变性、有序和索引结构的组合有助于RocksDB的整体性能和可靠性。

RocksDB是通过 LSM Tress 的方式通过将所有的数据添加修改操作转换为追加写方式,对于 insert 直接写入新的kv,对于 update 则写入修改后的kv,对于 delete 则写入一条 tombstone 标记删除的记录。

所以数据的查找会从 MemTable 内存数据开始,如果不存在,然后再从L0层级的 SSTable 开始找起,直到找到或者遍历完所有的 SSTable。SSTable 查找的时候会根据二分法加上布隆过滤器进行查找,过滤掉 key 不存在的 SSTable 文件,提升查询效率。

所以通过上面的简介可以知道对于 RocksDB 来说内部主要有以下几个结构:

  • MemTable:一个内存结构,所有的写入操作会先写入到这里,MemTable有好几种实现方式,默认使用跳表实现;
  • WAL日志:为了保证数据的持久性和一致性,用户写入的键值对首先被插入到WAL中。这确保了即使在发生故障时,也能从WAL中恢复数据;
  • SSTable(Sorted String Table):它是RocksDB中存储数据的基本单位,每个文件内部都是有序的。当MemTable写满之后会从磁盘flush到磁盘变成SSTable成为LSM树的 L0层级,当L0中的SSTable数量到达一定之后会出发 compaction 写入到下一层;

压缩(Compaction)

为什么会有Compaction

上面我们概述了一下 RocksDB 写入修改的过程是怎样的,数据首先会写入到 MemTable 中,当 MemTable 满了之后就会 flush 到磁盘中,称为 L0 级的 SSTable,L0 级的 SSTable 满了之后就会被 Compaction 到下一层级,也就是 L1 级中,以此类推。

如果没有 Compaction 行不行?直接把 L0 级的文件放入到 L1 中,这样不就省去了磁盘 IO 的开销,不需要重写数据,但是答案当然是不行。

因为 LSM Tree 通过将所有的数据修改操作转换为追加写方式,insert会写入一条新的数据,update会写入一条修改过的数据,delete会写入一条tombstone标记的数据,因此读取数据时如果内存中没有的话,需要从L0层开始进行查找 SSTable 文件,如果数据重复的很多的话,就会造成读放大。因此通过 Compaction 操作将数据下层进行合并、清理已标记删除的数据降低放大因子(Amplification factors)的影响。

一般我们说放大因子包括一下几种:

空间放大(Space amplification) :指的是需要使用的空间和实际数据量的大小的比值,如果您将10MB放入数据库,而它在磁盘上使用100MB,则空间放大为10。

读放大(Read amplification) :指的是每个查询的磁盘读取次数。如果每次查询需要读取5页来查询,则读取放大为5。

写入放大(Write amplification):指的是写入磁盘的数据与写入数据库的字节数的比值。比如正在向数据库写入10 MB/s,但是观察到30 MB/s的磁盘写入速率,您的写入放大率为3。如果写入放大率很高,高工作负载可能会在磁盘吞吐量上遇到瓶颈。如果写入放大为50,最大磁盘吞吐量为500 MB/s,那么只能维持 10 MB/s 的写入速度。

虽然 Compaction 可以降低放大因子的影响,但是不同的 Compaction 策略是对不同放大因子有侧重点,需要在三者之间权衡,后面我们会聊到。

什么是 Compaction

RocksDB的 Compaction 包含两方面:一是MemTable写满后flush到磁盘;二是从L0 层开始往下层合并数据。

最顶层的 L0 层级的 SSTable 是通过 MemTable 生成的,RocksDB的所有写入都首先插入到一个名为 MemTable 的内存数据结构中,一旦 MemTable 达到配置的大小,旧的 MemTable 和 WAL 都会变成不可变的状态,称为 immutable MemTable,然后会重新分配新的 MemTable 和 WAL 用来写入数据,旧的 MemTable 会被写入到SSTable文件中。

在任何时间点,都只有一个活跃的 MemTable 和零个或多个 immutable MemTable。 因为 MemTable 是有序的,所以 SSTable 文件也是有序的,所以 SSTable 都有自己的索引文件,通过二分查找来索引数据。

除L0层级的 SSTable 都是后台进程Compaction操作产生的。所以 Compaction 实际上就是一个归并排序的过程,将Ln层写入Ln+1层,过滤掉已经delete的数据,实现数据物理删除。

所以 Compaction 之后可以降低低放大因子的影响,使数据更紧凑,查找速度更快,但是因为会有一个 merge 过程,所以会造成写放大。

Compaction策略

现在主要的compaction策略就两种:Size-Tiered Compaction 和 Leveled Compaction,Leveled Compaction 是 RocksDB 中的默认 Compaction 策略。

Size-Tiered Compaction 策略

Size-Tiered Compaction 策略的做法相当简单。当新的数据写入系统时,首先被写入到内存中的一个结构 MemTable 中,一旦 MemTable 达到一定大小,MemTable会定期刷新到新的SSTable。

系统会监视 SSTable 的大小并将大小相似的 SSTables 分组。当一组中 SSTable 的数量达到预设的阈值(如 Cassandra 默认是 4),系统就会将这些 SSTable 合并成一个更大的 SSTable。在合并过程中,相同键的数据行会被合并,最新的更新会覆盖旧的数据。

如下4个小SSTable会合并成一个中等的SSTable,当我们收集到足够多的中等SSTable文件时,再将它们压缩成一个大SSTable文件,以此类推,压缩后的SSTable 越来越大。

Frame 4

Size-Tiered Compaction 的优点是简单且易于实现,并且SST数目少,定位到文件的速度快。缺点是空间放大比较严重。

Size-Tiered Compaction的空间放大

空间放大指的是需要使用的空间和实际数据量的大小的比值,Size-Tiered Compaction造成空间放大主要有这几个原因:

  • 数据重复存储。新的数据写入会导致创建新的SSTable,这意味着更新或删除的数据可以同时存在于多个SSTable中,直到发生Compaction操作,将多个SSTable合并成一个更大的文件。并且SSTable越大,Compaction操作越难触发,因为需要集齐多个同样大小的SSTable文件,这样导致数据保存了多份;
  • 临时空间需求。在Compaction操作中,删除较小、较旧的SSTable之前需要创建一个新的、较大的SSTable,这需要额外的磁盘空间,通常高达原始数据大小的50%以容纳新的SSTable和现有的SSTable,直到压缩完成并且可以安全删除旧的SSTable;
  • 高磁盘空间预留。由于临时空间需求,所以需要有一部分磁盘空闲(通常约为50%),以确保有足够的空间用于Compaction操作。这一要求有效地使数据库所需的磁盘空间翻倍,因为并非所有预留空间都被积极用于存储有用数据。

可以使用 cassandra 的极端的例子,在这个例子中,400万数据连续写入15次,写完之后将所有数据Compaction到一个文件中,显示磁盘使用与时间的图表现在如下所示:

img

由于最后进行了Compaction操作,所以在这张图中,我们可以看到我们数据库中真正拥有的数据量是1.2 GB。但是磁盘使用量的峰值是9.3 GB,并且在运行的大部分时间里,空间放大都高于3倍。

Leveled Compaction策略

Leveled Compaction 的思路是将原本 Size-Tiered Compaction 中原本的大 SSTable 文件拆开,成为多个key互不相交的小SSTable的序列。L0层是从 MemTable flush过来的新 SSTable,该层各个 SSTable 的key是可以相交的,并且其数量由配置控制,除L0外都是不相交的 SSTable。

Frame 6

其他层级中的每一个,L1、L2、L3等,每一层都有最大的大小,超过了层级的限制的最大大小会倍compaction 到下一层中,每一层的最大的大小通常呈指数增长。

Frame 7

在 RocksDB 中,当L0文件数达到 level0_file_num_compaction_trigger 时触发 compaction,会将所有 L0 的文件合并入 L1 中。

Frame 8

在L0 compaction 之后,可能会使 L1 超过其规定的大小,在这种情况下,我们将从L1中选择至少一个文件并将其与L2的重叠范围合并。结果文件将放置在L2中:

Frame 9

如果下一级的大小继续超过目标,那么会像以前一样执行操作,挑选一个文件进行合并。

所以由 Leveled Compaction 的 compaction 规则可以看出,它通过两种方式来解决空间放大的问题:

  1. Leveled Compaction 把文件都拆小了,所以在进行压缩的时候不需要这么大的临时空间;
  2. Leveled Compaction 除 L0 以外的每一层级数据都是互不相交的小SSTable的序列,数据上没有重叠,即使层与层之间有数据重叠,空间放大也是比较小的,这点我们可以算一下。例如,如果最后一级是L3,它有1000个SSTable。在这种情况下,L2和L1总共只有110个SSTable,那么L3 占全部的SSTable 90%,即使L1和L2都和 L3 重复,那么也就最多可以有1.11倍(=1/0.9)的空间放大。

img

同样1.2 GB数据集被一遍又一遍地写入15次。通过上图我们可以看到Leveled Compaction需要的空间要小的多,空间放大实际上达到了预期的1.1-2。

Leveled Compaction 虽然没有空间放大问题,但是随之而来的是写入放大的问题

Leveled Compaction的写入放大

写入放大指的是写入磁盘的数据与写入数据库的字节数的比值。在写入数据的时候 RocksDB 会有多次写磁盘的操作,如下图所示显示的是 Size-Tiered Compaction 策略写入放大情况,每个字节的数据都必须写入4次,有多少层就会写入多少次,还会写入一次 WAL log,至少4的写入放大。

Frame 10

但是 Leveled Compaction 是需要挑选上一层的一个 SSTable 然后找到下一层的重叠的SSTable进行合并写入,Ln层SST在合并到Ln+1层时是一对多的,如果下一层是上一层的十倍,那么在选择一个大小为X的sstable进行压缩的时候,它在下一个更高级别中会找到与此sstable重叠的大约10个sstable,并将它们与一个输入sstable进行压缩,它将大小约为11*X的结果写入下一个级别。所以在最坏的情况下, Leveled Compaction 可能比Size-Tiered Compaction 多写11倍。

写入放大最大最大值我们也可以很简单计算出来。首先假设每一层的级别乘数为10,L1 大小为 512MB,数据大小为 500GB,那么L2大小为5GB,L3为51GB,L4为512GB,因为数据大小为 500GB,所以更高级别将为空。

那么我们可以简单的算出空间放大为(512 MB + 512 MB + 5GB + 51GB + 512GB) / (500GB) = 1.14

计算写放大的时候可以从顶层开始写入。每个字节写到L0,然后将其压缩到L1,由于L1大小与L0相同,因此L0->L1压缩的写放大为2。然而,当来自L1的字节被压缩到L2时,它将被压缩为来自L2的10个字节(因为2级大10倍)。L2->L3和L3->L4压缩也是如此。因此,总写入放大大约为 1 + 2 + 10 + 10 + 10 = 33

写放大会带来两个风险:一是更多的磁盘带宽耗费在了无意义的写操作上,会影响读操作的效率;二是对于闪存存储(SSD),会造成存储介质的寿命更快消耗,因为闪存颗粒的擦写次数是有限制的。

RocksDB 的优化目标与 Dynamic Leveled Compaction

RocksDB 的优化目标最初是减少写放大,之后过渡到减少空间放大。在 RocksDB 上 Leveled Compaction 压缩方式的写放大通常在 10~ 30之间,在许多情况下,与 MySQL 中使用的 InnoDB 引擎相比,RocksDB 的写数量仅为其的 5% 左右。但是这个量级的写放大在频繁写的应用场景下面还是太大了,所以 RocksDB 引入了 Tiered Compaction 压缩方式,它的写放大只有 4–10 。

Frame 2

在经过若干年开发后,RocksDB 的开发者们观察到对于绝大多数应用来说,空间使用率比写放大要重要得多,此时 SSD 的寿命和写入开销都不是系统的瓶颈所在。实际上由于 SSD 的性能越来越好,基本没有应用能用满本地 SSD,因此 RocksDB 开发者们将其优化重心迁移到了提高磁盘空间使用率上。

RocksDB 开发者们引入了 Dynamic Leveled Compaction 策略,此策略下,每一层的大小是根据最后一层的大小来动态调整的。

我们来看个例子,如果不是动态调整,我们假设我们设置了 RocksDB 有4个层级,它们的大小是1GB、10GB、100GB、1000GB,如果数据都放满的话,那么空间放大将会是 (1000GB+100GB+10GB+1GB)/1000GB=1.111 ,可以看到空间放大非常小。但是实际中,是很难恰好让最后一级的实际大小是1000GB,如果在生产中,数据只有200GB,那么空间放大将是(200GB+100GB+10GB+1GB)/200GB=1.555

Frame 11

所以动态级别大小目标是根据最后一级的大小动态改变的。假设级别大小乘数为10,DB大小为200GB。最后一级的目标大小自动设置为级别的实际大小,即200GB,倒数第二个级别的大小目标将自动设置为size_last_level/10=20GB,倒数第三个级别的size_last_level/100=2GB,倒数第四个级别是 200MB。这样,我们就可以实现1.111的空间放大,而不需要对级别大小目标进行微调。

此策略的效果如下所示,Dynamic Leveled Compaction 策略将空间开销限制在13%,而Leveled Compaction策略在空间开销上可以超过 25%,在Facebook实际应用中,使用RocksDB替换InnoDB作为UDB数据库的引擎,可以使空间占用减少50%。

memory5

所以 RocksDB 应用程序的所有者应该选择合适的 compation 方式,来实现压缩率、写入放大、读取性能之间的平衡。

那么究竟如何降低写入放大?!

通过上面的分析,我们知道在 RocksDB 中的 compation 策略总有一定的问题,Size-Tiered Compaction 会增加空间放大,因为 SSD 成本比较高,后面 RocksDB 转向减少空间放大使用 Leveled Compaction 以及后面推出的微调版本 Dynamic Leveled Compaction 。

但是貌似写入放大的问题并 RocksDB 没有解决,因为我们知道 SSD 的擦写次数是有限制的,如果频繁的擦写也会减少 SSD 的寿命,增加 SSD 的使用。那么在业界,是如何解决 LSM 树写入放大问题的呢?主要有两种方式:

  • Key-Value分离,如 WiscKey在 LSMs 结构中存储 Key 和 一个指向相应 value 位置的指针,而Value 存在另外一个结构中,降低 LSM 树大小;
  • 优化LSM树结构,降低compaction次数,如PebblesDB借鉴skiplist思路,通过层级的结构性优化减少不必要的数据overlap,从而在整体上减少数据参与compaction次数。

下面我们就WiscKey和PebblesDB仔细聊聊他们是怎么做的。

WiscKey

FAST16,WiscKey: Separating Keys from Values in SSD-Conscious Storage

WiscKey 主要思想就是将key 和 value 剥离,在LSM树中只保留key值,用一个指针指向 value 的位置。因为一般情况下,Key比 Value小的多,所以这样做可以缩小 LSM 树的大小,降低写入放大。举个例子,假设现在 key 大小 1B,Value 大小 1KB,在LSM 树中按照10倍的写入放大来说,那么根据公式,实际的写入放大应该是:

写入放大 = 写入数据的大小/数据实际的大小 = (10*16+1024)/(16+1024) = 1.14

不过这样做的弊端是查找的时候比传统LSM树查找多一次IO,找到 key 之后需要再取 value。但是相对来说更小的LSM树也会有更好的查找性能,在LSM树中,查找可能会搜索更少层级的SSTables文件,并且由于LSM树很小,所以它的大部分内容可以很容易地缓存在内存中,所以在缓存住 key 的情况下,只有一次随机IO查找 value 的开销,大多数情况还是比 LevelDB要快的。

image-20240516173122366

WiscKey 的架构其实很简单,像上面的图一样,把 key 和 value 分开存储,value 这部分的文件叫做 value-log file 简称 vLog。

在插入数据的时候会将 value 数据 append 到 vLog 里面,然后写入一条key数据到 LSM 树里面,key 数据里面还包含了 value 的偏移和大小,类似这样 (<vLog-offset, value-size>)。在删除的时候还是只和LevelDB一样,写入一条标记了删除的记录到 LSM 树,vLog 里面的 value 数据随后会由一个单独的线程进行垃圾收集。

image-20240517151807591

为了能够实现更轻量化的 vLog 垃圾收集,vLog 不单只保存了 value 还保存了 key 值,保存的格式如上图为 (key size, value size, key, value)。在 vLog 中还用了 tail 和 head 表示头和尾,新的数据都从 head append 进入到文件里面。

在垃圾回收的时候会直接从 tail 读取一批数据,然后通过查询 LSM 树找出其中哪些值是有效的,将有效的数据 append 进 vLog 的 head,然后垃圾收集器会将这些被重新 append 的数据新的地址值 append 进入 LSM树,并更新 tail 位置的地址。

PebblesDB

SOSP17,PebblesDB: Building Key-Value Stores using Fragmented Log-Structured Merge Trees

PebblesDB 主要使用了一种新的数据结构 Fragmented Log-Structured Merge Trees (FLSM),它借鉴了 skiplist + lsm 的结构。通过这种结构,PebblesDB 在论文中提到了,它的写的的吞吐量是 RocksDB的 6.7倍,读的吞吐量比 RocksDB 高 27%,写入IO比RocksDB减少了2.4-3倍。

在 skiplist 中,其实是通过类似下面这样建立多层级的索引,上面的层级索引的节点实际上代表的一个范围内的数据,并且每一层的索引都是有序的列表,我们可以通过下图简明的看一下怎么查找 u 节点。

Frame 12

FLSM 就借用了上面索引的概念,定义了 Guards 结构来组织数据。Guards 就是 skiplist 里面的索引的概念,它会在插入数据的时候随机选择插入的key作为 Guard,L0 没有 Guard。

Guards 数量会随着层级的增加而增加,并且上层被的 Guard 也会带入到下一层,如下图 L1 中 5 被作为 Guard,那么 L2 和 L3 中它依然是 Guard。Guards 里面由一个个 SSTable组成,在同一层之间 Guards 是有序排列的,没有重叠,但是在单个 Guard 里面的 SSTable 是有可能重叠的。

Guard 和 skiplist 索引里面的作用一样,用来限定数据范围,如下图L1中,SSTable key 值超过 5 的都被放入到 Guard5 中,小于 5 的 SSTable 被放入到 Sentinel 中存放。L2 中 key 超过 375 的值都被放入到 Guard375 中,依次类推。所以 L1 Guard5 代表的其实是 [5,∞), L2 Guard5 代表 [5,375),Guard375代表 [375,∞)。这是一个左开右闭的合集。

image-20240514150432978

在大多数情况下,FLSM 的 compaction 不会重写 SSTables。在 PebblesDB 中,数据是通过 Guards 来组织的,这些 Guards 用于指示给定键范围在某一层级上的位置。每个 Guard 可以包含多个重叠的 SSTables。当一个层级上的 Guards 数量达到一个预设的阈值时,这些 Guards 和相应的键会被移动到下一个层级,这一过程通常不需要重写 SSTables,这是 FLSM 减少写入放大的主要方法。

PebblesDB 相比其他几个引擎,写放大是有显著的优势。下图显示了在插入或更新5亿个键值对(总计45 GB)时,不同键值存储引擎的总写入IO量(以GB为单位)。

Frame 3

总结

我们从 RocksDB 的 LSM Tree结构入手解释了 RocksDB 通过将所有的数据修改操作转化为追加写方式从而提高了数据操作的性能,解释了为什么这种结构支持高效的数据读写操作,然后说明了这种结构所引发写放大、读放大和空间放大等问题,以及对于 RocksDB 是如何通过 Compaction 策略去解决相应的放大问题。

此外,文章还探讨了如 WiscKey 和 PebblesDB 等新的 LSM 树优化方法,这些方法通过结构和操作的改进,旨在降低写放大,从而提高数据库的整体效率和性能。

Refercence

《Evolution of Development Priorities in Key-value Stores Serving Large-scale Applications: The RocksDB Experience》

《PebblesDB: Building Key-Value Stores using Fragmented Log-Structured Merge Trees》

《WiscKey: Separating Keys from Values in SSD-Conscious Storage》

https://artem.krylysov.com/blog/2023/04/19/how-rocksdb-works/

https://github.com/facebook/rocksdb/wiki/RocksDB-Tuning-Guide

https://bu-disc.github.io/CS591A1-Spring2020/slides_projects/RocksDB-Exploring-Compaction-Algorithms.pdf

https://github.com/facebook/rocksdb/wiki/Leveled-Compaction

https://github.com/facebook/rocksdb/wiki/Compaction

https://tidb.net/blog/eedf77ff#2%20%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%20compaction%20?

https://engineering.fb.com/2018/06/26/core-infra/migrating-messenger-storage-to-optimize-performance/

https://github.com/facebook/rocksdb/wiki/RocksDB-Overview

https://www.scylladb.com/2018/01/17/compaction-series-space-amplification/

https://www.scylladb.com/2018/01/31/compaction-series-leveled-compaction/

https://rocksdb.org/blog/2015/07/23/dynamic-level.html

https://zhuanlan.zhihu.com/p/490963897

扫码_搜索联合传播样式-白色版 1

深入 RocksDB 高性能的技术关键最先出现在luozhiyun`s Blog

透过ClickHouse学习列式存储数据库

2024-04-05 14:47:26

什么是列式存储数据库

我们平时见到的最多的就是行式存储数据库,如:MySQL、PostgreSQL等,它们通常是将属于同一行的值存储在一起,它的布局非常的像我们 Excel 表格的布局,比如下面面向行的数据库存储用户数据:

|ID|Name |Birth Date|Phone Number|
|10|John |1-Aug-82  |1111 222 333|
|20|Sam  |14-Sep-88 |5553 888 999|
|30|Keith|7-Jan-84  |3333 444 555|

在需要按行访问数据的情况下,面向行的存储最有用,将整行存储在一起可以提高空间局部性。因为对磁盘来说不管是 HDD 还是 SSD 通常都是按块访问的,所以单个块可能将包含某行中所有列的数据。这对于我们希望访问整个用户记录的情况非常有用,但这样存储布局会使访问多个用户记录某个字段的查询开销更大,因为其他字段的数据在这个过程中也会被读入。

比如上面这个用户表的数据,我这样查询:

select Name from user;

我只需要 Name 这个字段,但是每次都会都会逐行扫描、并获取每行数据的全部字段,然后返回 Name 字段。这是由行式存储的存储方式决定一定需要这样做。

而对于列式存储数据库同一列的值被连续的存储在磁盘上,例如我们想要存储股票市场的历史价格,那么股票这一列的数据便会被存储在一起。将同一列的值存储在一起可以便于按列查询,不需要对整行进行读取后再丢弃掉不需要的列。列式存储数据库非常适合计算聚合的分析型需求,如查找趋势、计算平均值等。

所以列式存储数据库的存储布局由于需要按列存储,所以很容易你可以想到,最简单的列存储引擎数据可能长这样:

Name:          10:John;20:Sam;30:Keith
Birth Date:     10:1-Aug-82;20:14-Sep-88;30:7-Jan-84
Phone Number:   10:1111 222 333;20:5553 888 999;30:3333 444 555

那么我们如果只查询 Name 这个字段,列式存储数据库就可以直接扫描 Name 这列数据的文件并返回,从而避免了多余的数据扫描。

ClickHouse 简介

ClickHouse 是一个开源的列式数据库管理系统(DBMS),专门设计用于在线分析处理查询(OLAP)。由俄罗斯的Yandex公司开发,首次发布于2016年,它的主要目标是快速进行数据分析。也就是说 ClickHouse 主要用于在大数据的场景下做一些实时的数据分析。

得益于列式存储的特性,以及 ClickHouse 做的诸多优化,使得它在批量查询分析上面非常的具有优势。 对一张拥有 133 个字段的数据表分别在 1000 万、1 亿和 10 亿三种数据体量下执行基准测试,基准测试的范围涵盖 43 项SQL查询。在 1 亿数据级体量的情况下,ClickHouse 的平均响应速度是 Vertica 的2.63倍、InfiniDB 的 17 倍、MonetDB 的 27 倍、Hive 的 126 倍、MySQL 的 429 倍以及 Greenplum 的 10 倍。

具体的Benchmark可以看这里:https://benchmark.clickhouse.com/,这里随便放一下对比差异

image-20240323195542474

基于 ClickHouse 这么快的查询和分析速度,所以一般可以用来:

  • 数据分析
  • 数据挖掘
  • 日志分析

Clickhouse是一个支持多种数据存储引擎的数据库。它可以将几乎任何数据源导入Clickhouse数据库,并支持快速灵活的下钻分析。例如,微信目前使用Clickhouse来存储日志数据,因为日志通常包含大量重复项。使用Clickhouse可以实现高压缩比,减少日志占用的存储空间。Cloudflare、Mux、Plausible、GraphCDN和PanelBear等公司使用Clickhouse来存储流量数据,并在其仪表板中向用户呈现相关报告。Percona也在使用Clickhouse来存储和分析数据库性能指标。

但是 Clickhouse 不能替代关系数据,ClickHouse 不支持事务,并且希望数据保持不变,尽管从技术上讲可以从ClickHouse数据库中删除大块数据,但速度并不快。ClickHouse根本不是为数据修改而设计的。由于稀疏索引,按键查找和检索单行的效率也很低。

ClickHouse 数据存储 & 索引

ClickHouse 的数据全都在 /var/lib/clickhouse/data 这个目录下面,比如我按照官网教程 https://clickhouse.com/docs/en/getting-started/example-datasets/ontime#creating-a-table 创建一个这样的表:

CREATE TABLE hits_UserID_URL
(
    `UserID` UInt32,
    `URL` String,
    `EventTime` DateTime
)
ENGINE = MergeTree
PRIMARY KEY (UserID, URL)
ORDER BY (UserID, URL, EventTime)
SETTINGS index_granularity = 8192, index_granularity_bytes = 0;  

在这个表里面我们指定了一个复合主键(UserID, URL),排序键(UserID, URL, EventTime)。如果只指定排序键,那么主键会被隐式设置为排序键。如果同时指定了主键和排序键,则主键必须是排序键的前缀。

执行成功后会在文件系统 /var/lib/clickhouse/data/default/hits_UserID_URL 中创建如下的目录结构:

.
├── all_1_1_0
    ...
│   ├── primary.cidx 
│   ├── URL.bin
│   ├── URL.cmrk
│   ├── UserID.bin
│   └── UserID.cmrk
├── all_1_7_1
    ...
│   ├── primary.cidx
│   ├── serialization.json
│   ├── URL.bin
│   ├── URL.cmrk
│   ├── UserID.bin
│   └── UserID.cmrk 
├── detached
└── format_version.txt

/var/lib/clickhouse/data 目录里面的一层 default 表示 database 名称,没有指定默认就是default,然后再往里面就是 hits_UserID_URL 表示表名,all_1_1_0 表示分区目录。

然后就是列字段相关的文件了,每列都会有两个字段:

{column}.bin:列数据的存储文件,以列名+bin为文件名,默认设置采用 lz4 压缩格式;

{column}.cmrk:列数据的标记信息,记录了数据块在 bin 文件中的偏移量;

primary.cidx:这个是主键索引相关的文件的,用于存放稀疏索引的数据。通过查询条件与稀疏索引能够快速的过滤无用的数据,减少需要加载的数据量。

因为我们设定了主键和排序键,所以数据在写入的时候会按照 UserID、URL、EventTime 顺序写入到bin 文件里面:

clickhouse1

因为主键的顺序和文件的写入是相关的,所以一张表也只能有一个主键。

最小数据集 granule

出于数据处理的目的,表的列值在逻辑上被划分为 granule。granule 是为进行数据处理而流式传输到ClickHouse中的最小的不可分数据集。这意味着ClickHouse不是读取单个行,而是始终读取(以流式传输方式并行读取)整个组(granule)行数据。

比如我们上面在创建表的时候指定了 index_granularity 为 8192,即数据将会以 8192 行为一个组,表里面我们插入了 886w 条数据,那么就分为了 1083 个组:

Frame 1

每个 granule 分组的第一条数据会被写入到 primary.cidx 当作索引处理。

主键索引 primary.cidx

ClickHouse 是通过稀疏索引的方式来构建主键索引的,所以它只记录 granule 的开始位置,一条索引记录就能标记大量的数据。所以像我们上面的例子中,886w 条数据,只有 1083 个 granule ,那么索引数量也只有 1083 条,索引少占用的空间就小,所以对 ClickHouse 而言,primary.cidx 中的数据是可以常驻内存。

Frame 2

再加上数据存储的时候就是顺序存储,所以 ClickHouse 在利用索引过滤查找数据的时候可以用二分查找快速的定位到索引数据位置。

但是由于 granule 是个逻辑数据块,我们并不直接知道它在数据文件(.bin)中的存储位置。因此,我们还需要一个文件用来定位 granule,这就是标记(.mrk)文件。

标记文件 .mrk

这里需要说明一下,根据 ClickHouse 版本、数据情况、压缩格式,标记文件会有不同的结尾,如:cmrk 、cmrk2、mrk2、mrk3 等等,由于它们的作用都是用来做文件映射,找到数据的物理地址用的,所以这里都叫它们 mrk 标记文件好了。

对于 mrk 标记文件每一行包含了两部分的信息,block offset 以及 granule offset,在 bin 文件中,为了减少数据文件大小,数据需要进行压缩存储。如果直接将整个文件压缩,则查询时必须读取整个文件进行解压,显然如果需要查询的数据集比较小,这样做的开销就会显得特别大。因此,数据是以块(Block) 为单位进行压缩,一个压缩数据块可以包含若干个 granule 的数据,如下:

Frame 3

比如上面我们通过 primary.cidx 找到了对应数据所在 mark176,然后就可以对应的去 mrk 里面找对应的 block offset 和 granule offset。然后通过 block offset 找到该数据文件包含的压缩数据。一旦所定位的文件块被解压缩到主内存中,就可以使用标记文件的 granule offset 在未压缩的数据中定位对应的数据。

Frame 4

联合主键查询

像我们上面的例子中,key 设定的是 UserID, URL 两个字段,这种 key 被称为联合主键。机遇我们上面给出的 ClickHouse 主键索引构造的方式可以很容易想到,如果 where 条件里面包含了联合主键的第一个键列,那么ClickHouse可以使用二分查找法进行快速的索引。

但是,当查询过滤的列是联合主键的一部分,但不是第一个键列时会发生什么?比如我们 where 条件里面只写了 URL='xxx',那么ClickHouse会执行全表扫描,因为数据是首先按 UserID 排列,当 UserID 相同时,才会按照 URL 排列,那么URL可能分布在各个地方。

delete & update 操作

前面我们也说了,ClickHouse 主键或排序键都是按照顺序存储,然后按 block 进行压缩存储,那么如果删除又是怎样做的呢?

Clickhouse是个分析型数据库。这种场景下,数据一般是不变的,因此Clickhouse对update、delete的支持是比较弱的。标准SQL的更新、删除操作是同步的,即客户端要等服务端反回执行结果,而Clickhouse的update、delete是通过异步方式实现的。

对于删除操作来说有两种方式 DELETE FROMALTER…DELETE

对于 DELETE FROM 操作操作来说,只是对已经删除的数据做了个标记,表示已经删了,并将自动从所有后续查询中过滤出来。但是,在下一次数据合并整理期间会清理数据。因此,在可能存在一定的时间段内,数据可能不会从存储中实际删除,而只是标记为已删除。这种删除属于轻量级删除,这种方式的缺点是数据并没有立即从磁盘种被清理。

执行删除时,ClickHouse为每一行保存一个掩码,指示它是否在 _row_exists 列中被删除。后续查询反过来会排除那些已删除的行。

lightweight_deletes_v2.png

对于立即需要从磁盘中清理需求,可以通过使用ALTER…DELETE操作:

 alter table  hits_UserID_URL delete where  UserID = 240923

ALTER…DELETE操作默认情况是异步执行的,我们可以通过 system.mutations 表执行监控:

SELECT
    command,
    is_done
FROM system.mutations
WHERE `table` = 'hits_UserID_URL'

Query id: 78c3169a-abbc-415a-bcb2-0377d29fa547

┌─command──────────────────────┬─is_done─┐
│ DELETE WHERE UserID = 240923 │       1 │
└──────────────────────────────┴─────────┘

如果 is_done 的值为 0 ,表示它仍在执行中, is_done 的值为 1表示执行完毕。

对于 update 来说,主键的列或者排序键的值就不能被更改了,这是因为更改键列的值可能会需要重写大量的数据和索引,这对于一个以高性能读操作为设计目标的列式数据库来说,是非常低效的。所以只能修改非主键的列或者排序键的值。更新ClickHouse表中数据的最简单方法是使用ALTER… UPDATE语句。

ALTER TABLE hits_UserID_URL
    UPDATE col1 = 'Hi' WHERE UserID = 240923

ALTER… UPDATE操作和ALTER…DELETE一样都同属于 mutation 操作,都是异步的。那么对于异步的方式来更新删除数据,就会涉及到一致性的问题。

mutation_01.png

像上图所示,mutation 操作具体过程实际上分为这么几步:

  1. 使用where条件找到需要修改的分区;
  2. 重建每个分区;
  3. 用新的分区替换旧的;

数据的重建替换不是全部同时执行的,而是分批执行的。

mutation_progress.png

如果在重建替换的过程中,用户去查询数据,如果查询跨越了部分已经被更新的数据段和部分尚未被更新的数据段,用户可能会同时看到旧数据和新数据,也就是说ClickHouse是不做原子性保证的。

按照官方的说明,update/delete 的使用场景是一次更新大量数据,也就是where条件筛选的结果应该是一大片数据。

总结

通过上面ClickHouse的分析我们大概知道了列式数据库有啥优缺点,优点是可以忽略不需要的字段查询非常快,数据可以压缩,索引使用稀疏索引的方式不需要占用很多空间,可以支持超大量的数据分析;缺点是没有事务,对于单行数据的查询效率并不高,删除修改效率很低。所以在选型上面可以根据自己的业务需求,如果是有大量数据分析的需求,不妨试一下 ClickHouse。

Reference

《Database Internals A Deep-Dive into How Distributed Data Systems Work》

https://clickhouse.com/docs/en/optimize/sparse-primary-indexes

https://www.cnblogs.com/traditional/p/15218565.html

https://xie.infoq.cn/article/9f325fb7ddc5d12362f4c88a8

https://clickhouse.com/docs/zh/engines/table-engines/mergetree-family/mergetree#mergetree-data-storage

https://zhuanlan.zhihu.com/p/646518360

https://medium.com/@hunterzhang86/supercharge-your-data-processing-why-clickhouse-is-a-game-changer-dc3e43f23f9e

https://chistadata.com/why-clickhouse-is-so-fast/

https://clickhouse.com/docs/zh/guides/improving-query-performance/skipping-indexes

https://clickhouse.com/blog/handling-updates-and-deletes-in-clickhouse

https://altinitydb.medium.com/updates-and-deletes-in-clickhouse-d5df6f336ce9

扫码_搜索联合传播样式-白色版 1

透过ClickHouse学习列式存储数据库最先出现在luozhiyun`s Blog