最近 Google 发布了 Gemma,是 Gemini 的低配版本,既然是 Google 出品那我一定要来吃螃蟹的。所以我本地部署了一个 7b 的版本来尝试使用一下看看效果。同时也来说明一些有关大模型本地部署使用的一些个人体会,比如,你可能会有以下问题:
首先,我想敲醒你沉睡的脑子。对于本地部署模型,你先要问清楚自己想要的是什么?也就是为什么需要本地部署,如果仅仅是想跑着玩,那没问题。如果只是平常使用,并且你已经能用 GPT 了,本地其实对于你来说毫无意义,因为你指望你的小电脑哪怕是大显卡能和别人成吨的 A100 相比吗?(夸张的修辞) 如果,醒了还是想玩,那么可以往下看了,最后我会总结本地去跑有哪些优势。
这里我推荐两个:
这二者基本都已经做到了开箱即用的地步了,其中我会更喜欢 ollama 一点。所以我就简单列一下它的步骤(其实官网已经描述的非常详细了,也很简单 https://github.com/ollama/ollama)
ollama run gemma
对的,直接在命令行里面就能直接开始问了,并且也提供了 API 接口。如果你需要一个 UI 界面,我推荐使用 https://github.com/open-webui/open-webui 也是一个命令就可以直接在本地运行
1 | docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main |
我本机是 MPB M1 16+256 的配置,一般回答结果反应在 20-30s 左右,根据具体问题的情况来看。我觉得,作为你个人使用,已经是够了。毕竟我也算是老设备了。
然后我给出我的跑的建议(个人总结,仅供参考,能不能跑起来还是实际说了算):
有大显存显卡的用户肯定会更吃的开一点,但我要说的重点其实是在后面
能用!但又不完全能用。(感觉是不是像废话)听我慢慢道来。
我不像很多 AI 模型的专业测试一样去测试各种疑难杂症,或者是测试各种幻觉问题或者是违法问题。我就是平常人最普通使用,哪里来那么多破事情呢?下面几个场景我相信能反应一些问题了
自从用了 AI 翻译之后我是再也不想用原来的普通翻译了,那种 one by one 中式翻译不可谓不难受。对于翻译任务来说,我觉得 Gemma 是可以帮助到你的,虽然依据可能有语法错误,但比一般的翻译好,它能理解一些语意意义的翻译。
数学问题别想了,很容易翻车,其他场景问题还可以。所以如果用它来做题,我不建议。
比如,二进制转换,显然到 15 这里就不对了。
比如,求和,显然也不对。
我测试了 go 和 rust,go 不错,基本能实现思路,但也会有莫名奇妙不能被调用的方法,rust 可能不太行,也可能是我水平不够,至少编译总是有各种问题。
可以的,我发现 AI 的总结能力还是比较强的,只要给出的内容不是特别零散的,它都能总结的不错,推荐的。
别想了,这我原来也没有期望,当他开始胡说八道的时候我就知道不行了。
可以的,重复劳动有很多的,比如修改各种文章格式,空格、大小写等;再比如对齐;处理表格,简单计算求和。通常来说只要 prompt 写好,来回几次,基本就能得到想要的结果了。
对于本地部署,我想你肯定是有这几方面的考虑:
那么,我想告诉你的是,对于现阶段而言,基于我本地部署使用了一段时间之后,我会推荐给想要做本地总结和翻译的用户,这二者的使用上其实是让我满意的,也能达到我的基本需求。其他工作,我还是会直接尝试使用 GPT 来帮助我完成会更加靠谱。
今天来水一篇 MySQL 的问题。什么是索引下推?其实很多概念都是被中文名字唬住了,这个概念并不复杂。如果看得懂英文,建议直接跳转 Index Condition Pushdown Optimization
这是一个前置知识点。当我们通过索引找到某条数据时,其实只是找到了它对应的 id,需要根据 id 回到原来的表里面把数据捞出来,这个过程就是回表。
直接用官方的例子说明:有一个索引 INDEX (zipcode, lastname, firstname)
有一个查询如下:
1 | SELECT * FROM people |
本来是不能命中全部索引的,因为后面两个条件是左 % LIKE,当然最左匹配原则可以先利用 zipcode 定位到数据。下面就是关键了:
如果,没有索引下推,那么当找到这条数据时,需要回表找到原数据来判断是否满足条件。
如果,使用索引下推,那么此时可以直接推理判断是否当然索引数据满足条件。
即:索引下推其实就是为了减少回表次数的一种优化。
MySQL can use the index to scan through people with
zipcode='95054'
. The second part (lastname LIKE '%etrunia%'
) cannot be used to limit the number of rows that must be scanned, so without Index Condition Pushdown, this query must retrieve full table rows for all people who havezipcode='95054'
.
With Index Condition Pushdown, MySQL checks the
lastname LIKE '%etrunia%'
part before reading the full table row. This avoids reading full rows corresponding to index tuples that match thezipcode
condition but not thelastname
condition.
官方也说了一些不能触发索引下推的条件,其实都很符合直觉:
其实关键还是满足 range, ref, eq_ref, and ref_or_null 这几个条件
不理解概念其实没关系,总结其实我还是那句话,写 SQL 的 where 条件的时候,将确定的条件按字段顺序放前面,将不确定的条件放后面,这样会给以后的优化留下很大的余地,剩下的问题就交给索引、MySQL 和 DBA 吧。
]]>📢 注意,该文本非最终版本,正在更新中,版权所有,请勿转载!!
作为 k8s 的使用者而非维护者来说,对于 k8s 的 GC 其实是很难接触到的(几乎是无感的)。这也就是为什么标题写的惊讶 “原来 k8s 也有 GC”。GC 这个概念在很多语言中都有的,比如 Java 和 Golang,它就是帮助我们来回收垃圾的。在编程语言中,GC 主要是回收那些垃圾对象;那么相对于的 k8s 中,GC 需要回收哪些资源呢?今天的内容不复杂,源码里面都是那种很符合直觉的实现。
其实,我一开始最好奇的就是镜像,由于 docker 镜像的大小我们是可想而知的。就算是我们常常使用的本地电脑,磁盘都有可能被占用很多,更别提是服务器这种动不动就更新镜像的情况了。
今天的入口还是比较好找的,因为很明确的命名 GarbageCollection
找到它,肯定就是了。首先,我们依旧先来看接口
1 | // pkg/kubelet/kubelet.go:231 |
接口在 Bootstrap
中有定义于是就容易找到具体实现了。于是我们就找到了 StartGarbageCollection
的具体实现,同样的,我们去掉不想干的日志和分支。主干如下:
1 | // pkg/kubelet/kubelet.go:1395 |
显然,这里启动了两个定时任务,一个是 ContainerGC
一个是 ImageGC
,ContainerGCPeriod
是 1 分钟,ImageGCPeriod
是 5 分钟。从名字来看这里我们已经可以看出一些端倪了。一个是对于容器的 GC,也就是回收哪些停止但是没有回收资源的容器;另一个就是我们开头关心的镜像了。
那么,我们接下来就分别看看 containerGC.GarbageCollect
和 imageManager.GarbageCollect
做了什么吧。
首先,一条路往下走,GarbageCollect
-> cgc.runtime.GarbageCollect
-> m.containerGC.GarbageCollect
1 | // pkg/kubelet/kuberuntime/kuberuntime_gc.go:407 |
这里特别明确的写出了三个清理的步骤: evictContainers
容器、evictSandboxes
沙盒、evictPodLogsDirectories
日志。当然,我们更关心容器的回收,那我们就来看看 evictContainers
是如何实现的。
1 | // pkg/kubelet/kuberuntime/kuberuntime_gc.go:226 |
可以看到,关键就是通过 evictableContainers
找到所有可驱逐的容器,然后通过 removeOldestN
方法来实现删除。在 removeContainer
其实就是去排序,然后通过之前我们看过的 killContainer
和 removeContainer
来操作容器,具体的操作人是 kubeGenericRuntimeManager
。相类似的 evictSandboxes
沙盒、evictPodLogsDirectories
日志 这里就不再具体描述了,有兴趣的可以继续追一下。
重点来了,镜像其实对于我们来说是比较重要需要关注的。由于镜像过多很容易占满磁盘,那么 k8s 是如何知道需要删除哪些镜像的呢?
如果是 docker,我们常常会使用
docker system prune
命令来进行清理
原理其实不复杂,让我们直接来看源码吧
1 | func (im *realImageGCManager) GarbageCollect(ctx context.Context) error { |
其中需要注意几点:
im.statsProvider.ImageFsStats
得到磁盘的使用率,也就是用了多少磁盘usagePercent
和 HighThresholdPercent
来判断是否需要 GC,HighThresholdPercent
默认 85%im.freeSpace
来清理镜像最后来看 im.freeSpace
是如何做的
1 | func (im *realImageGCManager) freeSpace(ctx context.Context, bytesToFree int64, freeTime time.Time) (int64, error) { |
RemoveImage
很好理解的逻辑,正常人也都是这样想的,找到那些不用的,然后最久没有使用的先移除。其中有一个关键点是这个过程中 imageRecordsLock 是锁了的,防止了并发记录的修改。
GC 的参数有一些可以配置的值如 --image-gc-high-threshold
根据磁盘使用空间的百分比来判断是否需要 GC。这些配置项可以在 https://kubernetes.io/zh-cn/docs/reference/command-line-tools-reference/kubelet/ 找到。
k8s 的 GC 设计很大程度上避免了磁盘资源使用带来的意外,所以平常正常使用的情况下一般不会出现问题,并且现在都上云了,磁盘一旦有任何问题,即将满了,会报警,运维的反应会更快。那么在实际的使用中,最容易出现问题的,不是镜像而是日志。遇到最多的就是意外是:有 pod 坏种(资源占用过多),导致节点资源不够,开始被驱逐,然后不断污染各个节点,导致雪崩的时候。过程中会导致 pod 不断创建或销毁,并且会出现各种 OOM 的日志 (The node had condition: [DiskPressure]
),导致节点磁盘满。这里的节点还不一定是 worker 先满的,master 也有可能哦。避免方式一个是限制 resources,一个是定期关注或直接监控日志,并不一定是 docker 的日志,有时是 k8s 本身的日志。
在编码上,有一个地方值得我们学习学习。是在启动 StartGarbageCollection
的时候,看到了一个方法是
1 | go wait.Until(fn, ContainerGCPeriod, wait.NeverStop) |
这里的这个 wait.Until
封装的很有意思,非常值得我们学习。比如,如果让你写一个不会因为 panic 而停止,一直定时运行的 goroutine 启动方法,你会如何封装呢?在没有看到这个方法之前,我的封装无外乎就是利用 defer + recover
的方式,然后用个 ticker 就完事了。因为一旦启动一个 goroutine 意味着就有 panic 的风险,所以一定会有一个 recover 去捕获。但是,这样一旦 panic 之后,就会停止,再也不运行了,那么势必就需要在 recover 之后重新启动一个新的 goroutine 去运行(有一点递归的意思在里面了)。这样的封装其实不够优雅。而 k8s 这里的封装就很有意思了。他不仅在 最后的 BackoffUntil
做了抽离,让运行是运行,定时是定时,然后通过 defer runtime.HandleCrash()
来捕获。并且在其中还实现支持了 Backoff
。所以这个封装其实很值得我们去学习和使用。
1 | // vendor/k8s.io/apimachinery/pkg/util/wait/backoff.go:210 |
是的,这个代码其实可以直接被抄过来用的,当作一个工具封装起来,这样 goroutine 用的会很放心。
]]>却忆携诗花底看,回头又是一年春。
不知不觉又一年过去了,每年 3 月都是博客装修的季节,但这次就不大折腾了。
更新主题版本至 4.13.0 https://github.com/jerryc127/hexo-theme-butterfly
其中我一个比较喜欢的优化是对于搜索的能力,现在感受比原来好用了很多。不过每次升级都是对于合并代码的一次巨大考验,这次我学乖了,直接把更新的配置文件内容全部拷贝进去改,这样就不会出现冲突了。这也让我对于配置一些软件配置文件的设计有了更好的认识,它们会加自定义的配置单独放在一个文件里,这样就不会影响到主配置文件的合并了,然后自定义的配置会覆盖默认的配置。xxxx.custom.yaml
-> xxxx.yaml
去年评论组件换成了自建的 twikoo 很喜欢,配置简单。
之前我看到了 Twikoo评论回复邮件模板 我一直没来的及修改,这次我终于修改了,总的样子没变,细节调整了一点点。 修改之后大概是这样的:
需要注意的是:MAIL_TEMPLATE
和 MAIL_TEMPLATE_ADMIN
配置是不一样的,一个是别人评论给你的邮件,一个是你回复别人的邮件。
这个是你回复给用户的评论,所以是带有原评论信息的 PARENT_NICK
和 PARENT_COMMENT
。
1 | <div class="page flex-col"><div class="box_3 flex-col" style=" display: flex; position: relative; width: 100%; height: 206px; background: #ef859d2e; top: 0; left: 0; justify-content: center;"><div class="section_1 flex-col" style=" background-image: url(你网站的LOGO图片地址); position: absolute; width: 152px; height: 152px; display: flex; top: 130px; background-size: cover; border-radius: 50%;"></div></div><div class="box_4 flex-col" style=" margin-top: 92px; display: flex; flex-direction: column; align-items: center;"><div class="text-group_5 flex-col justify-between" style=" display: flex; flex-direction: column; align-items: center; margin: 0 20px;"><span class="text_1" style=" font-size: 26px; font-family: PingFang-SC-Bold, PingFang-SC; font-weight: bold; color: #000000; line-height: 37px; text-align: center;">嘿!你在 ${SITE_NAME} 博客中收到一条新回复。</span><span class="text_2" style=" font-size: 16px; font-family: PingFang-SC-Bold, PingFang-SC; font-weight: bold; color: #00000030; line-height: 22px; margin-top: 21px; text-align: center;">你之前的评论 在 ${SITE_NAME} 博客中收到来自 ${NICK} 的回复</span></div><div class="box_2 flex-row" style=" margin: 0 20px; min-height: 128px; background: #F7F7F7; border-radius: 12px; margin-top: 34px; display: flex; flex-direction: column; align-items: flex-start; padding: 32px 16px; width: calc(100% - 40px);"><div class="text-wrapper_4 flex-col justify-between" style=" display: flex; flex-direction: column; margin-left: 30px; margin-bottom: 16px;"><span class="text_3" style=" height: 22px; font-size: 16px; font-family: PingFang-SC-Bold, PingFang-SC; font-weight: bold; color: #C5343E; line-height: 22px;">${PARENT_NICK}</span><span class="text_4" style=" margin-top: 6px; margin-right: 22px; font-size: 16px; font-family: PingFangSC-Regular, PingFang SC; font-weight: 400; color: #000000; line-height: 22px;">${PARENT_COMMENT}</span></div><hr style=" display: flex; position: relative; border: 1px dashed #ef859d2e; box-sizing: content-box; height: 0px; overflow: visible; width: 100%;"><div class="text-wrapper_4 flex-col justify-between" style=" display: flex; flex-direction: column; margin-left: 30px;"><hr><span class="text_3" style=" height: 22px; font-size: 16px; font-family: PingFang-SC-Bold, PingFang-SC; font-weight: bold; color: #C5343E; line-height: 22px;">${NICK}</span><span class="text_4" style=" margin-top: 6px; margin-right: 22px; font-size: 16px; font-family: PingFangSC-Regular, PingFang SC; font-weight: 400; color: #000000; line-height: 22px;">${COMMENT}</span></div><a class="text-wrapper_2 flex-col" style=" min-width: 106px; height: 38px; background: #ef859d38; border-radius: 32px; display: flex; align-items: center; justify-content: center; text-decoration: none; margin: auto; margin-top: 32px;" href="${POST_URL}"><span class="text_5" style=" color: #DB214B;">查看详情</span></a></div><div class="text-group_6 flex-col justify-between" style=" display: flex; flex-direction: column; align-items: center; margin-top: 34px;"><span class="text_6" style=" height: 17px; font-size: 12px; font-family: PingFangSC-Regular, PingFang SC; font-weight: 400; color: #00000045; line-height: 17px;">此邮件由评论服务自动发出,直接回复无效。</span><a class="text_7" style=" height: 17px; font-size: 12px; font-family: PingFangSC-Regular, PingFang SC; font-weight: 400; color: #DB214B; line-height: 17px; margin-top: 6px; text-decoration: none;" href="${SITE_URL}">前往博客</a></div></div></div> |
这个是用户给你的评论,所以是不带原评论信息的。没有 PARENT_NICK
和 PARENT_COMMENT
。
1 | <div class="page flex-col"><div class="box_3 flex-col" style=" display: flex; position: relative; width: 100%; height: 206px; background: #ef859d2e; top: 0; left: 0; justify-content: center;"><div class="section_1 flex-col" style=" background-image: url(你网站的LOGO图片地址); position: absolute; width: 152px; height: 152px; display: flex; top: 130px; background-size: cover; border-radius: 50%;"></div></div><div class="box_4 flex-col" style=" margin-top: 92px; display: flex; flex-direction: column; align-items: center;"><div class="text-group_5 flex-col justify-between" style=" display: flex; flex-direction: column; align-items: center; margin: 0 20px;"><span class="text_1" style=" font-size: 26px; font-family: PingFang-SC-Bold, PingFang-SC; font-weight: bold; color: #000000; line-height: 37px; text-align: center;">嘿!你在 ${SITE_NAME} 博客中收到一条新评论。</span><span class="text_2" style=" font-size: 16px; font-family: PingFang-SC-Bold, PingFang-SC; font-weight: bold; color: #00000030; line-height: 22px; margin-top: 21px; text-align: center;">${SITE_NAME} 博客中收到来自 ${NICK} 的评论</span></div><div class="box_2 flex-row" style=" margin: 0 20px; min-height: 128px; background: #F7F7F7; border-radius: 12px; margin-top: 34px; display: flex; flex-direction: column; align-items: flex-start; padding: 32px 16px; width: calc(100% - 40px);"><div class="text-wrapper_4 flex-col justify-between" style=" display: flex; flex-direction: column; margin-left: 30px;"><hr><span class="text_3" style=" height: 22px; font-size: 16px; font-family: PingFang-SC-Bold, PingFang-SC; font-weight: bold; color: #C5343E; line-height: 22px;">${NICK}</span><span class="text_4" style=" margin-top: 6px; margin-right: 22px; font-size: 16px; font-family: PingFangSC-Regular, PingFang SC; font-weight: 400; color: #000000; line-height: 22px;">${COMMENT}</span></div><a class="text-wrapper_2 flex-col" style=" min-width: 106px; height: 38px; background: #ef859d38; border-radius: 32px; display: flex; align-items: center; justify-content: center; text-decoration: none; margin: auto; margin-top: 32px;" href="${POST_URL}"><span class="text_5" style=" color: #DB214B;">查看详情</span></a></div><div class="text-group_6 flex-col justify-between" style=" display: flex; flex-direction: column; align-items: center; margin-top: 34px;"><span class="text_6" style=" height: 17px; font-size: 12px; font-family: PingFangSC-Regular, PingFang SC; font-weight: 400; color: #00000045; line-height: 17px;">此邮件由评论服务自动发出,直接回复无效。</span><a class="text_7" style=" height: 17px; font-size: 12px; font-family: PingFangSC-Regular, PingFang SC; font-weight: 400; color: #DB214B; line-height: 17px; margin-top: 6px; text-decoration: none;" href="${SITE_URL}">前往博客</a></div></div></div> |
尝试接入了 akismet 反垃圾,对于免费用户还是很友好的。
我改了一些高度和字号的细节样式,然后首页封面图也换了一张。没错,原批,是我没错了。
去年的造的轮子每日打卡,还可以。
但其实有一些时候还是在摸鱼了的,你可以看到日历里面还是有很多摸鱼日。于是今年更进一步,我直接建立星球了,然后每天都会在星球里面打卡,这样更好的接受大家的监督。当然,现在我还只有一个人,让我先把内容建立起来慢慢积累吧。我顺便让 GPT 给我写了个宣传文案,乖乖,小红书博主既视感:
🌟 每日打卡,知识星球等你加入!🌟
📚 想要每天接收精选学习内容,同时获得学习监督吗?我们为你建立了一个专属的知识星球!💡
🚀 扫码加微信,备注“打卡”,即可加入我们的星球!在这里,你将享受以下好处:
✅ 每日免费分享,助你不断学习进步;
✅ 互相监督,激励彼此坚持;
✅ 与志同道合的人一起成长。
🌈 不论你是新手还是资深学习者,这里都是你开始的地方。快来和我们一起打卡吧!💪
📩 扫码加入,备注“打卡”,马上开启你的学习之旅!✨
去年 3 个专栏更新的不是很勤快,并且发现设计这东西吧,对于我个人来说,还是太过于难驾驭了,并且特别是设计的一些元素,我会发现还不如我用 UI 框架来的整齐一些。所以我今年会替换掉这个专栏,来弄个新的鸡汤。GO GO GO!
]]>Vditor 是一款所见即所得的浏览器端 Markdown 编辑器。
个人使用它有两个原因:
基本使用就不多说了,文档很详细,功能也很全面,看看就知道了,本文是由于再离线环境下使用的过程中发现 Vditor 需要依赖外部 CDN 资源,所以踩了一些坑。
网络上有 Vditor 的 CDN 但无法直接拿来用,除非你是原生 HTML。如果你直接配置网络的地址到 CDN 的配置会发现路径多了一个 dist。当然,官方也支持自建 CDN,将源码放到 nginx 里面去也可以,但这样单独部署很麻烦。我还是希望打包到一起去。其实作者在 issue 里面已经说了,挺容易理解的,过程也就基本是这样。
1 | import Vditor from 'vditor'; |
将 node_modules/vditor/dist
复制到你需要部署的服务的文件夹中,比如,我这里用的是 vite 所以就是 public 文件夹下理论上和 index.html 是同级的。
如果此时默认这样服务的话,加载 js 会很大,大概 3.8M+,肯定不行,会明显卡顿一下,所以需要压缩。我这里使用的是 BR,当然你也可以使用 gzip。具体如何压缩就看看你部署的工具是什么了。
这样之后,你就会发现打包之后你的文件大小特别大,因为源码也在里面了,所以我删除了所有 dist 中不需要的组件源码,比如流程图渲染等等,这样可以减少打包后的文件大小。
总的来说,使用体验还是很不错的,后面还会继续使用。
]]>很长一段时间我也是用 MD5 + 盐 来解决绝大多数密码的问题的,因为确实很方便。不过,从安全的角度来说,还是有风险,那就干脆直接上 Bcrypt 吧。
其实,在大多场景够用了,毕竟 hash 和 salt 同时被黑的概率太低了,不过其实 MD5 最大的问题不是到不是这个,而是算的太快了,随着计算能力的发展总会是有概率被破解的。
1 | password_hash = md5(password+salt) |
不多说,直接上代码,看怎么用,然后再分析。
1 | package main |
1 | # output |
golang.org/x/crypto/bcrypt
提供了 bcrypt 方法,所以使用起来非常简单的。
GenerateFromPassword
提供了加密(hash) 的方法,其中第二个参数是计算成本(工作因子),越大计算耗时越长 MaxCost 是 31CompareHashAndPassword
提供了验证的方法,用于验证用户输入的密码是否正确最让人安心的就是,它的每次 hash 结果都都是不一样的,原因就是每次的 salt 也是不一样的。我们知道,md5 使用相同的 字符串 前后两次 hash 是一样的,从而可以验证前后用的密码是不是一样的。那么,Bcrypt 每次的 hash 都不一样,如何它是如何做验证呢?
首先我们看看 hash 之后的结果
1 | $2a$10$nYbAG/Om/bjEGq..x5TsVOy5VIVWudVaFxchrWLWPO5M7tMDIBDVO |
其实看完了结构你就不难猜测到它的原理了,说白了验证的方式很简单,就是将 hash 后的结果中的 Salt 取出来,然后对用户输入的密码再次使用相同的方式和次数进行 hash,然后比较结果,看结果是否一致。也就是说,其实 Bcrypt 的 hash 结果并不仅仅只是包含了 hash 还包含了具体的 hash 计算方式和 Salt。
所以,Bcrypt 相比于 MD5 来说,我认为最关键的还是有了 cost 这个选项,并且本身的计算就比 MD5 的时间要长,大大的提高了破解的难度,而且由于 salt 的不固定,彩虹表是别想了。最后,还有一个关键点要提醒你:Bcrypt 的加密长度是有限制的,比如 golang 这里的库限制长度最大为 72,超过就会报错。
]]>我直接用 brew 安装就完事了,当然你也可以官网下载
1 | brew install --cask squirrel |
安装完成之后,第一个坑就出现了,输入法并不会直接显示出来,你需要主动添加。
我一开始找了半天,一直以为没有安装成功😢
plum(東風破) 是 RIME 的配置管理工具
1 | curl -fsSL https://raw.githubusercontent.com/rime/plum/master/rime-install | bash |
雾凇拼音 提供了一套开箱即用的完整 RIME 配置
1 | # 拉取并 plum 进入目录(如果按上面步骤已经安装 plum 则你现在已经在这个目录了,不需要做这个操作) |
现在只需要重新加载一次就可以宣布安装完成了。F4 可以切换输入法(双拼等等)
当然 RIME 最重要的就是支持自定义配置,以下是我自己的配置,仅供参考。
打开配置文件夹(这里有个 Deploy 需要注意⚠️ 一下, 后面会提到)
新建配置文件 default.custom.yaml
1 | patch: |
这里我需要自定义的是候选词的个数,还有 Shift 切换中英文
新建文件 squirrel.custom.yaml
这里我需要调整的是字体和配色,app_options 可以使得进入某个应用的时候自动切换为英文避免输入中文,我经常用的是 raycast
1 | # 適用於【鼠鬚管】0.9.13+ |
其他还有很多自定义的配置,请参考官方文档。注意,配置添加或修改完成后需要重新部署才会生效。
下载地址:https://github.com/fcitx5-android/fcitx5-android/releases
注意选择版本是:
/storage/emulated/0/android/data/org.fcitx.fcitx5/data/rime
注意目录可能不一样,只需看清楚包名和最后的 rime作为一个隐私控,其实输入法一直被我遗忘,因为之前用搜狗输入法,也挺习惯的。但是最近发现了这个输入法,就开始折腾了。其实折腾的过程也是很有意思的,毕竟自己的输入法,自己的词库,自己的配置,这些都是很有意思的事情,使用起来也慢慢发现也挺顺畅的。
2023 年度读书总结
仅罗列,顺序是随机。怕链接会过期,失效的建议直接搜书名。
并不是说其他的书不好,而是它对我的影响最大,印象最深刻
《深入理解Linux网络: 修炼底层内功,掌握高性能原理》
今年最佳颁给它,一方面是它对我技术提高是最大的,另一方面是读的过程中真的有深入浅出的感觉。十分推荐。
使用 DailyCards 确实 push 了自己很多。Presenting yourself will push yourself. (展示自己,就会鞭策自己)。与“输出倒逼输入”相比,打卡这样的输出我觉得会更加减少自己的负担。以前我的输出必须是一篇博客,那么筹备的过程中容易拖延,而打卡不同,你无需关心内容的完整度和质量,它只是一个微博,没人会关心你微博有没有写的好不好,更多的是知道你在努力。
下面,我总结了几个过程中的有效感受
没打卡,就是没打卡;没学习,就是没学习。每个人都不是完美的。今天我玩游戏所以没学又怎么样?所以我没有补卡的习惯,展示的就是最真实的自己,所以你也不必焦虑。当然,我也非常钦佩哪些可以一直坚持每天学习的同学,他们也是我们的榜样。
你知道 操作系统里面 软中断了之后如何恢复吗?没错,关键其实是 context(上下文)。
所以,我发现连续中断了几天之后,毫无疑问有人会继续拖延(我自己也是),打卡之后的恢复关键在于回忆之前的行动,恢复你的上下文。
可以是翻开你之前看了一半的书,也可以是打开你之前看了一半的视频。而我的恢复方式很简单,就是打开打卡列表,看下我上一次做了什么。
你可能会觉得这很可笑,是的,就是这一个启动的小动作,一旦开始回忆,你就会自然而然的继续了。
我现在已经没有新年计划的习惯了,因为发现计划总赶不上变化,看完当下的书,走好脚下的路。2024 一起加油。
]]>一年一度的亚马逊云科技的 re:Invent 可谓是全球云计算、科技圈的狂欢,每次都能带来一些最前沿的方向标,这次也不例外。在看完一些 keynote 和介绍之后,我也去亲自体验了一些最近发布的内容。其中让我感受最深刻的无疑是 PartyRock 了。PartyRock 真的算是做到了:能让任何人快速的构建一个属于自己的 AI 应用。当然,本文最后也分享我对于其他在 re:Invent 上提到的一些看法和思考。
那么,不多说,先来看看今天的主角 PartyRock。
Everyone can build AI apps.
这句话是 PartyRock 首页的一句话,它就是 PartyRock 的最好的功能概括了。
去年到今年 AI 相关的应用层出不穷,GEN AI 已经太多了。到目前为止,其实我本人已经有点审美疲劳了,因为该看的都看的差不多了,所以说实话体验之前,我并没有对 PartyRock 带有很大期望,最多是体验完了之后厚脸皮来一句 “不过如此”。结果体验完成之后发现我说的是:
下面我就用我自己制做的两个应用和一个官方的应用来说明一下它的使用体验。
制作的过程其实非常简单,几乎 10 分钟就搞定了。
点击创建应用之后,在它给出的输入框里面输入你想要做的应用的功能描述,比如说,我最近在学英语,我第一想法就是做个选词填空的应用出来,于是我就在 App builder 的输入框里面输入如下的内容,然后点击 Generate
app 就开始生成了。
根据生成的内容,你自己按需求修改一下描述和内容,这里最后下方的答案输入部分我做了一些提示词的修改,其他我也就没动了。
然后就可以测试一下了。在第一个框(Words to choose from)输入一些单词,在右边(Sentences)就会生成对应的题目。
然后你可以在下面(Answer)作答并验证答案是否正确。
整个过程,需要我动脑的地方就是在想我应该如何描述我的应用,实际生成的效果很不错,我很满意。
第一个应用我们是依赖的 AI 直接帮我们生成的,虽然很简单,但是对于我们开发者来说,与其去想描述,不如直接动手来的快。于是这次我们从零开始(选择 “Start from an empty app” 选项),自己搭建一个应用试试看。这次我想试试有关于图片生成的能力,对于 AI 生成图片来说最麻烦的是写描述词,于是我想让 AI 先帮我扩写,然后再利用扩写的内容去生成图片。
第一步添加 widget ,其实我们在上面看到的一个输入框就是一个 widget,目前 PartyRock
提供了下面几种可以使用的 widget。
我们需要 3 个 widget,一个用户输入(User Input),一个文本生成(Text Generation),一个图片生成(Image Generation)。
然后,我们就需要编写 AI 生成的提示词了,点击每个 widget 右上角的编辑,就可以输入对应的提示词,还可以选择不同的模型。其中最重要的是,你可以使用 @ 符合直接引用其他 widget 生成的内容,比如,我需要根据用户输入的内容进行扩写,那么我在提示词里面就可以直接引用用户输入的部分;比如,我想根据扩写的内容生成图片,我就可以利用 “@Description” 引用扩写的内容。如下图 Prompt 中高亮的部分。
测试一下,下图就是我输入的一句描述,经过扩写最后生成了图片,当然模型不同最后效果也不一样。
此时你就可以发布你的应用了。
让我觉得最巧妙的一个应用,是官方给出的 ChatRPG。这个应用利用了 AI 对话的功能来完成了一个对话形式的 RPG 游戏,你可以通过对话的形式选择不同的路径(A B C)来获得不同的结局,并且最为巧妙的是,它利用了几个 AI 的联动,整个 RPG 的过程会生成不同的场景图片,让整个游戏的过程更加有了带入感觉。
说完了体验,来说说 PartyRock 精妙的地方。
当然,这次 re:Invent 提到了其他很多的产品和思考,这里就对其中几个我非常感兴趣的产品谈谈我的拙见。
我关注最多的一定是 serverless,我一直都觉得 serverless 一直一种对开发友好也对运维友好的结局方案。而这次 re:Invent 发布的 Amazon ElastiCache Serverless
让我也有了新的思考。Amazon ElastiCache Serverless
是根据应用程序流量模式自动的扩展容量的缓存服务,而对于缓存这样的热点数据来说,有过实际业务场景的同学都知道如果 Redis 突然内存满了是一种什么样的体验。而 ElastiCache 的自适应压力的工作负载模式可以很好的解决这个问题,而且兼容 Redis。
产品本身的意义很大,而带给我的思考是,在未来是否当 serverless 足够成熟之后是否会出现一直数据源的集合产品,自动会根据数据的访问情况来自动路由到对应合理的存储模式中呢?比如,热点数据会自动路由到 cache 而平常数据路由到 mysql,而冷数据当到达 “冰点” 时自动归档以减少消耗?而对于上层应用来说使用完全透明?当然里面的问题很多,不过我觉得随着 serverless 的发展或许这也是可以想象的。
Amazon Bedrock、Amazon CodeWhisperer 和 Amazon Q 是这次 re:Invent 提到有关 AI 的一些产品。比如本文提到的 PartyRock 应该就是建立在 Amazon Bedrock 之上的。当然,我也第一时间去试用了一下 Amazon CodeWhisperer 和 Amazon Q ,不过给我的感觉还没有那么的惊艳,或许是还在 beta 阶段,智能程度一般,相信体验过的小伙伴感受也差不多。而且由于目前支持的开发语言还不多(我常用的 golang 还没有)。
不过,re:Invent 上一直强调了另一个有关 AI 的关键点就是,安全。“生成式 AI 一定应该是安全的”。这里的安全有两个方面,一方面是生成的内容一定应该是安全的,不能出现违法的内容;另一方面是作为模型基础的训练数据应该是安全的。比如,企业内部基于自己内部代码和数据来建立的模型,进行使用,对应的数据不应该被公开或者出现在别的人生成内容中。所以,安全应该是未来 AI 前进的基石。
我在体验 PartyRock 的时候也发现了下面的提示,如果出现不安全的单词图片是不会生成的:
亚马逊 CTO Werner Vogels 博士今年在 re:Invent 上的主题演讲提到了 THE FRUGAL ARCHITECT(节俭/节制架构)。提到了成本应该在架构设计之初就应该被考虑进去,并且一直作为一个考量指标。
去年到今年一个词在国内大厂一直被提及 “降本增效”,结果最近演变成为了 “降本增笑”。是的,由于成本的缩减,往往带来的就是服务的不稳定,这是所有工程师都不想见到的。我就想到之前听到一个说法是,如果一个并发问题能通过加服务器来解决,那么领导会更愿意通过加服务器来解决而不是重构代码,因为养开发的成本往往高于服务器。而我也经历过一次 k8s 的降本,虽然有时候确实是因为 request 设置的不合理导致的成本超标,但实际改起来的时候真的是心惊肉跳,因为你真的不知道这个服务的并发明天会不会就坐火箭。所以,成本、安全、性能 一直都是一种权衡trade off,用流行的话说就是 “并不是我不知道两地三中心安全,而是单中心更有性价比😭”。
其实,有时候并不是不考虑,而是无法预估流量的大小,谁都也无法预测你的应用什么时候会火。所以 AWS 提供的 Lambda,ElastiCache 都是那种按成本去设计的。而对于上云来说最大的一个问题就是成本不可控,随着服务的类型越来越多,并且很多服务都是按量付费,预算与实际往往会有比较大的差异。所以,最让我感兴趣的是,这次 Werner Vogels 提到的 Management Console 内可以展示应用级别的成本,之前我们可能只能知道某个使用的服务成本很贵,而现在我们能知道具体那个应用在使用的成本最大。这种观测能力对于使用者来说是更加友好的,我能最大程度的去观测我的应用成本的占比,从而精准的控制我的成本,而不是盲目的去找压力。
总之,在我认为 THE FRUGAL ARCHITECT,给我的思考是你必须有能力去时刻关注成本,无法观测的系统将导致无法估量的成本。
最后总结一下,这次 re:Invent 不仅给我们展示了一些最新的应用和服务,更多的给我们带来了一些 AWS 对于最新技术方向的一些思考,接触这些前沿技术给我的架构解决方案又多了一些积累,相信明年的大会也会一样精彩。
]]>之前,我看了腾讯发布的两篇有关代码规范以及 Code Review 相关的文章,作者是 林强 大佬。
里面提到了很多有关代码相关的思考,以及一些价值观的传递,从中我也学到了很多(算是大厂内部一瞥吧)。非常推荐没看过的同学看看鹅厂是如何做 CR 的。文中的 CR 也是对 go 的,所以我看起来比较亲切。故,在这里总结一下其中对我来说意义比较大的。
之前,我看了《重构》之后也写了一下读后感,所以相同的部分我就不在本文中列举了。
看完之后,一方面给了我信心,鹅厂 CRUD 也这样写的;一方面给了暴击,林强 大佬 Review 可真是针针见血啊。
]]>对于一些个人的小项目来说,没必要也没能力上一些大型 devops 工具(如 jenkins,argocd) 时, 有一些小工具往往非常好用
当我们 ci 打包完成 docker 镜像之后需要 cd (部署)时,如果没有工具,有时候特别麻烦,而一些大型的重工具往往对于小项目来说并不合适。今天要说的一个小工具就是 watchtower。
地址: https://github.com/containrrr/watchtower/
使用部署非常简单,一个 docker-compose 就能说清楚所有基础能力
1 | version: "3" |
your-app1 your-app2
你需要监控的 docker 容器名称,如果不写,则是全部--cleanup
自动清理旧镜像,建议打开--interval
监控间隔时间,单位:秒。也可以替换为 cron 表达式 --schedule "0 0 4 * * *"
当然注意配置时区,否则时间不对。WATCHTOWER_NOTIFICATIONS
通知渠道,具体其他通知配置可以参考:https://containrrr.dev/watchtower/notifications//var/run/docker.sock:/var/run/docker.sock
必须有,docker 容器的操作权限/root/.docker/config.json:/config.json
如果需要拉取私有 docker 仓库,则需要配置这个,否则拉取不到启动之后,如果需要单次执行,可以使用下面的命令:
1 | docker exec -it watchtower-watchtower-1 /watchtower --debug --run-once your-app1 |
我最喜欢它的一点是解耦了 cd 和 ci,不需要一个独立的平台去配置 ssh 访问服务器去执行 cd 的工作,所以我用了也挺久的了,对于自己家里的小项目来说是足够了的。不过就一点很难受,通知不支持任意的 webhook,仅仅支持 slack 的 webhook,也没别的办法,无法接入钉钉飞书。
]]>📢 注意,该文本非最终版本,正在更新中,版权所有,请勿转载!!
资源在 k8s 中是一个非常重要的关键因素,一些运维事故往往也就是因为一些资源限制设置的不合理而导致的。而合理的设置资源也是一门学问和经验,最近不停地被提及的 “降本增效” 通常也伴随着资源设置的优化。对于一个应用应该设置多少内存和 CPU,我觉得这不是我们在这里应该学习的(这都是实战经验积累的)。而我们需要知道的是,这些限制条件何时会被检查,会被谁检查,超过限制条件会引发什么问题。 这对于我们来说很重要,一方面实际出现问题,我们可以迅速知道原因;另一方面,这些限制条件还会和之后的调度、自动扩容/缩容有关系。所以本章节我们来看看它。
这次的寻码就有点艰难了。我的第一个落脚点是 pkg/kubelet/eviction/eviction_manager.go
我没有直接去找 limit 和 request 的原因是我更在意驱逐,驱逐会直接导致最终 pod 被调度,而 limit 是触发的关键。所以我就看到了这个包名是 eviction
(驱逐),然后这个文件名称是 eviction_manager
,好家伙,就决定是它了。
1 | // pkg/kubelet/eviction/types.go:57 |
其中从上面的 Manager 接口其中的定义看方法名便知道其基本能力,其中 Start
方法最为关键,于是去找具体实现。
我们可以看到实现接口的是 managerImpl
(可以,这个命名很 java) 实现了 Manager
接口,然后看关键的 Start
方法
1 | // pkg/kubelet/eviction/eviction_manager.go:178 |
其中就有几个细节:
NewMemoryThresholdNotifier
Notifier
都是一个独立的协程去启动 go notifier.Start()
m.synchronize
看到这里,我就有了我的第一个问题:
NewMemoryThresholdNotifier
方法显然是针对内存的,那么 CPU 呢?难道 CPU 突破限制的通知放在别的地方?
这里就涉及一个之前提到的我看源码的一个方法,此时就可能是一个分叉,如果我现在去搜索有关 CPU 的相关问题,那么就会打破我现在的思路,所以我会选择先把这个问题记录下来,回头再寻找答案。
先小结一下:也就是说有一个专门管理驱逐的 Manager,它会启动一些协程去关注 pod 的内存;同时会同步 pod 状态,如果发现需要驱逐的 pod 则进行 cleanup。那么下面我的目标就是如何去监控 pod 的内存呢?
我在没有看过源码之前,对于 cgroup 是有一个简单的了解的,知道 docker 就是通过 linux 的 namespace 和 cgroup 来隔离的。但我不明白的是,通知是怎么来的,如果让我自己去实现那么肯定是定期循环查询内存超过阈值则进行通知,肯定性能不好。
于是,我就追着 go notifier.Start()
的 Start 找到了
1 | // pkg/kubelet/eviction/memory_threshold_notifier.go:73 |
可以看到,这里非常简单,就是不断地 handler events 这个 channel 的事件。所以,我们需要找到哪里在往 events 这个 channel 里面写入事件。引用位置只有一个那就是 UpdateThreshold
。
1 | // pkg/kubelet/eviction/memory_threshold_notifier.go:80 |
这里我们就见到主角了,NewCgroupNotifier
也就是 Cgroup
了。这里有个细节是 factory 是 NotifierFactory
也就是利用了设计模式中的工厂模式,抽象了一下生成的方法。
读源码注意事项:通常我们用的是非 Linux 的电脑阅读源码,于是在 IDE 中跳转的时候可能会有不同。比如,此时我在 Mac 下,默认点击
NewCgroupNotifier
方法最终会跳到pkg/kubelet/eviction/threshold_notifier_unsupported.go
中(IDE 会根据你当前使用的系统来进行跳转),而在非 Linux 下 cgroup 当然是没有的。但其实我们应该看的应该是 Linux 下的实现:pkg/kubelet/eviction/threshold_notifier_linux.go
本节的重点在这里,理解了 linuxCgroupNotifier 的实现,那么以后或许你也可以在其他项目中利用 cgroup 的特性来实现对内存用量的控制或管理。
threshold_notifier_linux.go
整个文件就一共 200 行,不多。分成三个部分:初始化、启动、等待。
源码阅读技巧:通常来说看 golang 的代码很长的时候,你可以先把所有的
if err != nil
去掉看看。
1 | // pkg/kubelet/eviction/threshold_notifier_linux.go:49 |
去掉之后,其实主干就非常清楚了:
看完初始化的方法,其实我大致也能猜到了,既然有了 epoll,有了 fd,大概率就是将 fd 通过 controlfd 也就是 cgroup.event_control
注册给 cgroup,这样当出现内存变化的时候将具体事件通过 eventfd 通知回来。
Start
方法就非常简单了,记得这个 Start
就是 eviction_manager 为每个 pod 配置的阈值来调用启动的。
1 | # pkg/kubelet/eviction/threshold_notifier_linux.go:110 |
这是一个标准的 epoll 用法了,就是通过 EpollCtl
将 fd 添加进去,然后 wait 有事件之后就将事件发送到 eventCh 通道里面就好了。所以到这里我觉得 wait
方法已经不需要看了,肯定就是 epll wait 没跑了。有兴趣的同学可以看下,我这里就不贴了。
至此,我们总结一下,linuxCgroupNotifier
的实现其本质就是利用了 cgroup 的 event 机制,说白了就是以格式 eventfd watchfd threshold
写入 cgroup.event_control
就可以了,然后使用 epoll 来等着事件来。所以我在一开始就提到了,或许以后当你想利用 linux 的 cgroup 机制来监控内存时,整个代码你是可以直接抄的。这也是我们阅读源码其中一个非常重要的好处,积累一些工具或方法的设计和写法。
前面我们看到的都是内存,那么其他资源的限制呢?
还记得我们在 managerImpl
中看到的 Start
方法吗?不记得你可以回到上面再看下,在最后有一个调用 synchronize
的过程,这个方法会返回一个需要被驱逐的 pod。于是乎,我们需要知道在 synchronize
方法中是如何得到需要被驱逐的 pod 的。
源码阅读技巧:synchronize 方法特别长(之前是哪个代码规范写的说一个函数不能超过多少行来着?你看看别人 k8s 不照样写成这样吗?手动狗头~),还是一样的方法,我们需要抓主干。
- 去掉所有不必要的 if 条件(自行觉得)和所有 debug 日志
- 只看方法名不看具体实现来判断所做的事情(优秀代码的命名此时就有非常大的作用)
- 分块总结串联总线(k8s 的代码分块还是很明显的)
其实大致的过程我们是可以猜到的,我问你如果是你,要找到一个需要驱逐的 pod 你会怎么做?是不是下面的思路
是的,思路无非就是如此,但是其他细节很多。
1 | // pkg/kubelet/eviction/eviction_manager.go:233 |
那么关键的是 pod 的那些指标会被收集呢?于是我们查看一下 summary 的结构会发现:
1 | // vendor/k8s.io/kubelet/pkg/apis/stats/v1alpha1/types.go:107 |
就是 Pod 的这些指标了,CPU、内存、磁盘等等这些都在了。那么具体这些指标如何获取的,有兴趣的同学可以追着继续看一下。同样的,节点也有统计状态,这里也不列举了,都在 summary 里面。
synchronize
立刻检查;还有一种就是定时执行 synchronize
间隔是 monitoringInterval
默认是 10sQoS 这个小知识点是容易被忽略的,当节点上资源紧张时,kubernetes 会根据预先设置的不同 QoS 类别进行相应处理。我最开始使用 k8s 的时候也没有掌握这个知识点,导致了一些问题。在这里我不做过多的介绍,你可以简单的理解为下面三种情况:
具体各个情况的说明参考官网文档:https://kubernetes.io/zh-cn/docs/concepts/workloads/pods/pod-qos/
然后,给出我自己的最佳实践
这也是一个容易遗漏的小知识点,也很容易理解:
有两个设计可以值得我们学习
Start
也可以学习。memoryThresholdNotifier
的 NotifierFactory
可以算是一个很标准的工厂模式了,定义接口实现,通过 factory 来创建 Notifier。
1 | // NotifierFactory creates CgroupNotifer |
这也是看源码的一个好处,如果你不知道一个设计模式应该如何使用或者没有最佳实践,看看别人实际中的使用可以让你最快学会它
如果后面有 cgroup 的使用需求,建议查看 man 文档 https://man7.org/linux/man-pages/man7/cgroups.7.html
因为 CPU 不是一个和内存一样的可以被很好量化的指标,它通常是只在一个采样周期内指标。而 k8s 采用的是 CFS,也就是说在一个采样周期内如何达到 limit,就开始限流了。所以 limit 限制过小,会导致一些突然的波峰 CPU 使用不停地被限流。并且其中还有与低版本内核 bug 相关的一些各种问题。总之你记住,给我的感觉是:“不准且复杂”。不过 CPU 密集型的业务实际不多,所以 CPU 的 limit 通常来说我的建议都是先给经验值,然后根据压测的情况去调整。
有一个国外的案例供你参考:https://medium.com/omio-engineering/cpu-limits-and-aggressive-throttling-in-kubernetes-c5b20bd8a718
]]>📢 注意,该文本非最终版本,正在更新中,版权所有,请勿转载!!
当我们知道了 pod 的生命周期,那么 k8s 如何知道一个 pod 的健康状态呢?就是通过今天要说的 Probe 也就是探针来检查 pod 的状态。一方面可以监控 pod 的健康状态,重启不健康的 pod;另一方面还可以监控 pod 的服务状态,当 pod 能提供服务时才会将流量打进来。
要知道这三种探针的能力 https://kubernetes.io/zh-cn/docs/concepts/workloads/pods/pod-lifecycle/#types-of-probe
探针这个东西就和 request limit 一样,你不配置的话,绝大多数适合,使用起来也问题不大。甚至在一开始的时候我都没注意到这个配置,但是当你的服务非常注重 SLA(承诺服务可用性) 或者你的容器出现了异常,无法服务又没有正确退出的时候,这个配置就显得非常有用了。而在实际中,不合适的探针配置也可能会导致奇怪的问题。
所以,针对探针,想要实际了解一下它具体是如何做的,防止一些意外使用。
这次当然是搜索 probe 或者你搜索具体 livenessProbe
也可以找到对应的定义和接口。因为我看到的具体已经有连个目录名字就是 probe 所以优先确认目录下的代码是否为我需要的。
pkg/kubelet/prober
目录下的源码首先映入眼帘的就是 prober_manager.go
从命名就可以看出它是管理员,那先来看一下内部的定义
1 | type manager struct { |
有一个 map 包含了所有 worker,然后一个锁,那既然这样,可以猜测 worker 就是最终干活的了。也应该是它来完成最终的 探针 工作。
看完结构,再看方法,manager 有两个很重要的方法:
func (m *manager) AddPod(pod *v1.Pod)
func (m *manager) RemovePod(pod *v1.Pod)
下面的代码就是 AddPod
中遍历找到所有探针的配置,然后进行创建,可以看到,如果 workers
map 中没有,那么就会新建一个 worker 并且开一个协程去跑这个 worker 。
1 | // pkg/kubelet/prober/prober_manager.go:185 |
那么只要知道谁调用了 AddPod
方法就能知道什么时候探针被启动了。我们发现调用的位置只有一个:pkg/kubelet/kubelet.go:1916
也就是:func (kl *Kubelet) SyncPod
方法中。
此时让我们回忆一下 kubelet 创建 pod 的时候的调用过程:
pkg/kubelet/pod_workers.go:1213
pkg/kubelet/pod_workers.go:1285
pkg/kubelet/kubelet.go:1687
pkg/kubelet/kubelet.go:1934
pkg/kubelet/kuberuntime/kuberuntime_container.go:177
在 go 中有一个编码规范:当你使用 go 启动一个协程时,你必须要清楚的知道它什么时候会退出。否则容易导致协程泄露。
那么既然 probe 是开协程启动的,那么什么时候会停止呢?那肯定要看 run 方法里面了
1 | // pkg/kubelet/prober/worker.go:145 |
其本质就是根据用户配置的 PeriodSeconds
时间定时执行 doProbe()
方法,而退出则是在 stopCh
有消息的时候,那什么时候来消息呢?是 worker.stop()
的时候。而调用 worker.stop()
方法的位置有三个。
func (m *manager) StopLivenessAndStartup
func (m *manager) RemovePod
func (m *manager) CleanupPods
for w.doProbe(ctx) {
执行探针的过程虽然代码长,但并不复杂。还是需要抓主要矛盾,精简之后就是如下的部分:
1 | func (w *worker) doProbe(ctx context.Context) (keepGoing bool) { |
w.probeManager.prober.probe
其实就是根据具体不同的探针类型去执行不同的探针方法了。w.resultsManager.Set
最关键的就是这里,最终将探针探测的结果通过 Set
方法传递了出去,为啥说传递呢?因为其内部就是一个 updates chan Update
的 channel。这样解耦了探测和状态改变。StopLivenessAndStartup
、RemovePod
、CleanupPods
方法执行时,也就是要么是 pod 状态异常,或者是 pod 要被移除或清理了,同时探针就会被一起关闭。看到 StopLivenessAndStartup
方法名的时候我就注意到了,为什么 readinessProbe
不在其中呢?原因是:kubelet
关闭 pod 的时候,先 StopLivenessAndStartup
停止 liveness
和 startup
探针,再来 killPod
,最后调用 RemovePod
来移除全部的探针。然后想想 readinessProbe
的作用你就会明白为什么是这个顺序了。
在设计上有两个常见的解耦:
1 | probeLoop: |
一个比较常用的 break+label
的写法,如果你没见过,可以了解下。通常在循环嵌套不方便退出的时候用。
1 | for _, probeType := range [...]probeType{readiness, liveness, startup} { |
上面的代码有一个不起眼的小细节,这里用到了 [...]
也就是说这里最终其实是一个定长的数组,而不是 slice(切片),我们平常写可能 ...
就不加了,也不影响。可见 k8s 源码中的细节真的很多。
📢 注意,该文本非最终版本,正在更新中,版权所有,请勿转载!!
pod 是 k8s 调度的最小单位,也就是整个 k8s 的基础之一,那么如何创建 pod 就是我们今天的关键了。这也是为什么我将它放在第一章的原因。
想看 k8s 源码,我不知如何下手,肯定是挑最熟悉最基础的部分,pod 肯定就是其中之一。而且日常的使用也让我们更熟悉 pod 的生命周期,所以我准备从 pod 入手。那么我知道 kubelet
作为操作 pod 的关键,那肯定就是代码的重点。于是我直接在代码中搜 kubelet
,找到对应文件名称为 kubelet
的文件,应该就是我们今天的目标了。
1 | pkg/kubelet/kubelet.go |
然后开始聚焦,由于源码很多,不可能面面俱到,所以一开始我们就要设定范围,看什么,不看什么。而我们今天的目标就是 pod 的创建 其他都和我们没有关系。所以,kubelet
本身的初始化等其他细节我们看到就略过。
看源码之前都自己先提出一些问题,这些问题能帮助我们更快的进入状态,以便能快速定位到所需的关键。
由于是第一篇,我就把详细的寻找过程也写进来,给小白提供思路。可略过。
pkg/kubelet/kubelet.go
shift + command + -
折叠所有方法我第一眼发下的三个方法:
HandlePodAdditions
HandlePodUpdates
HandlePodRemoves
显然这些方法是用于操作 pod 的,再看了一眼注释没错,那我们先看 HandlePodAdditions
。
通过 IDE command+点击方法名称可以看到哪里调用了这个方法
我习惯先看向上的链路,也就是是谁调用的这个方法,整个链路很清晰:
1 | Run -> syncLoop -> syncLoopIteration -> HandlePodAdditions |
然后简单查看一下 syncLoop
,从这里我们就可以理解到,kubelet 本质处理模式就是事件循环处理。启动之后通过一个 syncLoop
来不断循环处理过来的事件,在 syncLoopIteration
中根据不同的事件类型通过不同的方法处理事件,从而完成对 pod 的操作。下面的代码就描述了对于不同事件的处理:
1 | // pkg/kubelet/kubelet.go:2387 |
然后再看方法的内部,精简后,主要逻辑就是下面这样:
1 | // pkg/kubelet/kubelet.go:2506 |
在 podManager
中添加,在 podWorkers
中更新。也就是 kubelet
有两个帮手:podManager
和 podWorkers
。
那么接下来的 UpdatePod
就“有你好看”了,通常第一次看源码容易迷失的大多数原因就来源于大量的代码被吓怕了。还是那句话,我是来看 pod 如何创建的。所以其他的什么 if 判断全部都可以扔掉,因为它们都是在处理 pod 的其他状态,对于创建无关。
1 | // pkg/kubelet/pod_workers.go:926 |
根据这样的流程,你可以按照下面的路径开始理解和寻觅:
pkg/kubelet/pod_workers.go:1213
pkg/kubelet/pod_workers.go:1285
pkg/kubelet/kubelet.go:1687
pkg/kubelet/kubelet.go:1934
pkg/kubelet/kuberuntime/kuberuntime_container.go:177
1 | // SyncPod syncs the running pod into the desired pod by executing following steps:// |
SyncPod
的注释写的很清楚,步骤 123… ,这就是我们所说的 pod 的创建过程,有关 sandbox 我们稍后文章再说,你可以简单理解为这里在创建 pod 所需要的环境。其中我们关注到两个步骤:
而创建容器的方法是 startContainer
:
1 | // startContainer starts a container and returns a message indicates why it is failed on error. |
同样的,注释步骤很清晰,就是拉取镜像、创建镜像、启动,而这些最终的操作都落到了 ContainerManager
具体会在 vendor/k8s.io/cri-api/pkg/apis/services.go:34
也就是我们常说的 CRI
了。
kubelet
怎么知道要创建 pod 的?syncLoop
中有 updates chan
这个通道传递了 kubetypes.PodUpdate
事件,有事件(创建的事件)来的时候就会创建 pod。kubelet
本身去操作 CRI 的吗?还是有别人的帮助?kubelet
有 kubeGenericRuntimeManager
其中有 RuntimeService
也就是 ContainerManager
也就是最终得 CRI。下面这些,这些就是不看源码所很难了解到的内部细节了,虽然不影响整体理解,但可以作为额外扩展来学习一下。
在
HandlePodAdditions
创建的过程中出现了一个方法是:GetPodAndMirrorPod
,那么什么是MirrorPod
呢?
kubelet
会为每个 静态 pod 创建一个 MirrorPod
,而静态 pod 直接由 kubelet
管理,而不交给 apiserver
。如果 静态 pod 出现 crashes 那么 kubelet
会直接重启。而通过 kubectl get pod
看到的就是 MirrorPod
。其主要功能还是为了k8s 内部一些实现和操作方便,所以我们平常不需要知道它,也就是为什么看源码才知道,平常不知道的原因。
Static Pod 生命是 kubelet 控制的
普通的 Pod 生命是 control plane 控制的
那怎么让 APIserver 能看到它呢?答案就是 MirrorPod
如果还是不理解,我总结的不一定完整,建议看原文的参考文档:
事件循环,这是一个非常常见的设计,就是如同 kubelet
一样,通过 syncLoop
来不断循环来读取事件,通过不同类型的事件来执行对应操作。这样的优点就是解耦,并且职责清晰,还能通过事件类型来不断地扩展相对应的功能。当然其中配合 go 中 channel 和 select 写起来更加舒适。
小细节,我觉得缺少函数式编程的耳濡目染很难写出这样的代码:在 SyncPod
方法中,启动容器被封装成了一个内置函数 start
通过这样来共享了 pod 的相关配置,如果抽离成一个新的函数,参数会很多,又要封装新的对象,不划算。故,这样的写法,值得学习和感染一下。
1 | # pkg/kubelet/kuberuntime/kuberuntime_manager.go:1225 |
📢 注意,该文本非最终版本,正在更新中,版权所有,请勿转载!!
这个系列准备带领大家一起读 kubernetes 源码,推荐给下面的人群:
其实,我最大目标是通过这个系列让你明白,读源码的方法和技巧以及为什么要读源码。
其实我写 kubernetes 源码分析的文章已经 3 个版本了,这是 3 个。第一次是刚学,写了 3-4 篇,没发布,就流产了。第 2 次是写了很多发现写的不好,切入点还是太诡异了,太难理解,所以也放弃了。现在,我终于发现一个可以切入的点,并且基于这么长一段时间的积累,希望能把这个系列最好的版本写出来。
pkg/kubelet/kubelet.go:2387
),在 Goland 中可以直接双击 Shift,贴入代码位置直接跳转。// TODO 其他注意事项 🚧
让我们一起开启读源码的新篇章吧
]]>bind
listen
accept
epoll_create
epoll_ctl
epoll_wait
第一次听到的这个名词的时候觉得很是有趣,不知道是个什么意思,总觉得又是奇怪的中文翻译导致的。
复杂的说(来源于网络)TLDR;
惊群效应(thundering herd)是指多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只能有一个进程(线程)获得这个时间的“控制权”,对该事件进行处理,而其他进程(线程)获取“控制权”失败,只能重新进入休眠状态,这种现象和性能浪费就叫做惊群效应。
简单的讲(我的大白话)
有一道雷打下来,把很多人都吵醒了,但只有其中一个人去收衣服了。
也就是:
有一个请求过来了,把很多进程都唤醒了,但只有其中一个能最终处理。
说起来其实也简单,多数时候为了提高应用的请求处理能力,会使用多进程(多线程)去监听请求,当请求来时,因为都有能力处理,所以就都被唤醒了。
而问题就是,最终还是只能有一个进程能来处理。当请求多了,不停地唤醒、休眠、唤醒、休眠,做了很多的无用功,上下文切换又累,对吧。那怎么解决这个问题呢?下面就是今天要看的重点,我们看看 nginx 是如何解决这个问题的。
第一点我们需要了解 nginx 大致的架构是怎么样的。nginx 将进程分为 master
和 worker
两类,非常常见的一种 M-S 策略,也就是 master 负责统筹管理 worker,当然它也负责如:启动、读取配置文件,监听处理各种信号等工作。
图片来自: https://aosabook.org/en/v2/nginx.html
但是,第一个要注意的问题就出现了,master 的工作有且只有这些,对于请求来说它是不管的,就如同图中所示,请求是直接被 worker 处理的。如此一来,请求应该被哪个 worker 处理呢?worker 内部又是如何处理请求的呢?
接下来我们就要知道 nginx 是如何使用 epoll 来处理请求的。下面可能会涉及到一些源码的内容,但不用担心,你不需要全部理解,只需要知道它们的作用就可以了。顺便我会简单描述一下我是如何去找到这些源码的位置的。
其实 master 并不是毫无作为,至少端口是它来占的。
https://github.com/nginx/nginx/blob/b489ba83e9be446923facfe1a2fe392be3095d1f/src/core/ngx_connection.c#L407C13-L407C13
1 | ngx_open_listening_sockets(ngx_cycle_t *cycle) |
那么,根据我们 nginx.conf 的配置文件,看需要监听哪个端口,于是就去 bind 的了,这里没问题。
【发现源码】这里我是直接在代码里面搜 bind 方法去找的,因为我知道,不管你怎么样,你总是要绑定端口的
然后是创建 worker 的,虽不起眼,但很关键。 https://github.com/nginx/nginx/blob/b489ba83e9be446923facfe1a2fe392be3095d1f/src/os/unix/ngx_process.c#L186
1 | ngx_spawn_process(ngx_cycle_t *cycle, ngx_spawn_proc_pt proc, void *data, |
【发现源码】这里我直接搜 fork,整个项目里面需要 fork 的情况只有两个地方,很快就找到了 worker
由于是 fork
创建的,也就是复制了一份 task_struct
结构。所以 master 的几乎全部它都有。
nginx 有一个分模块的思想,它将不同功能分成了不同的模块,而 epoll 自然就是在 ngx_epoll_module.c 中了
1 | ngx_epoll_init(ngx_cycle_t *cycle, ngx_msec_t timer) |
其他不重要,就连 epoll_ctl
和 epoll_wait
也不重要了,这里你需要知道的就是,从调用链路来看,是 worker 创建的 epoll 对象,也就是每个 worker 都有自己的 epoll 对象,而监听的sokcet 是一样的!
【发现源码】这里更加直接,搜索 epoll_create 肯定就能找到
此时问题的关键基本就能了解了,每个 worker 都有处理能力,请求来了此时应该唤醒谁呢?讲道理那不是所有 epoll 都会有事件,所有 worker 都 accept
请求?显然这样是不行的。那么 nginx 是如何解决的呢?
解决方式一共有三种,下面我们一个个来看:
看到 mutex 可能你就知道了,锁嘛!这也是对于高并发处理的 ”基操“ 遇事不决加锁,没错,加锁肯定能解决问题。 https://github.com/nginx/nginx/blob/b489ba83e9be446923facfe1a2fe392be3095d1f/src/event/ngx_event_accept.c#L328
具体代码就不展示了,其中细节很多,但本质很容易理解,就是当请求来了,谁拿到了这个锁,谁就去处理。没拿到的就不管了。锁的问题很直接,除了慢没啥不好的,但至少很公平。
EPOLLEXCLUSIVE
是 2016 年 4.5+ 内核新添加的一个 epoll 的标识。它降低了多个进程/线程通过epoll_ctl
添加共享 fd 引发的惊群概率,使得一个事件发生时,只唤醒一个正在epoll_wait
阻塞等待唤醒的进程(而不是全部唤醒)。
关键是:每次内核只唤醒一个睡眠的进程处理资源
但,这个方案不是完美的解决了,它仅是降低了概率哦。为什么这样说呢?相比于原来全部唤醒,那肯定是好了不少,降低了冲突。但由于本质来说 socket 是共享的,当前进程处理完成的时间不确定,在后面被唤醒的进程可能会发现当前的 socket 已经被之前唤醒的进程处理掉了。
nginx 在 1.9.1 版本加入了这个功能 https://www.nginx.com/blog/socket-sharding-nginx-release-1-9-1/
其本质是利用了 Linux 的 reuseport 的特性,使用 reuseport 内核允许多个进程 listening socket 到同一个端口上,而从内核层面做了负载均衡,每次唤醒其中一个进程。
反应到 nginx 上就是,每个 worker 进程都创建独立的 listening socket,监听相同的端口,accept 时只有一个进程会获得连接。效果就和下图所示一样。
而使用方式则是:
1 | http { |
从官方的测试情况来看确实是厉害
当然,正所谓:完事无绝对,技术无银弹。这个方案的问题在于内核是不知道你忙还是不忙的。只会无脑的丢给你。与之前的抢锁对比,抢锁的进程一定是不忙的,现在手上的工作都已经忙不过来了,没机会去抢锁了;而这个方案可能导致,如果当前进程忙不过来了,还是会只要根据 reuseport 的负载规则轮到你了就会发送给你,所以会导致有的请求被前面慢的请求卡住了。
本文,从了解什么 ”惊群效应“ 到 nginx 架构和 epoll 处理的原理,最终分析三种不同的处理 “惊群效应” 的方案。分析到这里,我想你应该明白其实 nginx 这个多队列服务模型是所存在的一些问题,只不过绝大多数场景已经完完全全够用了。
原本多架构其实我还遇到的不算多,但自从苹果的 M1 出来之后 arm64 版本支持就变成了一个常态,所以会常遇到需要多架构镜像都构建的情况。以前的 docker 版本需要你去编写 manifest
很麻烦,而今天就说说如何使用 docker buildx
来同时构建多架构的镜像,其实现在已经非常方便了。
目标:构建出 amd64 和 arm64 的 docker 镜像
默认你在 docker build
的时候直接指定 --platform linux/arm64,linux/amd64
就会报错,告诉你需要使用 docker buildx
而前提是你需要有一个 builder
1 | $ docker buildx create --name mybuilder --driver docker-container |
Linux 非 arm 下可能需要你先安装 https://github.com/tonistiigi/binfmt#installing-emulators 以支持 arm 平台的打包
其实到这里已经非常简单的,就和原来的构建命令差不多,只是需要指定对应构建的架构即可
1 | docker buildx build --platform linux/amd64,linux/arm64 -t linkinstar/app:latest . |
当然,如何想要将构建完成的镜像直接 push 到镜像仓库,也可以追加参数 --push
1 | docker buildx build --platform linux/amd64,linux/arm64 -t linkinstar/app:latest . --push |
由于国内网络环境的问题,可以尝试使用 https://github.com/docker-practice/buildx 实际中我使用时发现和官方的还是有区别,所以竟可能还是用官方的 builder,实在不行可以考虑更换
1 | # 添加了镜像设置,建议在国内环境使用 |
之前我们已经讨论过 Sorted Set 在 Redis 的实现,学习到了 Redis 在不同数据量的时候使用了不同的结构来优化存储和性能,并且使用两种不同的数据结构的组合来进一步优化。而今天要讨论的 List 也如出一辙。
List 就是我们常见的列表,在很多语言中都有实现,无论是数组还是链表,它最终的表现形式都差不多,都是一个“长长的数据”,而对于不同的底层实现,所对应的操作带来的性能也不同。比如在 java 中就有 LinkedList 和 ArrayList。而 Redis 是如何做的呢?
如果你对 Redis 的 List 使用并不是很熟悉,建议下查看一下它所有支持的命令 https://redis.io/commands/?group=list
在 Redis 的老版本中 list 的实现和 Sorted Set 策略类似,对于不同数据量的情况下实现是不一样的。在数据量小的时候,使用的也是 ziplist
也就是压缩列表(可以看做数组),而当数据量变大触发条件时,就变成了 linkedlist
也就是双向链表。
ziplist 就是通过数据的长度来定位前一个和后一个数据,从而减少了双向链表中的前后指针。
而新版本中 Redis 使用了 quicklist
来实现,所以我们主要讨论的是 quicklist
是如何实现的。
首先要看的肯定是结构定义
1 | /* quicklist is a 40 byte struct (on 64-bit systems) describing a quicklist. |
我们抓重点:
不错,已经“很链表”了,那么我们问题的关键就放到了 quicklistNode
的结构上来了。
1 | /* quicklistNode is a 32 byte struct describing a listpack for a quicklist. |
看到 prev 和 next 就很“双向链表”了,我看到这里的时候都要开始奇怪了,如果就这样也没必要改了吧。既然从结构上看不出来,我们就来看看方法里面是如何实现的来判断它具体节点的连接关系。
1 | /* Add new entry to tail node of quicklist. |
从这里我们就可以看出端倪了,先不管判断条件,我们看到有两个分支:
OK,先不管 lpAppend
方法,大致就有感觉了,并不是一个普通的双链表,对于双链表中的每个节点来说都是一个新的结构,满足一定条件才会创建新的 node。所以,quicklist 解决问题的方式是压缩存储,原本的普通的双链会很长,将一些元素合并起来组成一个个 node,将这些 node 再连起来。嗯(我当时真感叹一下,是个不择手段的设计)
那么就留下了两个问题:
lpAppend
里面究竟干了什么事情?我们想到的肯定是和大小有关系,如果数据太多肯定就存不下了,看到分支的判断方法 _quicklistNodeAllowInsert
1 | REDIS_STATIC int _quicklistNodeAllowInsert(const quicklistNode *node, |
ok,非常简单和清晰的注释,isLargeElement
结合注释,太大的元素,比如 1GB,那肯定单独创建 node 实在了,没话说。the lowest limit of 4k
好了,破案了。
最后,注意一个细节 listpack
?是什么?没错,它就是我们下一个问题的答案
在老版本里面,都会告诉你,quicklist 就是双链套 ziplist,没错在老版本确实是这样实现的,而我只会告诉你看最新的源码,学的才是最多的。redis 已经慢慢在用 listpack 替换掉 ziplist 了。听我下面慢慢道来。
ziplist 其实最大的一个问题就是连锁更新,由于 ziplist 利用数据长度来定位前后元素,通过计算长度来得到下一个元素的位置。那么势必问题就是当数据更新时,长度也更新,前一个数据的更新会导致后面数据一起动,一起挪动位置。 虽然我们知道在列表中直接更新某个数据并非常见,但确实当真正出现这样场景的时候就会变的非常困难。而原本的双链表由于保存的时指针,更新数据非常容易。指针都不用动。
那想要避免这个问题,就需要对 list 的编码方式重新设计。
这个数据结构的设计理念可以参考:https://github.com/antirez/listpack/blob/master/listpack.md
1 | /* Each entry in the listpack is either a string or an integer. */ |
单个元素的结构是:
1 | <encoding-type><element-data><element-tot-len> |
就这样?对就这样。设计的关键点主要就是编码方式,它通过 11110001
这样的形式来告诉你后面的数据是什么类型的数据,保存数据的位数是多少。
举例来说:11110001 表示一个 16 位整数,数据记录在后续 2 个字节的 data 中。
ziplist 由于通过长度来找到后一个元素的位置,当数据变化是长度跟着一起变了。而 listpack 要注意了,它保存的是编码方式,而对于相同类型的数据,比如 16 位整数,后面存储的位置是固定的就是 2 个字节,即使数据变了,那也还是那个位置。相对应的,从后向前查找时与 ziplist 是一致的通过 element-tot-len 能找到前一个元素的位置,不会影响。
在看 Redis List 实现之前,我经常会觉得,不就是个 list 的嘛,弄个数组一下不就搞定了?但对于追求极致性能和巨大内存压力的缓存来说,能优化一点就要尽力去优化一点。对于 List 我们能学到:
这两点是我们能从中学到的,虽然我们不一定会遇到如此要求下的极致优化,但这样的思路又让我们有了更厉害的武器。
在没有真正认识 Redis 之前,你可能都低估了它
一开始对于 Redis 我们的认识都是一个 key:value
的缓存,当然用的最多的也就是这个作用。但随着 Redis 的不断发展,慢慢的我就发现它有的功能越来越多,它可能在一定程度上帮我们快速简化一些高并发场景下的开发。我觉得它其中最重要的设计是它的 数据结构 。通过几个基础的数据结构的组合,就能实现一些高性能的结构。比如我们今天要讨论的 Sorted Set 就是这样一个结构。由于 Redis 中称为 zset 所以后文中为了简化直接也叫 zset。
我觉得可能很多同学还没有用过,其实非常容易理解,就是一个有序的集合,无论你以什么顺序添加元素,最终都会根据分数排成一个有序的集合。通过它我们可以快速获得一个组数据的最高的几个值。
堆?没错,我的第一反应也是这个,要实现一个这样的结构最先想到的就是堆或者说是优先队列的实现,完美匹配。
但,不对,我们知道,对于堆,我们只能快速得到最大或最小值。而对于 zset 其中有一个方法是 ZRANGE key start stop
也就是可以获取一个范围,比如获取排序后的第 3 位开始后的 5 个元素。而且它可以快速获取到一个某个元素的位置 ZRANK key
,也就是快速查询到某个元素的排名。
所以,这显然不能简单的用“堆”搞定了。
这两个结构我在这里不具体展开了,不然篇幅太长。如有需要,请查看参考链接:
废话不多说,直接开代码最直接。
1 | typedef struct zset { |
所以 zset 的结构就是一个字典(hash 表)+ 一个 skiplist(跳表)就完事了呗?简单,走了走了。别急~ 其实并不是这样。
我来看一下元素的 add 方法就明白了里面的玄机
1 | int zsetAdd(robj *zobj, double score, sds ele, int in_flags, int *out_flags, double *newscore) { |
ziplist
和我们上面提到的 `dict + skiplist zset_max_listpack_entries
默认 128,还有一个条件是,有序集合保存的所有元素成员的长度都小于 zset_max_listpack_value(64)
字节OBJECT ENCODING key
命令查看对象的编码方式(数据结构)1 | 127.0.0.1:6379> OBJECT ENCODING linkinstar |
这是非常常见的一种查询优化策略,就是用空间换时间,为了实现快速用 key 查询该元素的分数和位置就能用到 dict(ZRANK)
1 | long zsetRank(robj *zobj, sds ele, int reverse, double *output_score) { |
可以看到,如果是 SKIPLIST 的 encoding 也就是有 dict 的数据结构的时候,就直接 dictFind
了,非常快。而且作为一个 dict 非常好维护,添加和删除元素的时候,同时操作一次 dict 就可以了。
Most sorted set operations are O(log(n)), where n is the number of members.
Exercise some caution when running theZRANGE
command with large returns values (e.g., in the tens of thousands or more). This command’s time complexity is O(log(n) + m), where m is the number of results returned.
绝大多数 zset 的操作都是 O(log(n))
但 ZRANGE
需要注意。当实现为跳表的时候,需要进行 ZRANGE
查询的时候,需要查询一个范围值,查询第一个目标肯定是 O(log(n))
然后向后找 m 个,所以显然 m 太大会影响性能。
第一个能想到的应用场景肯定是这个,毕竟它的特性太像了,将需要排序的人和分数扔进去,一下就能搞定一个高性能的排行榜。就不多说了。
这个是我一开始也没想到的。对于限流我们常用的肯定是 token 或漏桶什么的,当然也有窗口,由于固定窗口有边界问题。滑动窗口就可以解决很大一部分问题,而如何滑动就很关键了。此时 zset 就能帮助我们来实现这个滑动窗口,我们可以通过将用户访问的时间戳作为分数扔进去,每次访问的时候可以丢弃掉过期的分数,而在 zset 的中的数量就是限流的大小了,超过数量就拒绝了。
具体可以参考:https://engineering.classdojo.com/blog/2015/02/06/rolling-rate-limiter/
由于 zset 它本质就是个堆,那其实这个特性还可以被用在例如:撮合交易、抽奖等等需要堆的场景。
Redis Sorted Set 给我们带来的思考可能有下面这些:
如果你想要对 K8S 做二次开发或者说在原有的基础上封装一些功能让开发者更加好用,那么 Operator 的用法你可必须掌握。
我觉得 Operator 真的是 K8S 扩展设计的非常巧妙的一点,它好像一个插件系统,你有了它就好像有了 k8s 的一个扩展操作权,能扩展出各种各样的用法。那什么是 Operator 呢?这需要从 CRD 说起。
首先我们需要知道第一个概念就是 CRD(Custom Resource Define)
,自定义资源定义,顾名思义就是使用者可以通过 CRD 来创建自定义的资源。我们知道在 K8S 中有各种各样的资源 Pod
、Deployment
、StatefulSet
… 在编写 yaml
文件的时候会指定对应的资源类型。
官方文档:Create a CustomResourceDefinition 其中有一个实际的 CustomResourceDefinition 案例
1 | apiVersion: apiextensions.k8s.io/v1 |
然后,有了它,你就可以像操作一个 pod
一样操作这个你定义的对象了,你还可以为它定义一些必要的属性(properties)。那么有了 CRD 之后,我们就有了一个非常强大的能力来扩展 k8s 已有的功能了。但是只有这样还是不够的。因为它仅仅定义了你所需要的资源,但是这个资源如何被操作呢?
有了资源没有人管肯定也不行,那么我们就需要一个 Controller 来控制它的行为和动作了。其实 Controller 本质是一个控制循环。我们知道,k8s 的控制模式其实是基于一个状态模型的,它将监控所有资源的状态,当现在的资源状态不满足用户定义的资源状态的时候,它就会做出调整,想办法让资源调整状态到预期值。
1 | for { |
当 Controller Manager 发现资源的实际状态和期望状态有偏差之后,会触发相应 Controller 注册的 Event Handler,让它们去根据资源本身的特点进行调整。
所以,我们可以简单的理解为 Operator = CRD + Controller 也就是说自定义资源加自定义控制器就是 Operator,使用它我们不仅可以自定义我们想要的资源,还可以通过我们想要的逻辑和方式对它进行操作。
那此时你就可以想象的到它是有多万能了。比如:有了自定义资源,你可以定义你想要的各种属性,原来 deployment 只有那些属性,现在你就可以扩展各种你想要的属性了,并且你可以组合一些现有的资源。同时有了自定义控制器,你就可以任意的进行操作了,最重要的是,你能在出现各种情况(重启、异常退出等等)需要调度的时候第一时间知道,并且可以控制如何去调度,调度之后应该配置什么属性等等。
那本文下面就带你来快速制作一个 Demo 来体验一下 Operator,当然前提是你需要有一个可以操作的 k8s 环境。
开发 Operator 并不一定要用 kubebuilder 还可以使用 https://github.com/operator-framework/operator-sdk 我更习惯用 kubebuilder 而已
安装文档见:installation
1 | $ curl -L -o kubebuilder "https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)" |
1 | $ mkdir opex |
1 | $ kubebuilder create api --group example --version v1 --kind ExampleA |
此时项目结构已经创建好了,kubebuilder 也为我们创建了对应的 CRD 模板和 Controller 模板。你可以先大致浏览一下项目结构。下面我们就会开始编码的工作。
首先明确一下我们的目标,我们的目标是创建一个 CRD 和 Controller 来体验一下 Operator。我们这次创建的 CRD 扮演一个监察的角色,当整个集群中出现带有指定名称的标签(Label)的对象时,监察就会改变自己的状态,变成监控中。
修改 api/v1/examplea_types.go
文件
1 | package v1 |
可以看到这里我们主要是定义了 ExampleA
的 Spec
,也就是我们常常在 yaml
文件中写的 spec
属性,其中我们添加了 GroupName
也就是一个组名。
修改 internal/controller/examplea_controller.go
1 | package controller |
核心逻辑非常简单,就是遍历所有的 pod,如果发现 label 中带有对应 groupName 的 pod 就修改当前 crd 的 UnderControl 状态为 true
1 | if item.GetLabels()["group"] == exp.Spec.GroupName { |
其中有几个要点
Watches
方法的第一个参数就是监控的对象类型,第二个参数就是 handler。使用 kubebuilder 的方便就是部署和调试很方便,模板都有,执行下面的命令生成并将 CRD 安装到 k8s 集群中。
1 | $ make manifests |
安装成功后,查看一下
1 | $ kubectl get crds |grep linkin |
1 | error: accumulating resources: accumulation err='accumulating resources from 'bases/example.linkinstars.com_examplea.yaml' |
如果出现这样类似的错误,通常是由于生成文件名 s 的问题导致的,修改 config/crd/kustomization.yaml
文件中的 resources:
为 - bases/example.linkinstars.com_exampleas.yaml
对应的正确名称即可
建议新开一个终端窗口来启动,它会前终端中运行并输入对应的日志,方便后续查看
1 | $ make run |
1 | 2023-08-03T23:07:21+08:00 INFO controller-runtime.metrics Metrics server is starting to listen {"addr": ":8080"} |
创建一个 CRD config/samples/example_v1_examplea.yaml
内容如下,指定 groupName 为 business 也就是当出现 business 的 pod 这个 crd 就开始认真监察了。
1 | apiVersion: example.linkinstars.com/v1 |
1 | kubectl apply -f config/samples/example_v1_examplea.yaml |
然后,我们查看一下当前的 CRD 状态,可以看到现在状态应该是空的
1 | $ kubectl describe ExampleA my-opex |
然后新建一个文件 example_v1_examplea 1.yaml
1 | apiVersion: v1 |
然后再次查看 CRD 状态
1 | $ kubectl describe ExampleA my-opex |
可以看到控制状态已经变成了 true 了,同时你也可以在控制台的日志中看到资源状态变更的日志
1 | 2023-08-03T23:28:21+08:00 INFO 开始调用Reconcile方法 {"controller": "examplea", "controllerGroup": "example.linkinstars.com", "controllerKind": "ExampleA", "ExampleA": {"name":"my-opex","namespace":"default"}, "namespace": "default", "name": "my-opex", "reconcileID": "7578ca2f-2bfe-4e4b-ba1c-3d43ff366ddf"} |
至此,我们的上手工作就已经完成了。之后你就可以摸索更加高级的各种操作了,根据具体的实际业务场景需求来满足不同的需要。
如果需要回收删除对应的资源先使用 kubectl delete -f
删除所有创建的测试。然后直接执行 make uninstall
就可以了。
网上有很多对于 helm 和 Operator 的类比,其实我觉得二者方向就不同。helm 是将所有需要部署的资源统一打包在一起,方便打包和部署。当然 CRD 也可以实现类似的功能并且更加强大。但 helm 是对于已有资源的集合,大多数部署情况 k8s 提供的 deploy/service/… 等等已经足够用了。最最最关键的一点,helm 没法控制循环,Controller 才是 Operator 的灵魂。
我觉得很多人会认为 Operator 复杂或者很难上手,多数情况是不理解 k8s 内部原理导致的。如果你非常清楚他的 Controller Manager 的原理和行为,直到控制循环,其实 Operator 已经封装的非常好了。这样的设计我觉得巧妙的原因是扩展起来真的非常方便。
kubebuilder 官方还有一个 CronJob 的教程,让你快速使用 Operator 实现一个 CronJob 的功能。我觉得对于新手可能还是稍微复杂了一点点,当然你看完本文并且实践之后建议你跑一把玩一玩会更容易理解。
]]>最近好忙,也好久没水 Golang 的文章了,最近来水一些。说回今天的问题,这个问题非常简单,也能被快速验证。
Golang 中 能否将 slice 作为 map 的 key?
如果你现实中使用过,那么这个问题对于你来说其实意义不大,因为不行就是不行,可以就是可以。
如果你完全没这样使用过 map,那么这个问题对于你来说可能就有意义了。
所以其实,这个问题的本质是:“slice 能否进行比较?”
答案显然是不能的,因为 slice 是不能使用 “==” 进行比较的,所以是不能做为 map 的 key 的。
而官方文档中也说明了 https://go.dev/blog/maps
As mentioned earlier, map keys may be of any type that is comparable. The language spec defines this precisely, but in short, comparable types are boolean, numeric, string, pointer, channel, and interface types, and structs or arrays that contain only those types. Notably absent from the list are slices, maps, and functions; these types cannot be compared using ==, and may not be used as map keys.
所以如果真的需要以 slice 类似的数据来作为 key,你需要使用 array 而不是 slice,如下:
1 | package main |
那么只要数组中的每个对应下标元素相等,则 key 相等
]]>我们都知道,Redis 是单线程(非严谨),你是否想过,一个线程要如何处理来自各个客户端的各种请求呢?它忙的过来吗?没错,它还真的能忙过来,并且还井井有条。其中多亏了 IO 多路复用,而不仅仅是它,事件机制在其中也是一个不错的设计。
之前我提到过有关于 IO 多路复用对于 Redis 的影响,IO多路复用和多线程会影响Redis分布式锁吗? 其中有部分内容其实已经提到了,所以本文会更加关注于事件机制本身。
PS:Redis 高版本已经支持多线程处理某些事情,为了简化,这里不做讨论,故下文出现的单线程仅是描述那些必须单线程执行的场景。
首先,让我们来思考一下,如果是我们自己来实现,会尝试如何去做。
最笨的方法,那么就是来一个客户端 accept 一次,然后给什么请求做什么事情,先来先做,做完走人,对吧。那显然这样太慢了,要知道作为一个缓存,这样设计要把人给急死。
当然,我们也可以说,来一个我开一个线程单独处理你,相当于你一来我就单独找人为你服务,而服务的人最终会将请求给到一个处理中心,让处理中心统一去处理,然后将结果返回。但显然 Redis 没有那么多资源让你浪费。
于是要找人帮忙,那就是 IO 多路复用,至少它能帮我解决前面服务的问题,fd 我就不管了,直接告诉我哪些人来了,并且告诉我有事的是那些人。
既然 epoll_wait 能 告诉我们有那些 socket 已经就绪,那么我们就处理就绪的这些就可以了。但我们需要一个合理的机制来帮我们来优雅的处理他们,毕竟 Redis 后面只有个单线程在处理。由于处理没这么快,肯定需要一个地方来存放未处理的这些事件,那很合理就能想到需要一个类似 buffer 的东西。
所以,对于这个事件机制,我第一个想法就是弄个队列,或者 ringbuffer 来搞,那不就是一个生产消费者模型吗?
那么下面我们就来看看 Redis 它是如何设计。
首先 Redis 分了两类事件
OK,看完图我们就有了一个大致的印象,为了灵活的处理不同的事件,需要将事件分配给处理器去处理,这里也是我们之前思考的时候没有想到的一个设计。通常来说对于任何的处理往往都有这样一个分配器去分配所有的任务,这样可以让扩展更加灵活,如果后续有新的类型,只需要扩展出一个新的处理器就可以了。
https://github.com/redis/redis/blob/9b1d4f003de1b141ea850f01e7104e7e5c670620/src/ae.c#L493
首先入口在 aeMain 这个简单,就是循环,也正是这个循环处理着所有的事件,我们可以看到,只要不停(stop),就会一直循环处理
1 | void aeMain(aeEventLoop *eventLoop) { |
然后就是我们重点的 aeProcessEvents
方法,其中重点就是调用 aeApiPoll
获取当前就绪的事件,然后你就能看到我们的 aeFileEvent
也就是文件事件了,最后还有 processTimeEvents
处理定时事件。那么事件本身,是如何处理的呢?就是 rfileProc 和 wfileProc 一个处理读一个处理写。那么问题来了,这两个方法具体是什么呢?卖个关子,我们先瞅一眼 aeApiPoll
1 | int aeProcessEvents(aeEventLoop *eventLoop, int flags) |
这里其他都不重要,重点就在我们熟悉的 epoll_wait
,获取所有就绪的 fd 也就能知道所有需要处理的事件了。
1 |
|
好了,我们来解密究竟 rfileProc
和 wfileProc
是什么,aeCreateFileEvent
方法是用于创建 FileEvent 的方法,其中的入参里面有 aeFileProc
没错就是它了。根据不同的类型用不同的 handler 创建不同的 event。也就是说,最终的处理方式是通过参数传递进去的。
1 | int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask, |
如果是我设计,或许绝大多数情况下就是弄一个对象,而对象根据具体的事件类型执行不同的处理逻辑。最多用一个 策略模式 可能就上天了。而 Redis 的这样的设计思路,类似一种闭包的设计,或者说函数式编程的一种思路吧,将具体的处理对象,处理方式,处理结果,通通包含在内。我们先不说这样的设计好不好,但给我的第一印象是,这样的设计会让我觉得最终执行的整个处理会更加连贯,并且处理的时候执行的全部逻辑是高度一致的,而处理方式的本身真正做到了可扩展。
那我们通过 Redis 的事件机制能学到什么呢?
今天继续来看看有关 Redis 的一个问题,主从复制。通常,对于大多数的场景来说,读比写更多,于是对于缓存的水平扩展,其中的一个方式 “主从复制” 就是一个常见的思路。有了主从复制,那么可以扩展出很多从节点来应对大量的读请求。那么问题来了 Redis 的主从复制是如何实现的呢?
PS:本文仅关心复制的机制,不关心主节点下线重新选等等异常情况
题目本身不复杂,提问者问这个问题的想法可能会有下面几个方面
假设你完全没有看过 Redis 源码来思考这个问题,可以从下面几个角度去尝试分析,并猜测答案。
有了上面的思考,其实实际也就有思路的。首先主从复制肯定有两种情况,一种就是第一次复制,也就是要执行一次全量复制,将主节点的所有数据到复制到从节点上去;另一种就是增量复制,在数据同步之后后续的增量数据保持同步。
因为需要全量同步所有数据,我们知道 Redis 数据在内存里面,既然要发送,那势必需要先持久化一次。也就是先 SYNC 一遍,通过方法 startBgsaveForReplication
来完成的
代码位置在:https://github.com/redis/redis/blob/14f802b360ef52141c83d477ac626cc6622e4eda/src/replication.c#L855
这个问题不大, 就是保存一个 RDB 文件。
这个也很不难,就是将数据直接扔过去就好了。
代码位置在:https://github.com/redis/redis/blob/14f802b360ef52141c83d477ac626cc6622e4eda/src/replication.c#L1402
后续的任务就是增量同步后续产生的数据了。在猜测时我们想到有两种复制方式,一种是直接复制数据,这种方式复制 RDB 是可行,在全量同步的时候用这个肯定更好,如果同步命令那么从节点还需再执行一次过于复杂和麻烦,还耗时。而对于后续的增量同步来说,肯定是同步命令来的更高效(不过还是得看实际)。
下面就是传播命令的方法:
1 | /* Propagate the specified command (in the context of the specified database id) |
这个方法就是将增量命令传播给 AOF 和 Slaves,AOF 就是持久化的另一种方式,而 Slaves 就是我们需要同步的从节点了。具体 replicationFeedSlaves
方法就不具体看了。
这个其实是我们在猜测的时候漏掉的,想来也是,master 肯定需要知道 slave 的状态,如果连不上了,肯定要处理,在 replication.c 中有这样一个方法
1 | /* Replication cron function, called 1 time per second. */ |
看名字和注释就秒懂了,每秒执行一次的同步定时任务。
而其中调用了 replicationFeedSlaves
方法,也就是 PING 一下,看看活着没
1 | replicationFeedSlaves(server.slaves, -1, ping_argv, 1); |
如果我们 redis 存放的数据很多,第一次同步会有两个时间,一个是 bgsave 的时间,这个时间其实还好,毕竟平时就是要执行的,而第二个时间就是传输数据的时间,这个时间就取决于带宽了。
不过首先这个操作时,主节点依旧可以被读写,只不过操作均被缓存了,所以倒是不必担心这段时间无法被使用。难就在如果数据过多可能真的会导致一个问题就是,同步->超时->重试,然后不断循环,所以为了避免这样的情况出现,建议 Redis 前往别直接把主机全部内存吃完。通常 maxmemory 设置为 75% 就相对不会出现问题,也不容易 OOM。
当然,有人肯定会问,能不能直接先手动拷贝 RDB 文件来减少同步时间,实际操作过我告诉你,不要手动操作,容易出现意想不到的问题,当出现问题之后,数据还是会不同步,还是会执行重新同步,还不如第一次就手动让程序自己来。
命令在传播的阶段设置了主从同步发送的缓冲区,通过维护一个缓冲区来保证当主节点无需等待,从节点自己凭实力拿就好了,即使有一段时间突然抖动了一下,也没事,缓冲区里面还有,继续同步就行嘞。但当完全超过缓冲区的承受范围,那么还是需要执行一次全量同步来保证数据一致。
之前看代码的时候就注意到了一个参数 repl_diskless_sync
翻译过来就是无盘同步,显然这个优化是 Redis 注意到第一次同步的时候,如果马上写入 RDB 显然是有点慢了,直接 dump 内存肯定会来的更快,所以这就是无盘,也就是不先落盘。
最后用一张图来总结整个过程
我们看着这个图我们也可以想到,其实这样复制的策略在绝大多数复制的场景中都是适用的,如果实际没有命令这个说法,那就将数据拆分成小块(chunk)来同步。需要注意点和优化点可能 Redis 都帮你想好了,对着抄就可以了。所以,我称为一种设计为 ”单向同步“,那么如果什么是多向同步呢?也就是多个人同时编辑或操作数据,互相同步的策略,此时就需要一些 diff 算法和策略了,你也可以考虑设计看看,看具体会遇到什么问题。
首先,非常难得这个系列重新开始更新了,因为之前一直在纠结选题,很多时候我会觉得一些面试题或者是提问很没有实际意义,就好像是为了八股文而八股文,而后面渐渐发现,只要你去追寻背后的一些思考,还是能留下些东西的。
这个问题其实本身很”简单”,那么只有两种可能一种你看过 Redis 的源码,一种是没看过进行猜测。不过首先我们可以说一些前奏:
setnx
这个命令的,后来废弃了,而将 nx 作为 set 的一个参数项,同时也就支持指定过期时间set nx
来实现分布式锁,所以估计提问者想确保你了解原理,从而使用分布式锁的时候更加安心,或者想通过这个问题来引出分布式锁的问题我们首先可以大胆猜测一下实现方式。可以直接先简化一下问题,本质就是给你一个 map,然后实现一个 setnx 方法,当 k 存在则直接失败。最关键的问题就是解决并发问题。
那解决并发的问题,能想到的就是要么锁,要么 cas,要么直接队列卡死,对吧。
然后,结合所知道的,redis 本身执行命令就是单线程,不需要锁,没有并发,那么直接查一把,然后处理就完事了。如果不看源码应该没有别的坑了吧。
话不多说,直接上源码:
1 | /* Forward declaration */ |
看到代码直接吓坏了吧,不敢看了对吧?直接来看答案了对吧?其实这代码挺好懂的,没啥坑点与想的差不多。抓住 OBJ_SET_NX
表示用户输入的命令带有 NX 表示,其他一概不管。
lookupKeyWrite
直接利用这个方法查一把,如果 found(找到了) 并且是 OBJ_SET_NX 也就是 NX ,那么直接返回了。这里可以多说一句,注意到这个方法名称带有了 Write 了吧,所以这个方法是专门用来写 key 的时候用的,是保证并发下可用的。所以其实这个问题真的没有那么复杂,所以提问者还有一个考察点就是你对于 Redis 单线程执行命令是否有强烈的信任或者是熟悉细节。如果你信,那么其实猜也能完全猜出来的。
]]>有时候你需要再 SQL 执行之前对于 SQL 语句进行改写,有可能是修改表名字段名,有可能只是添加注释,这些看起来奇怪的操作其实有时候是为了帮助在数据库之前的 proxy 来实现某些功能,比如最常见的分库分表,读写分离,多租户等等。
举个具体的例子:有些数据库中间件支持在 SQL 语句之前添加注释来实现读写分离https://help.aliyun.com/document_detail/477438.html
支持在SQL语句前加上/*FORCE_MASTER*/
或/*FORCE_SLAVE*/
强制指定这条SQL的路由方向
所以当我们使用 orm 库的时候,就需要有一个类似钩子的东西,能在执行之前想办法将 sql 改写为所需要的样子,这就是今天的需求。
如果你只想知道如何使用,可跳过本段,直接去看最后的实现部分
一开始我做了各种尝试,由于 xorm 本身其实并没有相关文档说明,寻找并尝试了半天,虽然最后实现了,但是路径比较曲折。
最开始我想到的就是肯定是 Hook,不错,如我所料,确实有 Hook,并且里面有执行的 SQL,我非常高兴,然后直接开干。
1 | // Hook represents a hook behaviour |
于是我直接实现了一个自定义的 Hook 然后使用 BeforeProcess
方法,在执行 SQL
前,替换了 ContextHook
其中的 SQL
代码非常简单,我就不展示了,然后调试了半天,发现打印的 SQL 已经被改写了,但实际执行却还是原来的 SQL。
为什么?于是我去翻了源码,发现,见鬼,这个 ContextHook
里面的 SQL 仅仅是为了日志打印用的。也就是说,这个 Hook 其实目的很明确,就是为了打印日志和计算 SQL 执行时间用的。
在尝试 Event 之前我其实找了很多曲线救国的方式,但确实实现不了。然后我在文档里面找到了 Events。
比如:BeforeUpdate()
BeforeDelete()
等等。问题是,Event 无法获取到需要执行的 SQL,事件仅能拿到需要执行的条件,而还没有解析成 SQL,所以这个方案也不行
于是我翻遍了源码,看看源码之前到底有什么操作能帮助我来完成这件事,然后发现了 Filter
1 | // Filter is an interface to filter SQL |
Filter 原本的作用是帮助 dialect 去过滤一些特殊数据库的特殊 SQL 来帮助 xorm 来适配各种类型的数据库。我发现在 SQL 执行之前,只有它能获取到 SQL 并改写,并且改写后的 SQL 能被执行。但,你从上面的接口也看到了,Filter 除了 SQL,其他什么也没有。于是我其实返回去尝试了很多其他的解法,发现仍然无解,最后去官方仓库提交了 PR,将 context 信息传递了进去,至此,就有了后面的实现。
首先需要自定义 Dialect 和 Filter,因为 go 没有继承,所以使用组合的方式来实现多态,将原来的 dialects.Dialect 定义包装,并重写 Filters 方法用于获取到我们自定义的 Filter。
注意,mysql 默认是没有 Filter 的,其他数据库可能存在 Filter,可能需要将原来的拿过来并在末尾 append 一个自定义的 Filter。
替换 SQL 就很简单了,你只需要按照你的需求,改写 SQL 并返回就可以了。如果你和我一样需要额外的信息,可以从 context 中获取,比如传递用户信息,或者 id,用于分库分表或实现多租户等。
1 | type MyDialect struct { |
然后 xorm 只有 NewEngineWithDialectAndDB
方法执行自定义 Dialect
,所以用这个方法创建 Engine。并且使用 OpenDialect 方法将默认原先 xorm 的 mysql 对应的 Dialect 拿出来封装成自己的。
1 | driver := "mysql" |
其实总的实现并不难,但过程还是异常艰辛,不过好在后面的路都很顺畅了,有了 SQL 你就可以解析它,比如解析需要操作的表名和操作语句,查询走 A,插入走 B 等等。最后我码住一些 Golang 的 MySQL proxy,或许你也需要。PS:目前我没有使用以下的库,仅仅是将抽离了下面的几个库里面的协议部分,伪造了 MySQL 服务来使用。
]]>在此前,我都是通过一些硬件设备来构建一个私有网络,并且能有一个稳定的公网 IP,外部可以通过设备厂商对应的外部资源来构建一个私有网络,随时随地访问家中设备,如:NAS 。但,人生无常,大肠包小肠,最近很不稳定,于是准备了一个后手方案,防止意外。
之前,就有了解过各种方案,其中 Tailscale 是其中一个,比较青睐它的协议本身,并且现在已经可以自建,协议本身也开源,于是最近就折腾起来了。
一开始了解到 Tailscale 并不是因为它本身,而是它写的一篇有关 NAT 的一篇博客,我觉得是原理解释的非常清晰的一篇博客了,所以我就顺便去看了它本身是做什么的。
https://tailscale.com/blog/how-nat-traversal-works/
有关它本身的原理我建议直接看官方文档,直接看配图就能懂 https://tailscale.com/kb/1151/what-is-tailscale/
安装非常简单,我不过多赘述,记得先去官网注册一个账号
https://tailscale.com/download/linux
由于 AppStore 没办法上架国内,所以只能选择 Cli 或者 Standalone
https://tailscale.com/download/mac
建议使用老版本的 cli 工具 https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS
1 | go install tailscale.com/cmd/tailscale{,d}@v1.38.2 |
Standalone 版本 在下方链接的最下面一个
https://pkgs.tailscale.com/stable/#macos
1 | $ tailscale up # (any optional arguments) |
会让你访问一个网址,通过授权就可以了。
如果你使用的是 GUI 版本,一定记得要给权限,到隐私下面把权限开了,否则会一直卡主不动
1 | # 启动后台程序 |
启动之后到官网的管理后台就可以看到各个设备的情况了。当然你可以再界面或者通过命令行看到各个节点的状态,并且能看到对应的内网 ip。通过内网 ip 就可以直接访问了。
官方的中继服务都不在国内,并且用的人多,不花钱肯定慢,如果有条件还是建议自建。一方面是速度有明显的改善,有的时候突然晚上我会出现非常卡的情况,而自建的延时很低很低。另一方面,自建从心里角度能让你安心,毕竟节点访问就不通过官方服务器了(虽然按照它开源的协议本身,其实中继节点本身无法做什么劫持,不过心里安慰很大)。
自建 DERP Servers 官方文档 https://tailscale.com/kb/1118/custom-derp-servers/
1 | go install tailscale.com/cmd/derper@main |
1 | derper -hostname xxx.com -c=derper/derper.conf -http-port=-1 -a :8443 -certmode manual -certdir /cert-dir/ -stun -verify-clients |
-certmode manual
证书手动模式,支持手动通过 -certdir
指定证书位置,注意证书文件名词必须为 域名.crt
-verify-clients
必须要加!!!我看很多教程都没有,但是没有这个你的节点就有可能被扫描到被滥用进入官网的管理界面,配置 Access Controls,仅需加入
1 | { |
其中 DERPPort 为之前你启动时配置的端口,如果你还自定义指定了 STUNPort
,那也需要配置一下。
然后重启节点,就可以从官网的控制台查看是否正常。在 machines 中,点击一个节点详情,最下方 Relays,如果出现如下样子,证明已经成功。
1 | Relays Relay #900**: 8.59 ms |
中继节点启动后,如果出现类似下面的日志不停地有,你需要再中继节点服务器上本地运行一个 Tailscale 就可以了
1 | derp: 102.72.254.106:56228: client 6e6f64656 |
总的来说,使用如果是自建感受还是很不错的,如果非自建,有时候会不太稳定,本人暂时没有出现打洞不成功的情况,一切都非常顺利。
偶然得到这本书,之前确实没有听说过这本书。只要是书,我都如获至宝,读完之后,也有些感触。
本书介绍了许多思维方式,在很多看来会比较虚,由于将的都是方法论和模型,只有中间穿插了个人经历和实际项目。那么,很多人就会问了,有用吗?
很负责任的告诉你,有用。
因为很多人看完之后,会觉得在心里没有留下什么东西,又没有实际对自己的工作有所帮助,但其实书中很多思维方式都是经验总结。只有当你触发特殊场景的时候才会“刷”到存在感。
在思维上面,我也经历了几个阶段。一开始就想所有人一样,问题来了就思考如何解决,想到解决方案了,对比一下,没问题,解决。
学到了一些模型,感觉有用,于是在思考问题的时候,就套用各种模型。比如常见的四象限,或者金字塔,又或是冰山。然后就发现了一个问题,用了和没用好像一样?对,好像就是一样的,即使不用我感觉也是一样的。
于是我很长时间都弃用了,而又一次让我捡起来的时候,是之前一家公司的一个运维同学。在对比某两个部署方案的时候,我虽然不在运维组,但是我也尝试思考了一下。之后他在说明方案的时候,就是用了一个多维度的模型,当模型出现的那一刻,我就知道,有一个方面我完全没考虑到,那就是市场。
回到这本书,这本书就是总结了一些看似有用有无用的方法或者思维方式,一些经验你现在看起来没用,但是到实际中,你会发现,如果让你自己去总结,你是没法总结出来的,你只能给出结论。
这是书中其中一章,对于我来说影响最大的
做了那么久的程序员,我慢慢的会发现,知道的越多,不知道的就越多。然后,就发现自己出现了第一个我认为的“病态”:不敢。在刚刚写代码的时候,无论产品经理说什么,或者甲方想要什么,我都只会说“这个可以做”,“这个没问题”。那时无论什么技术,用过没用过,都敢直接上,当初 15 年第一次接触 Redis 就敢直接上秒杀活动。虽然这样的行为现在想来真的很可怕,哈哈。
而现在呢?很多时候都在犹豫和权衡,当然,不是指的技术选型,而是学习和思考。总是在想这个学了有没有用,而停滞在了开始之前。然后,就是不敢,不敢挑战。这里面有担心背 P0,又担心无法再规定时间实现等等。
意识到这个问题,是因为我曾经问领导,这件事有风险(很大)怎么办?他说:既然我们决定了,就去做。当时,突然我有两个想法:
这也或许也就是解决我病态的关键,也就是书中提到的 IBM 的前CEO郭士纳回复的:”我是新来的,别问我问题在哪或是有什么解答,我不知道“ 但是,他对自己能力有足够的自信来快速成长到能解决随时出现的问题。
这个词在整本书中,我觉得也有体现,作者对于一个架构或者代码的编写是有足够推敲的。他不断地在思考和反思,可能有时候我缺少的也就是这样的反思。作为一个程序员,工匠精神很重要,其实我觉得更多的是一种契约精神,你写这个代码,就如同给用户同事一个契约,用户好用,同事能用好。
跳出书本之外,提一下,如何你真的觉得思维方式方法无用,我强烈建议从马斯克(Elon Musk)提到的 “第一性原理” 开始。当时我就是受到了这个启发。比如:
方案 A 和 方案 B 哪个好?一般人思考的就是:A 的优缺点,B 的优缺点。而你不妨先考虑,为什么需要方案 A 和方案 B,本质原因究竟是什么,如果可以的话,我都不要行不行。
]]>我之前很烦恼 MacOS Option + 任意键
会输出 类似 åçΩçƒåß
这样的特殊字符。我根本没必要去输入这些特殊字符,很多 IDE 的键盘快捷操作会使用 option 加字母的方式来操作,比如 git 提交,在 IDEA 里面就是 option + i 的操作,这样就会在 commit 信息里面带有一个特殊字符。然后官方有没有设置可以关闭,这个问题一直困扰我很久了,最近才得到一个可行的解决方案。
下载自定义键盘布局 layout
https://gist.github.com/haosdent/573ea124e5ea666fc576
复制到对应目录
1 | ~/Library/Keyboard\ Layouts/x_layout.keylayout |
添加对应自定义键盘布局
然后就可以了,只要你使用这个键盘布局进行输入的时候,那么 option 键 + 字母就会失效。
如果你和我一样使用了第三方的输入法,还需要在第三方输入法对应的配置项中找到键盘布局并修改,我目前使用的是搜狗输入法,配置位置如下:
不过,需要注意的是,有时候突然使用搜狗输入法这个键盘布局会失效(依旧会有特殊字符),不知道为什么,但是重新切换其他输入法之后切换回来就好了,目前非常偶然才会出现。
最近正在使用 Warp 作为常用的终端,有一些细节体验做的真的很不错,无论是命令提示还是补全都很厉害。其中有一个细节我认为对于我来说非常实用,就是长时间执行完成之后的命令提醒。
举个例子🌰:当你在终端进行 make 编译或者打包镜像的时候,往往需要比较长的时间,而你一般不会一直等着它执行完成,而是执行完成之后就切到别的地方去工作(摸鱼)了,然后你无法及时知道命令执行完成了,而只能是不是过来看下执行情况。
而在 warp 执行命令完成之后,就有这样一个通知提醒,我就能时刻关注到命令什么时候执行完成了。
当然,这必须依赖于你使用 warp,但我们常常在 IDE 下方的命令行中执行命令,而非单独使用其他终端,因为切换窗口也比较麻烦。于是乎我就想,能否利用 zsh 来实现这个功能呢?
1 | vim ~/.zshrc |
1 | # 设置要匹配的字符串 |
1 | source ~/.zshrc |
其实原理非常简单,就是通过 add-zsh-hook 的钩子,在执行命令之之前(当前命令执行结束)判断,上一个命令是否在我的匹配列表中,如果这个命令需要提示则使用 osascript
进行提示,其中通过 fc -lnr -1
获取上一个执行的命令。
使用者只需要在 string_to_match 数组中添加你需要进行提醒的命令就可以了,因为通常我们不希望所以执行的命令都有一个提醒,而只需要包含特定功能的命令的进行提醒就可以了。比如:只要包含 go build 字符的编译命令执行完成之后就进行提醒。
其中,通知提示的声音可选项在 /System/Library/Sounds
目录下的文件名称,个人认为 Blow
还可以,其他声音有点小。当然如果你不需要声音提醒的话,去掉 sound name "Blow"
就可以了。
你还可以自定义声音,只需要将声音文件下载,并转换到 aiff
格式,并放到 ~/Library/Sounds
目录下就可以了,在脚本中只需要写文件名,不需要写后缀的 aiff
。
一般下载的格式是 wav,可以到 https://cloudconvert.com/wav-to-aiff 进行转换,当然网上有很多在线工具。
osascript
还支持 alert,但效果一般,需要手动确认,还是通知更加合理,虽然通知有时候会被忽略,但开启声音之后我觉得就很不错了其实对于设计模式,我早早在大学的时候就啃过《Head First 设计模式》《大话设计模式》。当时虽然对于设计模式本身的使用不够,但对于为什么会有设计模式已经设计模式的意义已经深入人心。
当年写的博客:你所学习的设计模式到底有什么用?到底怎么用? (现在看来还有点羞耻,当年大学的我居然是这样的我)
转到当下,其实在实际的工作中都是一种潜移默化的影响,有时候我真的没有意识到这是某种设计模式,而我会这样去编写我的代码。
以 go 语言去实现了各种设计模式,去讲述了各种设计模式,有一说一,从一个初学者的角度它是 OK 的,常用的设计模式均有涉及。对我来说复习了一遍,加深了印象。对于设计模式其中具体的示例代码来说对于小白来说是友好的,这些案例我在网上也常见到,特别看到访问者模式的“圆形”“正方形”的时候。不过从我的角度来说,其实我更希望看到的案例是,直接那 K8S 源码里面的的访问者模式来说明,比如 JSON 格式和 YAML 格式的访问等等。当然作者肯定有自己的考虑,如果这种案例其实需要读者对于这些开源框架有一定的基础,否则直接上来读,这样的案例不够容易上手模仿。
个人觉得其实不应该在设计模式这本书里面放入有关架构设计的内容,比如加入 DDD 在里面,一方面是由于篇幅的限制,很多 DDD 都是一整本书来讲述的,另一方面就感觉有点格格不入,感觉有点在为了凑字数(当然我知道出版书籍是有页数字数要求的)但我觉得可以从设计模式本身去入手,深挖下去会更加贴近书名。
所以本书我更推荐给没有学习过设计模式,但正好在使用 Golang 语言开发的同学。
对于 Go 语言使用设计模式本身,我个人也有些认识。每种语言都有自己的特点和特性,go 没有继承势必在某些设计模式的实现上并非优雅;但由于 go 函数是一等公民,在某些设计模式中也有自己的风格。所以我就举例说明几个我最常用,也在 go 中最常见的设计模式:
err != nil
最后总结下吧,对于已经写了那么久代码的我来说,这次看完书之后,给我一种看山又是山看水又是水的感觉,最好的设计模式是什么:
最近一直在折腾 Golang 的 AES 加密解密,最初的一个小需求只是寻求一个简单直接的加密工具而已,但是找着找着发现里面的坑太深了…
吐槽:对于加密解密,其实我们很多时候并没有特别高的要求(复杂)。一开始,我最直接的一个想法就是:
- 调用一个方法,传递一个秘钥,完成加密;
- 调用一个方法,传递一个秘钥,完成解密,
就可以了,但事实网上纷繁复杂的实现让我头疼。难道,就没有一个让我最省心、简单、最快、实现一个加解密的方法吗?
show me your code 先来看下最终实现情况如何,然后再来说原理和问题
1 | package main |
对,这就是我想要的,输入需要加密的内容和 key,给我出加密后的结果就好
解密也是类似的,这里我就不重复代码了
1 | import CryptoJS from 'crypto-js' |
加密模式有 CBC、ECB、CTR、OCF、CFB,其中 ECB 有安全问题,所以一定不选择,而常用的是 CBC,并且 crypto-js 默认也用了 CBC 所以就无脑选择了 CBC
AES 需要你指定的 密钥长度 必须为 128 位、192 位或256 位,即字符串长度为:16、24 或 32。
对于知道 AES 算法的人来说,其实这很好理解,并且很容易接受,但是对于一个完全不知道你程序或者应用的外部使用者来说,必须写一个长度固定的密码很难理解。
所以对与 key(密钥) 我做了如下处理:
ZeroPadding
方式补全 (小于 16 的补充到 16,大于 16 小于 24 的补充到 24)ZeroPadding 其实实现非常简单,就是将长度不足的末尾补 0 补足就可以
其实很好理解,AES 的加密方式是将原数据拆分成一块一块,每一块单独进行加密,最后组合到一起,而在 ECB 模式下,每块加密使用的 key 都是一样的,所以有安全风险,而为了解决这个问题,和 MD5 类似就是给你的加“盐”,我们知道正常的 hash 容易碰撞被猜到,而加了盐之后,相当于给了一个偏移量,使得结果不可被预测。而 CBC 模式下,第一块加密数据所需的这个盐就是 IV,后面几块加密所需的盐都是通过前面来得到的。
再次从使用者的角度出发,我既然已经提供了一个 key 去加密了,为什么还要提供一个与 key 类似的东西去加密呢?就相当于我需要记住两个密码,很麻烦。并且通常如果作为配置项出现的话,两个 key 肯定是配置在一起的,配置文件里面一般不会为了安全而特别的将两个密码分开存放。
所以我在思考如何创造一个 IV 呢?
首先,肯定这个 IV 需要从 key 出发,因为解密也需要,随机或固定肯定不可能,所以我的第一想法就是 IV 与 key 一致,当然我相信很多人都有和我一样的想法,但是,抱歉,不行。
📢 注意!!!IV 与 key 一致在某些加密模式下相当于你直接将 key 暴露给了用户
所以我参考了老版本 node 的实现,并且改进了一下
1 | The password is used to derive the cipher key and initialization vector (IV). The value must be either a 'latin1' encoded string, a Buffer, a TypedArray, or a DataView. |
老版本 node 里面就直接将 key MD5 了一下作为了 IV,那显然 MD5 是容易被碰撞的。那么好,既然 MD5 不行,那我直接 SHA256 总可以了吧(目前理论安全)。
于是,对于 IV 的生成我就采取了 SHA256 的方式,对 key 做了一次 hash 并且由于 IV 长度固定为 16,所以我又做了一次截取,这下你总不可能还原了吧。
上面我们知道,AES 使用 CBC 模式进行加密的时候,需要将数据拆分成一块一块的,那么问题就是,每块长度为 16,当拆分到最后长度不足的时候又需要补充,也叫 padding。padding 还有不同的方式:Zero padding、ANSI X.923、PKCS7…
这里,类似的,由于 crypto-js 默认使用 PKCS7 所以就用它了。
CryptoJS.enc.Utf8.parse
否则会导致加密不一致的情况CryptoJS.pad.ZeroPadding.pad(cypherKey, 4);
这里的 4 的原因是内部方法计算时 乘以了 4,其实是 block 的大小也就是 16,这也是一个坑,不看源码也不知道的坑。我一开始传递的就是 16 😭 源码位置:https://github.com/brix/crypto-js/blob/develop/src/pad-zeropadding.js代码实现在:
https://github.com/LinkinStars/go-scaffold/blob/main/contrib/cryptor/aes.go
如果需要,你不一定需要直接引用,拷贝对应方法到自己的项目中进行使用就可以了,希望能帮助到你。同时也有支持自定义指定 IV 的方法 AesCBCEncrypt
,但相对应的你需要自己去保证 key 和 iv 的长度正确了。
最后要提醒一下,虽然我使用了 crypto-js
进行加密,但由于是业务需要,如果你在使用的话一定要注意不要将 key 给前端页面进行解密,毕竟 AES 是对称加密。
]]>本文在线运行代码发布于 https://1024code.com/ 在博客内嵌入代码展示非常方便,喜欢的朋友可以尝试看看~
不知不觉,写博客 6 年了,在写博客的这些年里面,其实更多的是在技术上不断地学习,对于写作本身的总结,更多的体现在了文章本身的改变,而我还没有自我总结过一篇针对于技术写作的博客,正好最近有着这样的机会,需要做一个这方面的分享,下面就是分享的内容的讲稿,也正是我对于这些年写作的总结。
无论你现在处于写博客的什么阶段,希望下面的内容能帮助你这些:
输出倒逼输入
作为一个技术人,我们往往都沉浸于技术的海洋中学习。 在学习技术的过程中,我们不断的在输入知识,同时我们也需要关注输出。 输出是也一种学习的过程,无论是写博客、写文章、写书,还是做分享、做演讲,都是一种输出的过程。有时输出是你坚持学习的动力,有时是你巩固和总结学习的过程,有时是你向别人传递知识的方式。”费曼学习法” 其实就是输出的一种形式,它的核心思想是:如果你想要学习一件事,那么你就需要把这件事教给别人。
说到输出,其中一个非常重要的技能就是写作,写作是一种思考的过程,也是一种沟通的方式。 在写作的过程中,我们需要考虑的问题有很多,比如如何取标题、封面配图、如何搭建文章结构等等。
我也是一个技术博主,在写博客的 6 年时间里面, 我也不断地在积累和思考,如何去更好的写作。今天,就围绕着如何搭建文章结构,我将一个开发的视角分享一些我自己的经验和思考,希望能够帮助到你。
当然,下面的分享针对与文章、博客类型,对于书籍或是论文的写作有着更加专业的要求,这里就不做过多的讨论。 由于本人对于后端的知识更加熟悉一些,所以文章中具体的案例会偏向底层一些,但不用过多担心,你只需理解抽象的概念,具体的案例我相信你也就能理解了。
我相信,你既然看到了这里,一定已经解决了第一个问题,为什么要写。我觉得无论写作的内容是什么,只要你踏出第一步,那么我就觉得你比许多人要成功了,因为你已经开始了,而不是一直在想要开始。那么我主要来讨论为什么要写好。
虽然说技术文章,不像写散文、记叙文,需要华丽的词藻或者是一些倒序插叙的写作手法,所以我们常常看到的就是非平铺直叙。但并非说它的行文就可以没有章法。
有人可能会说:技术文章的重点不应该是内容吗?
没错,当然!技术文章的内容往往是吸引人的关键,作为一篇技术文章,别人想要读的前提就是他对你说的这个知识不了解,或者是对于你说的这个问题没有答案。所以我一开始就觉得,只要我的文章中有你想要的答案,就可以了。
但是当我拜读了许多的文章之后才发现,原来不是这样的。厉害技术文章不仅仅是内容,逻辑框架非常的重要。逻辑框架清晰的文章能让读者迅速理清思路获得知识,同时还有以下几个好处:
技术写作的其中一个目的,应该是自己对于一个技术或者知识的当下理解,它很多时候承载了你的笔记这样一个功能。所以,当几个月或者是几年后,当你想要查询当时的解决问题的思路时,一个好的逻辑框架能非常快速的帮回忆起你当时的想法并找到答案。
写作写好的另一个巨大好处,就是能够获得读者的认可,当你的文章容易被别人阅读的时候,别人才会给你更多的意见和建议,这样的建议往往能促使你更好的写作,从而形成一个正向循环。写作 -> 建议 -> 写的更好。
有时候,当你想要写作时,往往只是一个念头或者想法,想要成文,还需要花费很多的时间。你需要围绕你想要写的主题,来构思整个文章的前因后果。而一个好的逻辑框架能够帮助你快速的把想法转化为文字,从而快速的创作。 有了一个好的骨架之后,你只需要不断填充,一篇逻辑清晰的文章就能迅速成型。
我经常会使用的一种创作思路就是,积累+总结。比如,当我对于一个新的认识有自己的看法时,我马上将它在笔记上记录下来,然后再一周内不断地积累相关联的知识、背景、资料等等,最后再周末的时候将它们整合到一起,从而形成一篇文章。因为很多时候当前你对某个知识的理解是片面的,不完整的,在寻找相关资料的过程中能让你对它有更加深刻的理解。
而在最后整合和总结的过程中,就需要用到我们下面要讲的逻辑框架了。
提出问题不仅仅是为了吸引读者
第一个框架,也是我最常用的一个行文思路,就是提出问题。 许多的技术文章并非写的不好,而是没有一个好的逻辑框架,导致读者在阅读的过程中很难理解作者的思路,从而导致文章的阅读完成率很低。
而针对与技术文章,特别是一些困难的技术分析文章,往往就会有一个问题,就是我看着看着,就没有办法理解这个知识了,或者说我看完就看完了,但好像又没学到什么东西。
那么,或许使用问题提示法就能帮助你解决这个问题。
你肯定会说,这么简单?这不就和我现在写的事故分析报告差不多吗?先列举问题,然后分析问题产生的原因,如何解决防止下次出现。
没错,很多时候简单的东西往往容易被人忽略使用。最关键的是它能用在何处?
如果,你对技术比较喜欢,对于一些开源的技术实现,学习源码往往能让你快速理解这个项目,并且能学到很多编码设计上的技巧。所以源码分析类的文章也挺常见,但是许多文章有一个一致的问题,他们的文章大多数就是将代码的跳转路线告诉你了:
入口在哪?从那个方法进来,到那个方法去,这个方法是做什么的…. 等等,当你看完之后,你就会发现,你剩下的几乎没有,回过头你发现你等于没看。或者是当整个项目非常复杂的时候,往往方法和类的扩展很多,导致你看到最后,你都不知道在哪里了。还不如自己运行代码,跟着断点调试来的方便。
那么,文章的问题出在哪里呢?我们要如何使用这个框架改进呢?没错,合理的设计问题,也是在考验你对于整个技术实现的理解。比如你可以这样设计问题:
在阅读源码之前,你可以思考一下,如果是你,你将会如何实现它?
再比如:
为什么这个技术能处理的如此迅速?原因是什么?
然后,当你在叙述整个源码实现的过程的时候,你就可以时时刻刻围绕这个重点去阐述:
比如:这里数据结构的设计对于整个实现是有很大帮助的,如果我设计,可能无法想到用这个结构,以后可以用到
比如:你可以写,不要忘记我们的问题,关键在于如何迅速处理,没错,这里就是问题的关键,由于这里用了….技术…能帮助它更快的处理…
最后,不要忘记,在总结的时候再次回答这个问题,让读者一定产生:哦,原来是这样的感觉。
那么使用这个框架所构建的文章就很明显,让你原本平铺直叙的文章有了一个主心骨,原本无依无靠的零散的知识点,都是为了这个主心骨服务的。
这样,读者在阅读的过程中就会时时刻刻围绕着这个问题去思考。即使当我们有很多很复杂的项目时,我们的问题也有有多个,只要围绕着问题去写,读者就能迅速将思维更上,从而不乱。
并且,就像前面说的,当你很长一段时间回过头来复习的时候,你不需要看整个文章,你只需要口述回答你最前面提出的问题,你就能迅速回忆起来整个知识点。
当然这样的框架并非只能用在源码分析的文章中,还有类似的还有分析框架、分析中间实现等等,都可以尝试用这样的框架去构建,会让文章更加清晰。
即使直接上结论,读者也不会逃跑
在学校的时候,老师总是会告诉我们,写作的时候要有一个开门见山的思路,也就是说,你的文章要有一个明确的主题,而不是一上来就开始写,然后到最后才说,这篇文章是关于什么的。
而在当下,厉害技术太多了,很多人都喊着学不动了,躺平了。并且在短视频,这样短频快的节奏之下,已经很少有人能耐心仔细的看完你的文章了。
那么,反应到技术文章,大多数人追求的是什么?答案,他们只需要一个结论,其他的全部略过,知道结论仿佛就知道了全部,如果在文章中,无法迅速找到结论,那么他们会迅速切换下一个搜索结果。
这也是为什么很多时候在搜索英文报错的时候,很多人看着英文文章无法找到答案,无法解决问题,其实答案就在其中。那么此时,我们就需要用到这个框架。
直接上结论,会不会导致读者就看个结论然后就走了呢?让我们慢慢往下说
很多时候,在实现一个功能或者一个需求的时候,有很多的方案供我们选择。常见的就是两个开源项目的是实现,或者两种技术方案的对比。当我们在做选择的时候,往往就会去详细调研两种不同技术的优势和劣势。又或者是只有一种技术,用与不用也是一种选择。用是因为什么,不用是有什么原因。
那么对于这样的文章,我们就可以尝试运用这样的框架去写。
首先,我们可以上来就直接说明,我认为:应该选择使用这个技术,并且我已经将之实践了。
然后,你就开始从各个维度进行论证,为什么:
比如从使用角度
从代码的实现角度
从维护成本出发
…
在论证中,最能给到用户肯定的就是数据,如果你能拿出你的实际测试数据来说话,会更加有说服力。
最终,你是在什么样的场景下,做出了什么样的选择。并且可以总结很多实践过程中,出现的问题。
这样的写作框架与说明文的写作有着类似的道理,优势在于:
很多人就会说,别人肯定会看了开头就走了。但其实实际并非如此:很多人其实在看文章的时候一开始都是大致扫一眼,去搜索他们想要的信息,而当他们无法找到信息的时候就会走。
而看了这个开头的用户往往反馈就两个:
对于同意你观点的人,无非就两种,一种是已经对这个知识非常熟悉了,这样的人其实并不是你的受众,你这样做非常可以节约别人的时间。还有一种就是他同意你的观点,但是,只是他的一个个人想法,并没有人支持,那么他会继续看下去,找到你这样说的理由。
同样的不同意你的人也是类似的道理。抓住了用户的第一眼,很多时候就能绑住用户。
当然 技术分析、项目分析、优势分析,等等文章都可以使用这样的方式去写作。
让你的文章满足更多受众
对于特别难得知识点或者问题,我们往往需要一些前置的知识来做铺垫,铺垫的好与坏直接就决定了文章的阅读体验。
我也经常在看一些底层技术的文章,对于同一个内容,有的文章阅读很顺,从头到尾,读完就学会了;而有点文章就很难,看完之后你还需要去找别的文章,最终拼凑出你想要的结果。
于是乎,有时,对于特别困难的技术说明类型的文章,我就会尝试采用这样的写作框架来帮助我。
这里的案例我实在是找不到合适所有读者的说辞,只能以具体的一个案例进行说明,我会尽可能用大家都懂的语言来解释。
“IO 多路复用 ”是一个非常复杂的技术难点,如果直接上来就告诉读者,它是怎么做的,如何实现的,然后贴一贴代码,这样很难让人明白。或者说不太友好
那么,如何去做铺垫呢?你可以从以下几个方面着手:
当然,在得出结论之后,我的建议,这样类型的文章,你想要提升,一定要给一些文章站稳脚跟的链接,并且对于一些知识点,你可以引用你之前写过的博客链接,这样不仅能缩短用户的寻找时间,还能帮助你其他博客的引流,也算是一个小技巧吧。
对于困难的知识,很多人其实难以理解的原因往往是由于前因后果没有搞清楚,前置的基础知识没有掌握,你直接告诉它最终的结论,往往会很难让人明白。
这个框架对于读者来说非常的友好,相当于新手和一知半解都能真正在你的文章中 “顺理成章” 。
而这个框架我特别喜欢的还有一个原因是:在写作的过程中,它帮助我去回忆起之前很多的知识点,帮助我在不经意间构建了整个知识网络,让我明白这个知识点在我的整个网络中处于哪一个节点,与之相邻的问题是什么。
这样的框架不仅适用于较难知识或技术的说明,我还发现在一些框架和开源项目的说明中有所体现,他们会直接引用一开始别人基础的实现逻辑,然后说明其中的问题,推导到他们升级的原因,从而说明他们新技术或者新设计的优势,整体下来一气呵成。
在搭建文章结构,还有一些别的注意事项,我想做一些提醒
首先在搭建文章结构之前,你应该确定这个文章想要发布在什么地方。在不同地方发布的时候,内容量是不一样的。我经常能在一些公众号的推文标题看到 “万文长字,分析 xxx 技术” 又或者是 “看这一篇就够了 xxxx” 然后点进去哗啦哗啦非常的长,在手机上观看极为不友好。
所以,针对与不同的平台,你在设计的时候需要考虑内容的不同,当内容过长时进行拆分。比如,针对一个特别系统的设计分析,你可以拆分成专栏的形式进行谋篇布局,将原本的内容拆分成为几个小部分,然后在前后增加衔接的内容,从而形成一个体系。
我在写博客的过程中,也经常会分析一些成体系的内容,将他们拆分后归类到一起,对于读者来说更加友好,每次看的不多,理解没有负担,而又在一起,相互只有有联系。
重要的事情说三遍。我一开始写博客并且到后面有很长一段时间都是在截图,特别是对于一些源码分析。当时的想法是,截图一张图片能放下大段大段的代码,不用占用很多篇幅。直到有读者向我反馈问题:图片看起来有时候不清晰,太小或太大,最大的问题是无法复制粘贴,没有办法进行搜索。所以,从那之后我就基本告别截图了。
如果是对于开源的代码分析,记得加上外链,能方便用户迅速定位到代码段,找到原始方法进行追踪。
如果你给出的是一个教程或者一个可以运行的完整示例,请一定要保证完整性。我无数次会发现从网上拷贝过来的代码无法使用,而一些博客没有留言功能,最后一点点写才发现,哦,原来对方忘记上传了其中某个方法的实现,而这个方法非常关键….
针对于开源的代码,没必要整段整段的拷贝,一方面确实篇幅太长了,占用了文章大量的资源。并且读者不好阅读,其实我们在分析代码的时候往往抓住的是主线,所以其中没有围绕主线的部分你可以手动删除并做注释,缩量之后会让整个结构更加清晰。
当然,说了这么多,对于写作的框架还有很多,这里只是列举出我现在常用的一些,希望对你有所帮助。当然,写作框架固然非常重要,但是它需要你慢慢去总结和摸索。所以对于不同的人来说,我给出以下的建议:
不知不觉,写博客 6 年了,在写博客的这些年里面,其实更多的是在技术上不断地学习,对于写作本身的总结,更多的体现在了文章本身的改变,而我还没有自我总结过一篇针对于技术写作的博客,正好最近有着这样的机会,需要做一个这方面的分享,下面就是分享的内容的讲稿,也正是我对于这些年写作的总结。
无论你现在处于写博客的什么阶段,希望下面的内容能帮助你这些:
当我们有了一个好的想法和内容,并且有了合适的写作框架,构建一篇不错的技术文章就不是什么难事了。但是,如果你想让你的文章有更多的阅读量,那么你就需要花一些时间来构建一个好的标题和封面配图。
对于一篇文章,内容肯定是关键,但如果标题无法吸引读者,很多时候,读者就很难点进来看。
然后,就是对于封面和配图,正所谓 “一图胜千言” ,特别是在技术文章中,很多时候配图可以帮助读者更好的理解文章的内容。特别是在一些难以理解的架构设计或者代码设计上。
话不多说,那么,作为一个技术文章应该如何去拟定你的标题呢?
有一段时间,我非常非常痛恨微信公众号的推送,因为那段时间里,无论是什么样的技术文章,标题都类似下面这种:
我相信,你也看到过不少这样的文章标题,一开始也和我一样容易上当,但是后来我们渐渐就对这些文章标题免疫了,变得对这些标题嗤之以鼻。
不可否认,这些标题能给你的公众号点击带来巨大的流量,没错,是巨大的,因为初期很多人都会上当,而博主为了接广告有时不得已为之,我也是非常能理解的,我一直没有去接广告一方面也是因为这个原因。
所以,这些标题被污染了,所以我们要有所区分,标题党并不是绝对的坏,“标题党”更多的是指题文不符的情况,只要你的文章紧扣题意,夸张未尝不可。
我觉得,技术文章最忌讳的就是标题没有主题,比如:”这个技术真厉害,你一定要学会。” 你又不说是什么技术,我怎么知道我要学会呢?
所以,标题中必须要有这篇文章的主题,如果你是讲述某个技术难点,那么势必要在标题中提到这个技术。如果你是分析某个框架,那么框架的名称也是必须要提到的。
一个没有主题的标题,很快就会被人认为是广告而忽略,并且,这种标题也不利于搜索引擎的收录。
取类似的主题,我建议你可以从 动词 + 关键词 的方式取着手:
提问题是一种很好的标题写法,因为这种标题能够吸引读者的注意力,让读者产生好奇心,想要知道答案。如果,他心里正好有相同的疑惑,那么他就会点进来看你的文章。
提问题的方式,我觉得有两种:
疑问的方式我觉得会更加靠谱。比如:
如果对于你的所有文章来说,标题的取名不能总是一成不变的,有时候需要一些点缀,让它更加生动,更加吸引人。
比如,你可以使用使用有趣的形容词,来吸引读者的兴趣,例如:惊人的、独特的、有趣的、神秘的等。有时运用一些拟人的手法,也可以从另一个方面展示你所写主题的特点,例如:默默无闻的 xx 等等。
对于技术文章来说,配图的质量往往就决定了你文章的难易程度,就像前面所说的,一图胜千言,一张图能说的往往比大段文字想要表现的还要多。
那么对于配图,我们有那些需要注意的问题呢?
过于复杂的图理解起来本身就不容易,看不懂,那么势必你就要配合大量的文字去说明,而一张图所占用的位置是有限的,围绕在旁边的文字也有限,文字过多也会导致用户需要上下移动来阅读,这样就会影响用户的阅读体验。
解决方法也很简单,就是将原有的复杂逻辑进行拆解。比如将一个复杂的框架拆分成多个模块,然后分别进行说明。每个模块配一张小图。几个模块全部说明完成之后,再将几个小图组合成为一个最终的全局图,让读者认识能更加清晰。
颜色的选择真的很重要,我见过很多文章的配图都是黑白的,阅读起来就没有主次,或者说没有层次。这样导致的问题就是用户在第一眼看到你这个图片的时候无法知道你说的主要功能或者主要逻辑是放在哪里的,因为读者本身其实对于这个知识是不知道的,才会来看,对于他的理解成本就会提高。
而清晰的颜色对比度区分,会让人有眼前一亮的感觉。
不过对于颜色的选择,每个人的审美不同,我觉得最好的方式就是多看看别人的文章,看看别人是怎么选择颜色的,然后自己也可以尝试一下。这样不仅能快速上手,也不至于让你的文章看起来太过于单调。
除了,针对与颜色,我们还可以通过加粗、变大等方式来突出重点。比如,我们可以将重点的部分加粗,或者将重点的部分变大,这样就能让读者更加清晰的看到重点。
对于一些有数据流转或者是行为导向的文章,当你在配图的时候可以使用箭头来引导。特备是对于复杂的架构来说,数据的流向往往就能串联整个架构的逻辑,这样就能让读者更加清晰的理解整个架构的逻辑。
对于技术文章来说,很多人会说,封面图不是可有可无的吗?而且制作图片对于一个非专业的人来说也是一件比较麻烦的事情。我们需要去找素材,去切图,去拼图等等工作。一开始,作图是一件非常复杂的工作,尤其是对于像我一样 PS 不熟练的同学。但是,随着时间的推移,我发现,其实作图并不是那么难,现在网上已经有各种各种的工具能帮助一个小白同学快速的制作一张封面图片。 只需要,选择一个你想要的模板,修改一下文字,然后就可以导出图片了。
所以对于封面图来说,我的要求是:
那么对于封面图,我这边有几点需要提示的地方,他可以帮助你快速找到你需要的元素:
有了这两个要素之后,你就可以快速的制作出一张封面图了。公式为:合适的模板 + 标题 + 关键词 + 技术icon = 封面图
文章的标题、配图、封面图,往往就是你的点金之笔,在好的内容的基础之上,再加上好的标题、配图、封面图,你的文章就能够更加吸引读者的眼球,从而提高你的文章的阅读量。
并且,这些都是非常容易实现的,只需要你花一点时间去学习一下,就能够快速的实现。
当我一开始写博客的时候,也是非常的无脑,就是想到什么就写什么,图片大多数都是随便截图一下,就好了。慢慢的,当我看到了很多其他高手的博客,我就发现,从 1 到 2 把你的文章变得更加精致其实也很简单。
]]>下面是我最近经常用的一些免费 ICON 来源
各种类型样式的 ICON 都有,免费的很多,很实用
GitHub 上开源的一个免费 ICON 仓库,4000+ 小巧又精致
https://github.com/tabler/tabler-icons
近些时候才发现的一个大厂 ICON 库,也非常使用,并且在线的功能很方便使用
https://iconpark.oceanengine.com/official
]]>这个是当前博客也在使用的 ICON,要注意的是,不是所有都是免费的,但免费的也足够精致
最近我常用的觉得好看和实用的字体
从命名上我就很喜欢这个字体,给我一种很古风很诗情画意的感觉,然后看到字体我就发现它是真的“隽永”,很能体系中文那种美感,更加纤细的柔,而不是方正结尾的硬直笔锋。
下载地址:https://github.com/lxgw/LxgwWenKai/releases
这个字体看起来和我们正常使用的差不多,但是实际用上之后发现它更加直,在展示上更加稳重一点,是我经常会使用的一种字体
下载地址:https://web.vip.miui.com/page/info/mio/mio/detail?postId=33935854&app_version=dev.20051&ref=MIUI13
]]>前几个月就看到 Google 有了 Golang 这个规范,但是一直没有时间去看。最近仔细看了一下,其中有几个点,之前搬砖的时候还没有注意到,所以记录一下。
本文仅针对于我个人针对这个规范的小结,建议有时间的同学去看看原文,毕竟每个人查缺补漏的地方不一样。
包名称Package names 不应该有下划线,例如,包
tabwriter
不应该命名为tabWriter
、TabWriter
或tab_writer
。 Link
我们有时候会不得不出现包名需要两个单词来描述的情况。在没有了解到这个规则之前,确实我很多命名的时候还是会选择使用下划线进行分隔来命名包名。
原因有两个,一个是之前 C 的影响,一个是由于全小写难以辨认,故会使用下划线。所以,这个规则以后还是要多注意。
函数和方法名称不应使用
Get
或get
前缀,除非底层概念使用单词“get”(例如 HTTP GET)。此时,更应该直接以名词开头的名称,例如使用Counts
而不是GetCounts
。如果该函数涉及执行复杂的计算或执行远程调用,则可以使用
Compute
或Fetch
等不同的词代替Get
,以使读者清楚函数调用可能需要时间,并有可能会阻塞或失败。
我之前就经常会写的类似方法名称就是:GetUserByID
这样的,这个规则以后也要注意。
错误字符串不应大写(除非以导出名称、专有名词或首字母缩写词开头)并且不应以标点符号结尾。
这个之前经常会被 lint 查出来,多数情况都是由于用 copilot 生成代码的时候,自动加了标点符号。
Go 的格式函数(
fmt.Printf
等)有一个%q
动词,它在双引号内打印字符串。
1 | // Good: |
这个之前没有注意到,虽然很多时候不会去手写单引号或者双引号,但是会经常去书写 [%s]
。
原因是,有时候打印的内容可能是空字符串或者空格,如果不加符号很难看出来。下次可以使用 %q
来代替。
接收者是
map
,function
或channel
,使用值类型,而不是指针。
1 | // Good: |
这个之前没有仔细去注意,可能写的非常随意,针对与值和指针接受者的选择往往就只是关注在结构体是否需要修改上。
不要重复命名的规则体现在下面:
widget.NewWidget
widget.New
func (p *Project) ProjectName() string
func (p *Project) Name() string
func OverrideFirstWithSecond(dest, source *Config) error
func Override(dest, source *Config) error
func TransformYAMLToJSON(input *Config) *jsonconfig.Config
func Transform(input *Config) *jsonconfig.Config
由于 Golang 与 Java 不同,Golang 不支持方法重载,所以会出现类似方法效果不同,但参数不一致的情况。 此时为了区分,才会使用重复命名的方式,将参数或者必要的信息加入到方法名称中。如:
1 | func (c *Config) WriteText(s string) |
1 | // Good: |
这也是一开始一个习惯问题,我常常还会使用 coords := &Point{}
来声明,然后进行反序列化。可能是对于空指针的忌惮吧
使用 https://pkg.go.dev/github.com/golang/glog#Fatal 而不是 panic
Link
之前针对与一些启动时的异常,如读取配置文件失败,导致程序无法正常启动的时候往往会使用 panic 来处理。 但是这里给出了一个更好的方式,glog 是 google 开源的一个日志库,可以使用 Fatal 来处理异常。
之前在编写的命令行工具的时候往往需要一些子命令。而官方只有 flag 包,只能使用 flag 参数来实现。
而我要的不是 ./cmd -flag1 -flag2
,而是 ./cmd subcommand
。所以当需要使用子命令的时候,会直接毫不犹豫的使用 cobra 来实现,但是有时候只为了一个子命令引入确实有点大材小用。
subcommands 就是一个不错的替代,更加轻量,能帮助我们快速实现子命令,以后小东西可以考虑使用这个。
总的来说,有了 lint 和 gofmt/goimports,以及一些 IDE 的帮助,Golang 代码风格还是比较统一的。主要的问题还是在于命名和方法的使用上。
]]>前置知识
那么问题来了,这两者会导致我们的分布式锁的原子性有影响吗?
我们知道当我们使用 redis 作为分布式锁的时候,通常会使用 SET key value EX 10 NX
命令来加锁,获得锁的客户端才能成功 SET 这个 key,那么问题来了,这条命令在多线程的情况下是一个原子操作吗?
其实答案是显而易见的,因为 redis 的设计者肯定考虑到了向前兼容的问题,并且也不会让这样的特性消失,所以在问这个问题以前,我虽然不能肯定,但是还是能自信的回答,但没有足够的底气。 今天的目标就是找到真正的原因。
上锁,没啥多说的直接 SET key value EX 10 NX
就可以了
解锁,有两种:
DEL
就可以了EVAL
执行只要上锁和解锁操作都能保证,就能解决问题。
那么问题的关键就是命令的执行过程,Redis 执行命令也是需要有过程的,客户端一个命令过来,不会直接就啪的执行了,而是有很多前置条件和步骤。
大致可分为:
其中,命令读取和解析显然是不会影响数据的,所以当然多线程执行也没有问题。最关键的步骤也就是执行了。
先来看看 IO 多路复用会有影响吗?
代码来自: https://github.com/redis/redis/blob/074e28a46eb2646ab33002731fac6b4fc223b0bb/src/ae_epoll.c#L109
1 | static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) { |
没事,不要担心看不懂,只要抓住最关键的地方 epoll_wait
这个我们很熟悉对吧,我们就可以看到这里一次循环拿出了一组 events,这些事件都是一股脑儿过来的。
其实 IO 多路复用本身没有问题,无论是 select 还是 epoll 只是将所有的 socket 的 fd 做了一个集合而已,而告诉你那些 fd 出现了事件,让你具体去处理。如果你不愿意多线程处理这些读写事件,那么 IO 多路复用是不会逼你的。
多线程倒是真的有可能会出问题。那如果我们自己去考虑实现的话,当一个命令被多线程去同时执行,那势必会有竞争,所以我们为了尽可能利用多线程去加速,也只能加速,命令接收/解析/返回执行结果的部分。故,其实 Redis 的设计者也只是将多线程运用到了执行命令的前后。
代码在: https://github.com/redis/redis/blob/4ba47d2d2163ea77aacc9f719db91af2d7298905/src/networking.c#L2465
1 | int processInputBuffer(client *c) { |
同样的,也不用慌,抓住重点的部分
CLIENT_PENDING_COMMAND
状态的时候是直接 break 的,后面就根本不处理,而这个状态就是表示客户端当前正在等待执行的命令。在这个状态下,客户端不能发送其他命令,直到当前命令的执行结果返回。processCommandAndResetClient
方法总结一下,IO 多路复用本身其实没有影响,而 Redis 真正执行命令的前后利用多线程来加速,加速命令的读取和解析,加速将执行结果返回客户端。所以,本质上 “IO多路复用和多线程会影响Redis分布式锁吗?” 而这个问题与分布式锁其实没有必然联系,分布式锁本质其实也是执行一条命令。故,其实面试官问这个问题的原因更多的是关心你对 IO 多路复用和多线程在 Redis 实践的理解。
]]>最是一年春好处,绝胜烟柳满皇都。
不知不觉又一年过去了,每年 3 月都是博客装修的季节,这次也不例外,这次的装修内容如下:
之前使用的还是老版本的 hexo 由于这次想要升级主题,而主题需要 hexo 5.0 以上版本,所以就顺便升级了 hexo
1 | # 确认当前版本 |
需要注意的是 使用的 node 版本,以及其中一些命令需要以 sudo 权限执行
版本对应
更新主题版本至 4.7.0 https://github.com/jerryc127/hexo-theme-butterfly
有时一些文章还在撰写过程中,或者一些文章并非重点,无需占用首页版面资源,故想要隐藏
修改文件 themes/butterfly/layout/includes/mixins/post-ui.pug
1 | mixin postUI(posts) |
其中添加 if article.hide !== true
这一行,并将其中下方所有代码缩进(一定注意缩进不要错了,拉一条竖线看看,不要把最下方不需要缩进的地方缩进了)
修改文件 themes/butterfly/layout/includes/widget/card_recent_post.pug
1 | - let no_cover = article.cover === false || !theme.cover.aside_enable ? 'no-cover' : '' |
也是同样添加 if article.hide !== true
这一行,并且进行代码缩进
对想要隐藏文章的 front-matter 中添加 hide: true
1 |
|
Twikoo 评论系统 https://github.com/imaegoo/twikoo 我记得在某个版本之后就支持私有化部署了,但由于我已经付费了,所以就没再去部署了。这次服务快到期了,正好就迁移一下到私有化部署的 Twikoo 上
我这边使用 docker 部署,飞快,一键部署,非常方便。迁移一下评论数据,就可以了。
跟着文档操作很容易:https://twikoo.js.org/quick-start.html#%E7%A7%81%E6%9C%89%E9%83%A8%E7%BD%B2
之前一直想要做一个每日打卡的功能,但一直没有找到合适的方案,没有轮子就造轮子呗
在首页上方添加了 每日打卡 的链接
开源在了:https://github.com/LinkinStars/daily-cards
之前没有特别关注 RSS 订阅,只是开启了这个功能,但是实际上不太好用,内容被截断,展示格式也有问题
这次修改主要是关注在两个问题上, 一个是让文章的内容能够完整展示,另一方面想要在 RSS 的内容最上方添加跳转链接,来提示用户跳转到原网页查看
RSS 插件我这里使用的是 https://github.com/hexojs/hexo-generator-feed
修改 hexo 的 _config.yml
1 | # 排除文件 |
这里我将 content
设定为了 true,并且添加了模板
./source/custom-rss-tmpl.xml
<content type="html">
部分为下方样式即可1 | <content type="html"> |
]]>对于搜索的支持篇幅比较大,我就单独写了一篇,链接在下方
在文字中利用与 ICON 类似的颜色来实现:区分和强调
浅色小字做分类
大字凸显做展示
上面图片下面文字+ICON 做卡片
这种卡片是全色背景,用一个渐渐消失的图片做展示
适用于需要展示一些更多介绍文字的情况。
这种背景模糊的弹窗有点意思,有一种减少束缚的感觉,让选项自然的变大更容易进行选择。
]]>插画 + 标题 + 描述 + 按钮 布局成提示框