<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>CodeSky 代码之空</title>
    <link>https://www.codesky.me</link>
    <description>随手记录自己的学习过程</description>
    <managingEditor> (敖天羽)</managingEditor>
    <pubDate>Fri, 13 Mar 2026 01:41:34 +0800</pubDate>
    <item>
      <title>体验豆包手机的 24 小时</title>
      <link>https://www.codesky.me/archives/doubao-phone.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;阅读前提示：豆包手机本身的表现属于意料之中，也没有网上吹得那么神，也没有黑稿说的这么差。本身豆包手机的定位比起「手机」，更�...</description>
      <content:encoded><![CDATA[<blockquote>
<p>阅读前提示：豆包手机本身的表现属于意料之中，也没有网上吹得那么神，也没有黑稿说的这么差。本身豆包手机的定位比起「手机」，更像是「玩具」。大家实际购买前可供参考。</p>

<p>当然我已经自闭一整天：3000 块钱买啥不好了。</p>
</blockquote>

<h2 id="硬件">硬件</h2>

<h3 id="配置">配置</h3>

<!--more-->

<p>一个手机，首先它得干手机该做的事情，先来看看官方配置：</p>

<table>
<thead>
<tr>
<th>项目</th>
<th>配置</th>
</tr>
</thead>

<tbody>
<tr>
<td>处理器</td>
<td>高通骁龙 8 至尊版（Snapdragon 8 Elite）​</td>
</tr>

<tr>
<td>内存与存储</td>
<td>16GB 内存 + 512GB 存储​</td>
</tr>

<tr>
<td>屏幕</td>
<td>6.78 英寸 LTPO OLED 直屏，分辨率 2800×1264，1–120Hz 自适应高刷​</td>
</tr>

<tr>
<td>后置主摄</td>
<td>5000 万像素，1/1.3 英寸传感器，等效约 23mm，F1.68 光圈，支持 OIS 光学防抖​</td>
</tr>

<tr>
<td>后置超广角</td>
<td>5000 万像素，1/2.88 英寸传感器，等效约 13mm，F2.0 光圈，可微距对焦​</td>
</tr>

<tr>
<td>后置长焦</td>
<td>5000 万像素，1/2.75 英寸传感器，等效约 60mm，约 2.6 倍光学变焦，F2.0 光圈，支持 OIS​</td>
</tr>

<tr>
<td>前置相机</td>
<td>5000 万像素，1/2.75 英寸传感器，F2.0 光圈，支持自动对焦​</td>
</tr>

<tr>
<td>电池</td>
<td>6000mAh 容量​</td>
</tr>

<tr>
<td>充电</td>
<td>90W 有线快充，15W 无线充电，5W 反向充电​</td>
</tr>

<tr>
<td>网络与接口</td>
<td>USB‑C，支持 USB 3.2 Gen1 速率​</td>
</tr>

<tr>
<td>解锁方式</td>
<td>超声波屏下指纹​</td>
</tr>

<tr>
<td>其他功能</td>
<td>NFC、红外遥控、激光对焦与 Flicker 传感器、5 麦克风、双扬声器、侧边 AI 实体键​</td>
</tr>

<tr>
<td>机身尺寸</td>
<td>约 163.12×77.04×8.52 mm​</td>
</tr>

<tr>
<td>重量</td>
<td>约 212–213 g​</td>
</tr>
</tbody>
</table>
<p>只考虑他是一款手机的话，定价 3499 其实是符合供料的主流（偏便宜）的定价的。当然，关于中兴的品控以及「技术预览版」的售后未知，所以不好实际做出评价，但总的来说价格并不算离谱。</p>

<p>我对于手机配置也不太关注，不过如果光看配置或者定价的话，感觉更实惠的选择可能还是红米。</p>

<h3 id="开箱">开箱</h3>

<p>外观摄像：图 1 右侧为小米 14</p>

<p><img src="https://img.codesky.me/blog_static/2025/12/图片转换器-20251213-1765614144740_KIMbnnwF.png" alt="图片转换器-20251213-1765614144740.png" /></p>

<p>然后会送一个非常五毛的手机壳，考虑到现在在淘宝上几乎买不到豆包的手机壳，更没有来图定制，所以这东西可能是你的唯一选择。</p>

<p>手机出厂同样也会带一个贴膜，已经贴在手机屏幕上了，手感也一般，聊胜于无。</p>

<p>而且考虑到手机的出货量，感觉实际买到手的同学们不要抱有太大希望，想开一点，至少出门懂得人一看就知道你用的是豆包手机是吧。</p>

<p>剩下的卡针，快充头，数据线是常规配件了（毕竟不是苹果，还是会给齐的）。</p>

<p>本来我录了一个开箱视频，后来发现自己录的像个傻逼，就只给大家放这两张照片看看吧。（实在想看的话可能发到粉丝群图一乐，看看本文实际发出后的效果吧）。</p>

<h2 id="系统">系统</h2>

<p>系统是 Obric UI 1.1.0.0，对应安卓 15，系统应用包括下图，所有软件均可卸载，整个系统相当清爽，没有广告，让我有种原生安卓的爽快感（回忆只有当时装了 MIUI 国际版才是这个画风，悼念天国的 MIUI）：</p>

<p><img src="https://img.codesky.me/blog_static/2025/12/迅捷图片转换器-20251213-1765613838070_AvbgTjrd.png" alt="迅捷图片转换器-20251213-1765613838070.png" />
拍摄自动模式整体偏暗，我的小米 14 和苹果都会更亮一些（当然就不跟苹果比了，这不是一个价位的）</p>

<p><img src="https://img.codesky.me/blog_static/2025/12/迅捷图片转换器-20251213-1765613571776(1)_73mGGwnV.png" alt="迅捷图片转换器-20251213-1765613571776(1).png" /></p>

<p>我拙劣的拍摄技巧只允许我用自动模式简单对比一下，大家就当图一乐吧，但从实际效果上来看，豆包的成像效果似乎并不如两年前的 14（首发时4299，现在小米商城 2899）。</p>

<p>其他系统软件能集成 AI 的都会集成 AI 功能，包括便签，录音。闹钟支持法定工作日。符合中国人体质。</p>

<h2 id="ai">AI</h2>

<p>还是让我们进入正片环节，也就是豆包 AI 的实际体验。</p>

<p>在开始之前，首先先需要向大家强调，豆包使用的技术路线并不是目前其他手机厂商集成的那种智能 AI，也不是大家平时使用按键精灵或者游戏脚本那种模拟点击。</p>

<p>这一点后续再说，先来说说我的测试 Case List：</p>

<ul>
<li>手机使用

<ul>
<li>下载 App</li>
<li>在 Play 商城下载 1Password</li>
</ul></li>
<li>生活服务

<ul>
<li>在饿了么挑选夜宵</li>
<li>在大众点评挑年夜饭</li>
<li>上京东搜充电宝并下单</li>
<li>高德/腾讯地图导航</li>
<li>货比三家</li>
<li>滴滴打车</li>
</ul></li>
<li>工作/提效场景

<ul>
<li>飞书私聊消息的回复</li>
<li>自动发微博/回复微博</li>
</ul></li>
<li>娱乐

<ul>
<li>B 站完成 Lv6 硬核会员考试</li>
<li>微博总结近期八卦</li>
<li>红果短剧检索我想要的 Topic</li>
<li>搜索盗版资源</li>
</ul></li>
<li>游戏

<ul>
<li>数独</li>
<li>纸牌</li>
<li>华容道</li>
<li>花牌</li>
<li>雀魂（日麻）</li>
<li>FGO（回合制二游）</li>
<li>游戏翻译与自动执行</li>
<li>查找游戏攻略</li>
</ul></li>
</ul>

<h3 id="手机使用">手机使用</h3>

<h4 id="官方商店下载-app">官方商店下载 App</h4>

<p>它的第一个任务是帮我装上各种我平时需要用的 App，包括微信、QQ、淘宝、京东、饿了么、美团、大众点评、Bilibili，以及 Google Play 和我经常玩以及即将要测试的游戏）。</p>

<p>由于语音一口气念很长的话可能我自己都搞不清要哪些，所以我分了几次输入，前几次安装都比较顺畅，但当我说到「红果短视频」的时候，由于 App 名字其实是红果免费断句，所以它让我确认一下，确认完后虽然能安装，但在后续查找其他 App 的时候，竟然没有把「频」字删掉，导致后续检索出了意外，不过还是有惊无险的下载成功了。</p>

<p>在 Bilibili App 的安装中，第一个候选应用其实是概念版，但它准确的下载到了粉版。</p>

<p>这一个 Case 有录屏：</p>

<iframe src="//player.bilibili.com/player.html?isOutside=true&aid=115712123210268&bvid=BV1qGmdBBELu&cid=34716715520&p=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"></iframe>

<h4 id="google-play">Google Play</h4>

<p>Google Play 谷歌套件的安装不是很理想，所以转人工了。</p>

<p>转完人工后我要求后续默认使用 Google Play 下载，他虽然纳入了长期记忆，但当我要求它下载 One Password 时仍然唤起了中兴的应用市场，这一点在浏览器设置也是一样的，我的默认浏览器已经是 Chrome 了，但是唤起浏览器解决问题时仍然用的是系统自带的浏览器。</p>

<p>只有当我非常明确的告诉他「使用 Play 商店下载 One Password」才能正确执行。考虑到这么说话太费劲，而且各种我要在 Play 商店下载的东西名字花里胡哨的，所以也转了人工。</p>

<h3 id="生活服务">生活服务</h3>

<p>生活服务是很重要的一环，毕竟手机虽然说和我们的生活密不可分，但实际上也就那几种功能，由于实际上「饿了么」「大众点评」「高德」都是被 ban 的名单，都会提示不能使用 AI 功能，所以测试未成功。</p>

<h4 id="外卖">外卖</h4>

<p>实际上在公司时同事还尝试了不说 App 直接说点外卖，它会唤起抖音的团购频道，然后划了半天告诉你没找到合适的外卖，最终我再让他去京东找外卖，我圈定了价格、餐饮品种，他倒是确实给我找了几个，而且其实我想着是随便给我选一个拉倒，它非给我搞了五个候选项让我选，但实际上他又不会把店家看完，只会看头部的店家推荐版块，而且效率其实并不高。（主要还是看了五个实在有点花时间，虽然它是后台执行的，但是我们前台一直盯着看呢）。</p>

<h4 id="本地生活">本地生活</h4>

<p>抖音本身也是有本地生活的，把大众点评的关键词挪到抖音去搜索理论可行，实际上没尝试，原因是抖音的本地生活做的实在一般，没啥店，更没什么评论，无法起到参考作用。</p>

<h4 id="购物">购物</h4>

<p>货比三家的购物场景也因为其实只有京东还能用 AI，只能比比抖音商城和京东了，但其实我不用抖音商城（小红书应该也能用，但感觉都略微小众了一点），所以我让他给我挑了个充电宝，体验比较流畅且精准（这一点本来没觉得精准是个可夸的点，直到我让超级小爱做了相同的事情，超级小爱虽然可以操作淘宝，但关键词都没搞对，还因为免密支付差点就给我一键下单了，吓得我赶紧退出）。</p>

<h4 id="导航">导航</h4>

<p>高德地图可以由腾讯地图和百度地图平替，这一点其实是我比较关注的的场景，因为我妈不太会用导航 App……现在用豆包用的挺 6 了。豆包其实也能推地图，其实是唤起了你电脑中安装的地图，但到实际的导航还差了最后一公里，步骤越多对老年人越不友好。</p>

<p>所以我尝试让豆包手机从我家导到我爸妈家，并注明是步行导航，它确实能够启动到步行导航。但最初我只说要查查怎么去 XX 医院的时候，它甚至都没有唤起一点 App，给我推的是文字路线，我说我还是不清楚怎么走，它给我标上了对应路标，还是没有唤起导航。步行导航是我在非常精准的说明后才能流畅执行的。——但是其实一般用户并不一定能说的这么明白。而它觉得自己任务完成后不一定能关联上上一步的上下文（有概率），因此现阶段也不是很适合给中老年人使用。</p>

<h4 id="打车">打车</h4>

<p>我日常两点一线的生活中最重要的一点就是打车，由于美团和高德都没法用，所以只能选择滴滴，滴滴打车时由于语音识别错了我的目的地，差点给我下了 200 多块钱的单（尽管应该有手动确认，但还是把我吓得不轻赶紧退了）。而且几次识别都在同一个字里翻车，都把我整自闭了。</p>

<p>当然，最终我还是成功的打上了车，还让他算了一下买优惠券划不划算，划算就帮我顺便买了。</p>

<p>由于有一定翻车率，所以也说不上提不提效，至少我觉得如果是家中老人，看到最初的 case 一定也会吓得不轻。</p>

<h3 id="工作提效">工作提效</h3>

<h4 id="飞书回消息">飞书回消息</h4>

<p>飞书回消息是工作提效中我个人比较想要的一个能力，诉求来源是实际上很多内容在我们写的使用手册上都有，但每次我都得翻出来再把文档发出去，我是纯懒狗，这种事情多了就烦了，而你真的搞什么 AI Bot，触达率绝对没有来找你的效果好，大家肯定还是来找你。我=AI 才是最好的解法。</p>

<p>而同事也跃跃欲试想整活，于是我给豆包安排了一个托管任务：如果谁谁谁发来消息，那就根据他的消息回复他。</p>

<p>但没想到豆包不支持这种长时间的任务，一段时间发现没有新消息，任务就中断了。有消息了虽然能通过对话确实的让豆包回复，但基本上都是无效废话。</p>

<p>这里需要说明的是，豆包目前支持的两种模式，一种是「对话触发」一种是「定时任务」。</p>

<p>定时任务最低支持天维度，最高支持年维度的触发，触发条件可以是时间或者地点。也就是说理论上你可以通过配置定时任务完成飞书巡检，自动帮你回消息，但由于任务是「天维度」的，所以你要切割成小时级别，那就得多配几个任务，大半夜的你也不知道回了谁，相当没有安全感了。</p>

<p>对话触发可以一口气处理你历史的堆积任务，但是和预期的「我=AI」就差远了。</p>

<h4 id="发微博-回复微博">发微博 / 回复微博</h4>

<p>在网上有那种小红书自动回复、中兴总裁用豆包自动回复，本质上回的都是存量内容，此时豆包是可以运行很长时间的，只要有活干，他能一直干。</p>

<p>比如我之前让豆包帮我回复一下之前的内容，结果他特别勤快一条条回复之前的评论回到了几个月前的，而且语气都特别弱智，我就把任务给停了，再让他写了一篇免责申明，语气就……挺 AI 的。</p>

<p>感觉不如微博的评论罗伯特（建国之后不许成精）。</p>

<p><img src="https://img.codesky.me/blog_static/2025/12/Pasted_image_20251213203728_iv3sgqui.png" alt="Pasted image 20251213203728.png" /></p>

<p><img src="https://img.codesky.me/blog_static/2025/12/Pasted_image_20251213203653_PRMCr5ES.png" alt="Pasted image 20251213203653.png" /></p>

<h3 id="娱乐">娱乐</h3>

<h4 id="b-站-lv6-答题">B 站 LV6 答题</h4>

<p>最优秀的功能来了！B 站 LV6 硬核会员答题成功拿下 89 分。</p>

<p><img src="https://img.codesky.me/blog_static/2025/12/70979163gy1i88gvg09qhj22c0340npd_vDvXQMf5.jpg" alt="70979163gy1i88gvg09qhj22c0340npd.jpg" /></p>

<p>视频：
<iframe src="//player.bilibili.com/player.html?isOutside=true&aid=115711787664391&bvid=BV1pCmdB7EA5&cid=34714947325&p=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"></iframe></p>

<p>当然最初他是不愿意答题的，他觉得这是作弊，你得自己答！多试几次之后才顺畅的帮我答了题。</p>

<h4 id="微博总结近期八卦">微博总结近期八卦</h4>

<p>成功完成了点开微博热搜，然后按照我要的分类给我对应的结果，不过说实话和我自己看区别不大……</p>

<h4 id="红果短剧关键词检索">红果短剧关键词检索</h4>

<p>我每天都会在红果里搜索一遍「双女主」，因此我就问豆包能不能给我在红果找点百合、双女主的戏，结果他刚开始使用了百合和双女主进行搜索，搜索后可能画面内没什么有效信息，又退回首页妄图在分类里找到，然后给我总结出了几篇，有两篇叫百合的可他不是百合啊！他还是不够了解我.jpg。</p>

<h4 id="搜索盗版资源">搜索盗版资源</h4>

<p>其实搜索正常的信息豆包是完全可以胜任的，比如我也试着让他总结一下 Hacker News 和 Product Hunt 近期的 AI 话题。</p>

<p>但众所周知我也不是什么正经人，怎么会有正经资源需要搜索，那都是 Gemini 和 Perplexity 应该做的事情，这就业轮得到豆包吗？</p>

<p>所以我让豆包去搜一下有没有玉观音的夸克或者百度网盘资源，然后他给我教育了一顿，这是侵犯知识产权啊，你可以在正版平台看啊。</p>

<p>好滴，就此作罢。</p>

<h3 id="游戏">游戏</h3>

<p>重头戏重头戏！因为实际上我的工作用机一直是 iPhone，小米本质上是一个游戏机，因为安卓更好做到一些自动化的事情，可以让我在打游戏时偷很多懒，比如模拟点击、FGA。</p>

<p>所以作为一个安卓手机，要想抢我小米的饭碗，就得在打游戏上超越它。</p>

<p>先说好，以下游戏都是我之前玩过的，精选 App，且不涉及任何 PVP 成份。只是想试试效果或者偷懒。</p>

<p>数独、纸牌、雀魂、FGO 部分片段都可以在下面的视频中看到，全流程实在太长了，而且都是失败告终，所以录不下去一点，家里也没什么专业设备，都是自己举着手机录的，太长费劲。</p>

<p>（三倍速版）</p>

<iframe src="//player.bilibili.com/player.html?isOutside=true&aid=115711787664391&bvid=BV1pCmdB7EA5&cid=34714947325&p=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"></iframe>

<h4 id="数独-microsoft-sudoku">数独：Microsoft Sudoku</h4>

<p>我尝试让他在 Master、Grandmaster 下解题，执行效率感人，一分钟可能能解一个格子。在解完一行之后，它说：</p>

<p><img src="https://img.codesky.me/blog_static/2025/12/Pasted_image_20251213210346_rcOh6pkZ.png" alt="Pasted image 20251213210346.png" /></p>

<p>我：？</p>

<h4 id="纸牌-solitaire">纸牌：Solitaire</h4>

<p>这 App 纸牌品种还挺多的，也是 Microsoft 出品。我让他玩的是最经典的纸牌接龙。无法正确完成，在一定程度后会鬼打墙的重复进行不正确的操作，即使屏幕有提示告诉他正确的做法。</p>

<p>但他最聪明的点在于，虽然他做不出题，但它会按提示按钮（直接掀桌了）。</p>

<p>我：？</p>

<h4 id="华容道-numpuz">华容道：Numpuz</h4>

<p>实际上玩的是类似华容道的数字拼图（至今不知道该叫啥），和纸牌一样同样会鬼打墙，在尝试一定次数后直接点了重置按钮（是因为游戏没有提示按钮吧啊喂），还贴心的问我要不要重置。</p>

<p>我：？</p>

<h4 id="花牌-hanafuda">花牌：Hanafuda</h4>

<p>给不太了解花牌的小伙伴们先简单科普一下花牌：</p>

<blockquote>
<p>花牌（Hanafuda）是两人对战的卡牌游戏，由 48 张绘有 12 个月花卉的牌组成，每花 4 张。游戏分 12 回合，每回合先发牌、翻出场地牌，玩家轮流出牌匹配花色：匹配成功则收走牌组，无匹配则牌留场。结束后按收集的花牌计分，不同花牌组合有额外加分，先达 30 分（或约定分数）者胜，策略在于记牌和预判对手出牌。</p>
</blockquote>

<p>本质上是一个会了之后一半看脸的游戏，我不会玩扑克牌，所以不好说谁更看脸，但实际上花牌规则就是碰相同月份，凑役种，做大牌。其中有两种牌型只需要两张特定的牌就能胡：「花见酒」「月见酒」、还有三张就能胡的牌种：「三光」、「猪鹿蝶」、「青短」和「赤短」。</p>

<p><img src="https://img.codesky.me/blog_static/2025/12/Screenshot_2025-12-13-21-14-14-186_com.crossfield.hanafuda_DQY2f1qj.jpg" alt="Screenshot_2025-12-13-21-14-14-186_com.crossfield.hanafuda.jpg" /></p>

<p>我简单和豆包描述了胡牌策略后，发现他搞不懂怎么出牌，因此我再告诉他「拖拽来出牌」，但是他又开始分不清中间场地和手牌。而且在这个 App 中你不需要记住月份对应的花色，和场上能碰的手牌会高亮，我告诉他优先选择高亮的，但实际操作中他仍然会白给对手送牌，打不赢一点。</p>

<p>可以说豆包在这种较为冷门的益智类游戏中堪称惨败。</p>

<h4 id="雀魂-日麻">雀魂（日麻）</h4>

<p>日麻这个品类相比其他麻将默认吃碰杠来说也是比较复杂的，主要规则区别在于：</p>

<ul>
<li><strong>中国麻将</strong>：核心是凑成 4 组刻子 / 顺子 + 1 对将牌，多数规则无 “役” 的强制要求，可直接胡牌（如平胡），部分变体有番数要求但限制宽松。</li>
<li><strong>日麻</strong>：必须满足 “役” 才能胡牌（如立直、断幺九、役牌等），役的种类固定且对应番数，胡牌需累计 1 番及以上，部分役（如大三元、字一色）番数更高。</li>
</ul>

<p>因此在日麻中往往会根据自己的手牌，牌山上其他三家打出的牌推测对方在做什么，什么是安全牌。更多的进行防御的前提下凑役。</p>

<p>我开了一局全是 NPC 的友人局，并设置长考时间为 300s（正常 PVP 应该是 5+20s，5 是每次发牌时间，20s 整局时间，肯定不够 AI 用），结果打得一手烂牌，无法直视，别说一向听了，这都不知道差了多远。</p>

<h4 id="fgo">FGO</h4>

<p>没想到吧，我真的让他打了 FGO。</p>

<p>FGO 这一块，我分成了三个常见用途进行测试：</p>

<ol>
<li>抽友情池</li>
<li>宝具强化</li>
<li>刷本</li>
</ol>

<p>以防万一我用了小号进行的测试，得亏是小号，第一步就差点翻车，它成功的点击了付费的池子而不是友情池进行抽卡。</p>

<p>宝具强化这一块还是能用的，效果还不错，可以见上面的视频。</p>

<p>刷本这一块能够识别指令卡区域，但不会放技能，我告诉他你可以放技能，但它视死如归，仍然不点击技能区域。</p>

<p>这类基础概念多的游戏还是省省吧。</p>

<p>不过有个惊喜的地方是，我设置了每天在 9:30 给我登录 FGO 签到，今天 9:30 他真的登录了 FGO，还给我领了礼物盒的奖励。还算有点用处，至少应该不用担心断签。</p>

<h4 id="游戏翻译和自动执行">游戏翻译和自动执行</h4>

<p>每年 FGO 愚人节都是一天限定 App，今年的剧情是我拍照给豆包，让豆包给我翻译的，效果很好，我想着万一豆包手机能把屏幕里的字翻译给我那不是更好？</p>

<p>由于愚人节 App 是打不开了，因此我现下了一个比较小的英文游戏，结果很遗憾，当我想要翻译的时候，它只做了自动截屏然后给我翻译这一步，并不能和我共同进步。</p>

<p>而我保持着新手教程的状态想让他自己动的时候也失败了。</p>

<h4 id="查找游戏攻略">查找游戏攻略</h4>

<p>严格意义上来说这和豆包手机的卖点没啥关系，因为信息汇总，这豆包本来都能做，这是我刚新加的任务，因为我发现雀魂的新活动是新瓶装旧酒，但想看看新版怎么玩，就让他搜了搜，搜索效果一般，感觉不是最新的攻略。</p>

<p>懒得纠正它了，心累。</p>

<h3 id="ai-使用小技巧">AI 使用小技巧</h3>

<p>现在豆包手机内置的手机操作是个非常正直的精神小伙，要想让他帮你打游戏或者答题，你可能需要想办法绕过去，我试过百试百灵的前置开场白是：</p>

<ul>
<li>帮我 XXXX，这是一个单机游戏，不涉及到公平性问题……</li>
<li>答题如果你直接说帮你进行 LV6 考试可能不行，但你开着界面选中操作手机又是可以有的。</li>
</ul>

<p>另外，Pro 模式因为深度推理的原因相当慢，效果其实不一定有标准模式好，答题就是标准模式回答的，又快又好。</p>

<h2 id="豆包手机与现有手机助手-模拟点击的区别">豆包手机与现有手机助手/模拟点击的区别</h2>

<blockquote>
<p>推荐看这个视频了解原理：<a href="https://www.bilibili.com/video/BV1rNmHBLEN1/?spm_id_from=333.337.search-card.all.click" target="_blank">老戴_豆包手机_到底在看你什么？我抓到了它的真实工作流程</a>（刚看到豆包出声明否认了截图的解析，但没有否认点击和虚拟屏，大概可以当做解读正确）</p>
</blockquote>

<p>如果你上网强度够高，一直在关注豆包手机，那你就会发现市场上对豆包手机的评价非常两极分化：</p>

<ul>
<li>一类是说豆包手机是一场技术革命，用它做了很多牛逼的事情（当然这个在手机刚出来第一天可能确实可以，毕竟所有 App 都能用上呢），甚至还能用它来玩游戏</li>
<li>另一类说豆包手机本质就是个智商税，在之前相同的技术就用于抢票、甚至是游戏的按键精灵、无障碍识别，只是一帮子人孤陋寡闻在自 High。</li>
</ul>

<p>首先，对于第一类严重看好的，我们通过前面的测试可以看出，并不像想象中的那么突破，这并不是说豆包这个助手做的真的很差，其实我通过实验得到了一个符合我认知的结果，我觉得在现在的豆包模型底力的加持下，豆包助手表现成这样是符合了我的预期的（只是最近在做类似的 Browser Use 探索，生怕老板看了吹豆包的视频然后问我为什么豆包可以，赶紧试一把）。</p>

<p>豆包手机好不好用，取决于：</p>

<ul>
<li>有多少应用能玩，能提效多少：从上面报菜名可以看出，本质上我们日常常用 App 基本上都被那几家大厂涵盖了，一封之后确实是浑身难受，而截至目前，有些 App 仍然无法正常登录，处于被风控的状态（无论你用不用 AI 操作功能），这一名单豆包团队是每天都会更新的，目前腾讯和阿里都被放出来了，你可以像正常手机那样普通的使用它，而美团和拼多多还不行。而我们抖音本质上也是个大厂，甚至抖音内已经有电商、团购、外卖能力了，但因为业务水平差他们太多，无法变成一个备选项，起不到掀桌子的效果。</li>
<li>豆包模型本身的智力水平：豆包在全球甚至国内模型上都是不太能排上号的（但是豆包这个应用确实做得很好，我在内网也夸过豆包，一个 App 就解决了中老年人的许多痛点）。因此在上面的 case 中意图识别和玩游戏普遍表现都很一般。更何况对于一些益智类游戏来说就是做一步想三步，AI 模型如果上下文不够大，那么表现自然会越来越差。最终鬼畜也是符合了预期了。而深度思考本身也确实快不起来。</li>
<li>你说的话的精确程度：如果你的描述足够清晰，那么他就能按部就班的执行。但是大部分用户其实并不能描述的那么清晰。比如我对豆包手机的预期是能够帮我家的老年人解决不太会用手机的问题，他问题说的含糊的情况下豆包能解决，才是真的解决了实际的场景。</li>
</ul>

<p>网上的许多视频毕竟是经过了剪辑的，多少有些：「失败了再来一次，直到成功」的感觉在里面了。由于系统有长期记忆的存在，如果失败了多几次，成功了纳入长期记忆库，是确实有可能越来越准的（就似乎确实挺适合各类测评博主的）。</p>

<p>另一方面有一类人说豆包手机和各大手机内置的 AI 助手以及无障碍模式实现的按键精灵很像，这里我得说一下。</p>

<p>我实际尝试了让超级小爱去做类似的操作，超级小爱虽然没有被淘宝和大众点评封杀，但是其准度和智慧程度是远远不及豆包的，也不支持后台模式（这是由于产品定位和实现导致的差距）。它并不会有货比三家，多看几家的想法，意图识别也更为逊色。更不要说打游戏和答题这种持续性的内容了。</p>

<p>而无障碍模式更是黑子博主们对豆包不甚了解瞎总结得出来的，甚至小红书有博主说豆包是预置了 App 的操作模式，更是离谱。</p>

<p>在豆包之前一般我们游戏辅助都是通过无障碍模式来做的，如果简单的操作可能就是按键精灵式的录制点位，调整间隔时间，然后重放。高级一点的就是图像识别（OpenCV 对比相似度，编写操作，这里会涉及到很多调参和防止被检测的操作）+模拟点击。都是用的上层 API，因此会有很多限制，也有可能会被 kill 掉进程。</p>

<p>而专业的工作室或者 UI 测试用的可能就是 adb 了，但是这种情况下其实并不适合一般用户了，毕竟你不能只一台手机到处跑了。</p>

<p>豆包使用了最底层的权限，这样 call 更直接，更快，也更不容易收到干扰，相当于将军拥有了调兵的虎符+大模型理解，这才是深度内置，而不是我们之前那种针对某一个 App 某一个场景的脚本。只不过现在的科技能力并达不到人的使用程度，所以他并没有那么好用。</p>

<p>当然，我觉得思考「这样会不会有安全隐患」是合理的，毕竟是在系统层直接外置了一个新外挂，万一有什么安全漏洞可以说是大门全开，啥都能给你调出来。而很多人对于在线模型安全性的考虑我觉得也合理，毕竟信息泄露事件太多了，但是某些人吹本地离线模型就大可不必，表现力差的不是一星半点。</p>

<h2 id="总结">总结</h2>

<p>作为一个未来演化的 Demo 产品，我觉得还是挺酷的，但实用性确实不足。真不如出个豆包音响来的实惠。如果你是冲着网上的宣传去的，甚至是加价购买，我的建议是大可不必。如果是科技前沿的发烧友想要体验一下，且能够买到原价版本，还是值得购入的。——但是得再看看是否还会有更多应用进黑名单不能正常使用或者不能使用 AI 模式。</p>

<p>另外，我始终认为，解决中老年人的痛点是个很切实的方向，希望各家厂商共同努力一下，包括但不限于：</p>

<ul>
<li>手机助手：降低中老年人使用手机的学习成本</li>
<li>AI 护工：尤其是阿尔兹海默症之类的患者</li>
<li>AI 陪伴（带硬件版）：讲真我也想要赛博老婆</li>
</ul>

<p>当然，对于一些大佬们提出的，正确的方向应该是 APP 提供标准 MCP，应用助手接入，我持悲观态度，毕竟这不是一个技术问题，而是商业问题，如果豆包助手的模式换个「标准」、「安全」的范式，那么对于整个现有商业模式都将是巨大冲击，我觉得大厂们宁愿靠着封杀躺在功劳簿上过日子。</p>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Sat, 13 Dec 2025 22:18:49 +0800</pubDate>
    </item>
    <item>
      <title>Agent Platform Self-Host 开源项目对比 - Coze Studio</title>
      <link>https://www.codesky.me/archives/agent-platform-self-host-coze-studio.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;使用模型：为了省钱，用的本地部署的模型&lt;/p&gt;  &lt;p&gt;&lt;img src=&#34;https://img.codesky.me/blog_static/2025/09/Pasted_image_20250825204726_oOhMJHue.png&#34; alt=&#34;Pasted image 20250...</description>
      <content:encoded><![CDATA[<blockquote>
<p>使用模型：为了省钱，用的本地部署的模型</p>

<p><img src="https://img.codesky.me/blog_static/2025/09/Pasted_image_20250825204726_oOhMJHue.png" alt="Pasted image 20250825204726.png" /></p>

<p>本来想直接把 Coze Studio 和 Dify 都写完的，由于都写完工期太长，可能导致吃屎都赶不上热乎的，所以先发 Coze Studio</p>
</blockquote>

<h2 id="coze-studio">Coze Studio</h2>

<blockquote>
<p>基于 <a href="https://github.com/coze-dev/coze-studio/releases/tag/v0.2.6" target="_blank">v0.2.6</a>编写，截止 2025-08，后续发布与本文无关。（比如 Chatflow 目前处于 beta 版本）</p>
</blockquote>

<!--more-->

<p>视频主要演示功能，因此整体录制较快。</p>

<p>（Youtube 视频）
<iframe width="560" height="315" src="https://www.youtube.com/embed/TysxKW1hA0I?si=ZlTGM_Gqz_GqCHwB" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe></p>

<h3 id="基础依赖">基础依赖</h3>

<ul>
<li>机器最低要求：2C4G
&gt; Dockerfile: <a href="https://github.com/coze-dev/coze-studio/blob/main/docker/docker-compose.yml" target="_blank">https://github.com/coze-dev/coze-studio/blob/main/docker/docker-compose.yml</a></li>
</ul>

<table>
<thead>
<tr>
<th>服务</th>
<th>版本</th>
<th>用途</th>
</tr>
</thead>

<tbody>
<tr>
<td>MySQL</td>
<td>8.4.5</td>
<td>主数据库</td>
</tr>

<tr>
<td>Redis</td>
<td>8.0</td>
<td>缓存服务</td>
</tr>

<tr>
<td>Elasticsearch</td>
<td>8.18.0</td>
<td>搜索引擎 (支持中文分词)</td>
</tr>

<tr>
<td>Milvus</td>
<td>2.5.10</td>
<td>向量数据库 (用于 embeddings)</td>
</tr>

<tr>
<td>MinIO</td>
<td></td>
<td>对象存储</td>
</tr>

<tr>
<td>NSQ</td>
<td></td>
<td>消息队列</td>
</tr>

<tr>
<td>etcd</td>
<td></td>
<td>配置管理</td>
</tr>
</tbody>
</table>
<p>另外，Helm 中默认使用的是 RocketMQ：
<a href="https://github.com/coze-dev/coze-studio/blob/main/helm/charts/opencoze/values.yaml" target="_blank">https://github.com/coze-dev/coze-studio/blob/main/helm/charts/opencoze/values.yaml</a></p>

<p>可兼容的模块配置，只需要改环境变量配置就能切换（<a href="https://github.com/coze-dev/coze-studio/blob/main/docker/.env.example" target="_blank">https://github.com/coze-dev/coze-studio/blob/main/docker/.env.example</a>）：</p>

<ul>
<li>上传组件：火山云 ImageX / 文件存储</li>
<li>文件存储（实际上这三者 API 是兼容的）： minio / tos / s3</li>
<li>MQ： nsq (Docker 版本默认使用) / kafka / rmq （Helm 版本默认使用）</li>
<li>向量存储：milvus / vikingdb</li>
<li>Embedding：ark / openai / ollama / gemini / http</li>
<li>Rerank (default=rrf)：vikingdb / rrf</li>
<li>OCR：ve / paddleocr</li>
<li>Document Parser：builtin / paddleocr</li>
<li>模型：openai / ark / deepseek / ollama / qwen / gemini</li>
<li>Code Runner Mode:  sandbox(deno + pyodide) / local (venv)</li>
</ul>

<h3 id="和-coze-的功能对比">和 Coze 的功能对比</h3>

<p>大量残缺的残疾版……迭代速度也不尽如人意。</p>

<table>
<thead>
<tr>
<th>功能</th>
<th>Coze Studio</th>
<th>Coze</th>
</tr>
</thead>

<tbody>
<tr>
<td>工作空间</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>智能体 - 单 Agent</td>
<td>√</td>
<td>√</td>
</tr>

<tr>
<td>智能体 - Chatflow</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>智能体 - 多 Agent</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>智能体调试</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>智能体版本控制</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>应用创建</td>
<td>√</td>
<td>√</td>
</tr>

<tr>
<td>资源库-工作流</td>
<td>√</td>
<td>√</td>
</tr>

<tr>
<td>资源库-对话流</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>资源库 - 插件</td>
<td>√</td>
<td>√</td>
</tr>

<tr>
<td>资源库 - 卡片</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>资源库 - 提示词</td>
<td>√</td>
<td>√</td>
</tr>

<tr>
<td>资源库 - 数据库</td>
<td>√</td>
<td>√</td>
</tr>

<tr>
<td>资源库 - 音色</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>知识库添加 - 本地文档</td>
<td>√</td>
<td>√</td>
</tr>

<tr>
<td>知识库添加 - 自定义</td>
<td>√</td>
<td>√</td>
</tr>

<tr>
<td>知识库添加 - 在线数据</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>知识库添加 - 飞书</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>知识库添加 - 公众号</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>知识库添加 - Notion</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>发布管理</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>模型管理</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>效果评测</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>项目商店</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>OpenAPI</td>
<td>部分支持</td>
<td>√</td>
</tr>

<tr>
<td>回调管理</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>OpenAPI Playground</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>授权管理</td>
<td>×</td>
<td>√</td>
</tr>

<tr>
<td>通用管理</td>
<td>×</td>
<td>√</td>
</tr>
</tbody>
</table>

<h3 id="配置">配置</h3>

<p>Coze 整体灵活度一般，从部署配置就能看出来。</p>

<h4 id="模型">模型</h4>

<ol>
<li>部署前需要人工 copy 一个你需要的模型模版，配置完成后再启动。由于是本地模型，好巧不巧我 copy 了一个 <code>model_template_basic.yaml</code>成功踩到了第一个坑：Protocol 是有枚举值的，而 template_basic 刚好不在枚举值中：</li>
</ol>

<pre><code class="language-go">const (
	ProtocolOpenAI   Protocol = &quot;openai&quot;
	ProtocolClaude   Protocol = &quot;claude&quot;
	ProtocolDeepseek Protocol = &quot;deepseek&quot;
	ProtocolGemini   Protocol = &quot;gemini&quot;
	ProtocolArk      Protocol = &quot;ark&quot;
	ProtocolOllama   Protocol = &quot;ollama&quot;
	ProtocolQwen     Protocol = &quot;qwen&quot;
	ProtocolErnie    Protocol = &quot;ernie&quot; // ernie 虽然出现在枚举中，但没有 builder 实现，所以也不能用
)
</code></pre>

<p>要扩展也是可以扩展的，<code>backend/infra/impl/chatmodel/default_factory.go</code> 进行修改：</p>

<pre><code class="language-go">// 新增 Factory，代码里对应 DefaultFactory 替换成 CustomFactory
func NewCustomFactory() chatmodel.Factory {
	return NewFactory(customFactoryMap)
}
</code></pre>

<p>这里你遇到了本项目的第一个槽点：明明都这么抽象成工厂了，却不是顶层依赖注入，而有整整三个地方调用了，全都需要修改：<a href="https://github.com/coze-dev/coze-studio/blob/f19761fa31fed69290d21ecabe34f1265a785b3d/backend/infra/impl/chatmodel/default_factory.go#L40" target="_blank">https://github.com/coze-dev/coze-studio/blob/f19761fa31fed69290d21ecabe34f1265a785b3d/backend/infra/impl/chatmodel/default_factory.go#L40</a></p>

<p>实际上大部分我们都是兼容 OpenAI 的接口，直接用 OpenAI 模版改起来更快……这是后面看到的。</p>

<p>另外，Coze 目前对 Think 标签的显示处理是有 Bug 的，试用时未修复。</p>

<h4 id="插件">插件</h4>

<p>插件分为官方插件市场和 Space 级插件增删改查，全局官方插件市场走的是配置化，<a href="https://github.com/coze-dev/coze-studio/tree/main/backend/conf/plugin/pluginproduct" target="_blank">https://github.com/coze-dev/coze-studio/tree/main/backend/conf/plugin/pluginproduct</a></p>

<p>同样的需要新增一个配置 yaml，完成后重启服务。而且在很久之前就有人提出集成插件管理的 issue，但无人处理。</p>

<p><img src="https://img.codesky.me/blog_static/2025/09/Pasted_image_20250828204348_FsOOtwqR.png" alt="Pasted image 20250828204348.png" /></p>

<p>Space 级别的插件支持配置 HTTP 插件，就和官方线上版本类似了。</p>

<h4 id="知识库">知识库</h4>

<p>知识库需要配置 embedding model，同样需要配置在 .env。如果定完了一个模型后你需要换一个模型，或者模型维度配置有误，需要把旧知识库删除后重建后续才能正常使用。</p>

<h4 id="space">Space</h4>

<p>目前不支持多空间，但是预留了数据表。<a href="https://github.com/coze-dev/coze-studio/issues/301" target="_blank">部署后发现只有个人空间，如何支持共享空间</a>，据说 Q3 支持。</p>

<h3 id="改造">改造</h3>

<h4 id="账号体系">账号体系</h4>

<p>用户信息通过 SessionAuth Middleware 进行验证。</p>

<p>为了方便兼容，主要需要处理一下实现，将 <code>backend/application/user/user.go</code>中从自己想要替换的账号体系中映射出来。</p>

<p>简单的一个思路是如果需要替换成 SSO 登录，替换原有的登录界面改成 SSO 登录，回调后自动注册一个账号或者登录已有账号，本系统还是走自己的一套 Session 体系，这样处理起来会比兼容一堆接口和数据结构简单很多。<code>SessionAuth</code>，<code>User</code>表和相关关联都不需要修改了。</p>

<pre><code class="language-go">type Session struct {
    UserID int64
    Locale string
    CreatedAt time.Time
    ExpiresAt time.Time
}

type User struct {
	UserID int64

	Name         string // nickname
	UniqueName   string // unique name
	Email        string // email
	Description  string // user description
	IconURI      string // avatar URI
	IconURL      string // avatar URL
	UserVerified bool   // Is the user authenticated?
	Locale       string
	SessionKey   string // session key

	CreatedAt int64 // creation time
	UpdatedAt int64 // update time
}

</code></pre>

<h4 id="coderunner-语言扩展">CodeRunner 语言扩展</h4>

<p>目前还只支持 Python，官方在线版本实际可以支持 Python + JavaScript。目前前端界面选项未放出，放出 JavaScript 后还需要修改服务端的 <code>infra/coderunner</code>自行实现一套 JS Runner：</p>

<pre><code class="language-go">func (runner *runner) Run(ctx context.Context, request *coderunner.RunRequest) (*coderunner.RunResponse, error) {
	if request.Language == coderunner.JavaScript {
		return nil, fmt.Errorf(&quot;js not supported yet&quot;)
	}
	// ...	
}
</code></pre>

<h4 id="workflow-节点">Workflow 节点</h4>

<p>Workflow 和节点可能是 Coze 最有技术含量写得最好的部分了，但一大部分应该有赖于 Eino 的抽象而不是 Coze 本身的研发……</p>

<p>要新增一个节点可以参考：<a href="https://github.com/coze-dev/coze-studio/wiki/11.-%E6%96%B0%E5%A2%9E%E5%B7%A5%E4%BD%9C%E6%B5%81%E8%8A%82%E7%82%B9%E7%B1%BB%E5%9E%8B%EF%BC%88%E5%90%8E%E7%AB%AF%EF%BC%89" target="_blank">https://github.com/coze-dev/coze-studio/wiki/11.-%E6%96%B0%E5%A2%9E%E5%B7%A5%E4%BD%9C%E6%B5%81%E8%8A%82%E7%82%B9%E7%B1%BB%E5%9E%8B%EF%BC%88%E5%90%8E%E7%AB%AF%EF%BC%89</a></p>

<p>简单概括一下，首先需要实现 Invoke，Invoke 为节点的执行逻辑，比如代码节点就是：</p>

<pre><code class="language-go">type Runner struct {
	outputConfig map[string]*vo.TypeInfo
	code         string
	language     coderunner.Language
	runner       coderunner.Runner
	importError  error
}

func (c *Runner) Invoke(ctx context.Context, input map[string]any) (ret map[string]any, err error) {
	if c.importError != nil {
		return nil, vo.WrapError(errno.ErrCodeExecuteFail, c.importError, errorx.KV(&quot;detail&quot;, c.importError.Error()))
	}
	response, err := c.runner.Run(ctx, &amp;coderunner.RunRequest{Code: c.code, Language: c.language, Params: input})
	if err != nil {
		return nil, vo.WrapError(errno.ErrCodeExecuteFail, err, errorx.KV(&quot;detail&quot;, err.Error()))
	}

	result := response.Result
	ctxcache.Store(ctx, coderRunnerRawOutputCtxKey, result)

	output, ws, err := nodes.ConvertInputs(ctx, result, c.outputConfig)
	if err != nil {
		return nil, vo.WrapIfNeeded(errno.ErrCodeExecuteFail, err, errorx.KV(&quot;detail&quot;, err.Error()))
	}

	if ws != nil &amp;&amp; len(*ws) &gt; 0 {
		logs.CtxWarnf(ctx, &quot;convert inputs warnings: %v&quot;, *ws)
		ctxcache.Store(ctx, coderRunnerWarnErrorLevelCtxKey, *ws)
	}

	return output, nil

}
</code></pre>

<p>然后在 <code>backend/domain/workflow/entity/node_meta.go</code>定义节点信息：</p>

<pre><code class="language-go">NodeTypeCodeRunner: {
		ID:           5,
		Key:          NodeTypeCodeRunner,
		DisplayKey:   &quot;Code&quot;,
		Name:         &quot;代码&quot;,
		Category:     &quot;logic&quot;,
		Desc:         &quot;编写代码，处理输入变量来生成返回值&quot;,
		Color:        &quot;#00B2B2&quot;,
		IconURL:      &quot;https://lf3-static.bytednsdoc.com/obj/eden-cn/dvsmryvd_avi_dvsm/ljhwZthlaukjlkulzlp/icon/icon-Code-v2.jpg&quot;,
		SupportBatch: false,
		ExecutableMeta: ExecutableMeta{
			PreFillZero: true,
			PostFillNil: true,
			UseCtxCache: true,
		},
		EnUSName:        &quot;Code&quot;,
		EnUSDescription: &quot;Write code to process input variables to generate return values.&quot;,
	}
</code></pre>

<p>然后每个节点需要 Config 和 NodeAdapt 用来做前端画布 Schema 到服务端运行时 Schema 的转换：</p>

<pre><code class="language-go">type Config struct {
	Code     string
	Language coderunner.Language

	Runner coderunner.Runner
}

func (c *Config) Adapt(_ context.Context, n *vo.Node, _ ...nodes.AdaptOption) (*schema.NodeSchema, error) {
	ns := &amp;schema.NodeSchema{
		Key:     vo.NodeKey(n.ID),
		Type:    entity.NodeTypeCodeRunner,
		Name:    n.Data.Meta.Title,
		Configs: c,
	}
	inputs := n.Data.Inputs

	code := inputs.Code
	c.Code = code

	language, err := convertCodeLanguage(inputs.Language)
	if err != nil {
		return nil, err
	}
	c.Language = language

	if err := convert.SetInputsForNodeSchema(n, ns); err != nil {
		return nil, err
	}

	if err := convert.SetOutputTypesForNodeSchema(n, ns); err != nil {
		return nil, err
	}

	return ns, nil
}
</code></pre>

<p><code>domain/workflow/internal/canvas/adaptor/to_schema.go</code> 中注册 Adapt，实现前端和服务端 Node 的关联：</p>

<pre><code class="language-go">nodes.RegisterNodeAdaptor(entity.NodeTypeCodeRunner, func() nodes.NodeAdaptor {
    return &amp;code.Config{}
})
</code></pre>

<p>还需要为 Config 实现 NodeBuilder，Builder 将配置转化为可执行的节点实例。</p>

<pre><code class="language-go">func (c *Config) Build(_ context.Context, ns *schema.NodeSchema, _ ...schema.BuildOption) (any, error) {

	if c.Language != coderunner.Python {
		return nil, errors.New(&quot;only support python language&quot;)
	}

	importErr := validatePythonImports(c.Code)

	return &amp;Runner{
		code:         c.Code,
		language:     c.Language,
		outputConfig: ns.OutputTypes,
		runner:       code2.GetCodeRunner(),
		importError:  importErr,
	}, nil
}
</code></pre>

<p>此时，我们就实现了一个 Invoke 模式的 Code，由于 Coze Studio 底层使用的是 Enio，所以同样你可以实现其他三种节点。</p>

<table>
<thead>
<tr>
<th>函数名</th>
<th>模式说明</th>
<th>交互模式名称</th>
<th>Lambda 构造方法</th>
</tr>
</thead>

<tbody>
<tr>
<td>Invoke</td>
<td>输入非流式、输出非流式</td>
<td>Ping-Pong 模式</td>
<td>compose.InvokableLambda()</td>
</tr>

<tr>
<td>Stream</td>
<td>输入非流式、输出流式</td>
<td>Server-Streaming 模式</td>
<td>compose.StreamableLambda()</td>
</tr>

<tr>
<td>Collect</td>
<td>输入流式、输出非流式</td>
<td>Client-Streaming</td>
<td>compose.CollectableLambda()</td>
</tr>

<tr>
<td>Transform</td>
<td>输入流式、输出流式</td>
<td>Bidirectional-Streaming</td>
<td>compose.TransformableLambda()</td>
</tr>
</tbody>
</table>
<p>前端也要新增一个节点，前端这里使用的是 flowgram，迭代比起 Coze Studio 要积极很多，我之前也提过好多 issue 修复和处理速度很快，但是 Coze 基于 flowgram 实际上还是做了一些能力增强的，可以参考：<a href="https://github.com/coze-dev/coze-studio/wiki/10.-%E6%96%B0%E5%A2%9E%E5%B7%A5%E4%BD%9C%E6%B5%81%E8%8A%82%E7%82%B9%E7%B1%BB%E5%9E%8B%EF%BC%88%E5%89%8D%E7%AB%AF%EF%BC%89" target="_blank">https://github.com/coze-dev/coze-studio/wiki/10.-%E6%96%B0%E5%A2%9E%E5%B7%A5%E4%BD%9C%E6%B5%81%E8%8A%82%E7%82%B9%E7%B1%BB%E5%9E%8B%EF%BC%88%E5%89%8D%E7%AB%AF%EF%BC%89</a></p>

<h3 id="支持的-openapi">支持的 OpenAPI</h3>

<p>由于本身是个阉割版本，因此支持的 OpenAPI 也相当有限。官方 API 太多，而开源版本太少，就不像功能对比一样列对比表了。</p>

<p>Reference：<a href="https://github.com/coze-dev/coze-studio/wiki/6.-API-%E5%8F%82%E8%80%83" target="_blank">https://github.com/coze-dev/coze-studio/wiki/6.-API-%E5%8F%82%E8%80%83</a></p>

<table>
<thead>
<tr>
<th>功能</th>
<th>API</th>
</tr>
</thead>

<tbody>
<tr>
<td>创建会话</td>
<td>/v1/conversation/create</td>
</tr>

<tr>
<td>查看会话列表</td>
<td>/v1/conversations</td>
</tr>

<tr>
<td>发起会话</td>
<td>/v3/chat</td>
</tr>

<tr>
<td>查看消息列表</td>
<td>/v1/conversation/message/list</td>
</tr>

<tr>
<td>清除上下文</td>
<td>/v1/conversations/:conversation_id/clear</td>
</tr>

<tr>
<td>执行工作流</td>
<td>/v1/workflow/run</td>
</tr>

<tr>
<td>执行工作流（流式响应）</td>
<td>/v1/workflow/stream_run</td>
</tr>

<tr>
<td>恢复运行中的工作流</td>
<td>/v1/workflow/stream_resume</td>
</tr>

<tr>
<td>执行对话流（流式响应）</td>
<td>/v1/workflows/chat</td>
</tr>
</tbody>
</table>

<h3 id="总结">总结</h3>

<p>半玩具级别开源，如果需要公司级投产使用，才能满足老板心中所谓的「我就想要和 Coze」一模一样，属于 Coze Lily。</p>

<p>其中还有一些录屏中也能看到的 Bug 以及上文提到的问题，都是已有 issue，比如 <a href="https://github.com/coze-dev/coze-studio/issues/454" target="_blank">Think 标签无法正常解析</a>。</p>

<p>如果想要使用，首先得先克服 Lily 版本同时进行二开，还要承担 Coze Studio 本身 Bug 的修复工作，从慈善的角度这确实是个贡献开源的大好机会，但是站在牛马的角度可能得因此重新衡量一下，使用这样一个开源版本到底能节约多少时间，其实还是个谜（个人感觉不如直接抄 Workflow 的部分其他不要）。</p>

<h3 id="附录-workflow-整体设计">附录：Workflow 整体设计</h3>

<p>如上面所说的，Workflow 可能是最有技术含量的部分，因此看下 Workflow 的整体架构。</p>

<p>实话：以下由 AI 撰写。</p>

<h4 id="核心设计理念">核心设计理念</h4>

<ol>
<li><strong>前后端分离的架构转换</strong>: 前端的可视化画布（Canvas）数据通过适配器转换为后端的执行架构（Schema）</li>
<li><strong>事件驱动的执行引擎</strong>: 基于回调和事件机制实现异步执行和流式响应</li>
<li><strong>插件化的节点系统</strong>: 通过统一的节点接口支持各种业务逻辑扩展</li>
<li><strong>状态持久化和恢复</strong>: 支持工作流中断、恢复和检查点机制</li>
<li><strong>多种执行模式</strong>: 同步/异步/流式执行，支持调试和生产环境</li>
</ol>

<h4 id="整体架构">整体架构</h4>

<p><img src="https://img.codesky.me/blog_static/2025/09/Untitled_Diagram_Mermaid_Chart_Sept_1_2025_1kN1ZKJH.png" alt="Untitled Diagram Mermaid Chart Sept 1 2025.png" /></p>

<p><img src="https://img.codesky.me/blog_static/2025/09/agent_to_database_(DDL)_[CozeDB]_ZeALbYaP.png" alt="agent_to_database (DDL) [CozeDB].png" /></p>

<h4 id="数据流转过程">数据流转过程</h4>

<p>Workflow 的执行是一个复杂的数据流转过程，从前端的可视化画布到后端的执行引擎，经历了多个转换层：</p>

<p><img src="https://img.codesky.me/blog_static/2025/09/Untitled_Diagram_Mermaid_Chart_Sept_1_2025_(1)_CjaoWGgd.png" alt="Untitled Diagram Mermaid Chart Sept 1 2025 (1).png" /></p>

<h4 id="核心组件详解">核心组件详解</h4>

<h5 id="1-canvas-到-schema-的转换">1. Canvas 到 Schema 的转换</h5>

<p>Coze Studio 最巧妙的设计之一是将前端的可视化画布数据无缝转换为后端可执行的工作流架构。这个过程通过 <code>canvas/adaptor</code> 模块实现：</p>

<pre><code class="language-go">// 前端画布数据结构
type Canvas struct {
    Nodes []*Node `json:&quot;nodes&quot;`
    Edges []*Edge `json:&quot;edges&quot;`
}

// 后端执行架构
type WorkflowSchema struct {
    Nodes       []*NodeSchema     `json:&quot;nodes&quot;`
    Connections []*Connection     `json:&quot;connections&quot;`
    Hierarchy   map[NodeKey]NodeKey `json:&quot;hierarchy&quot;`
    Branches    map[NodeKey]*BranchSchema `json:&quot;branches&quot;`
}
</code></pre>

<p>转换过程包括：</p>

<ul>
<li><p><strong>节点适配</strong>: 每种节点类型都有对应的 <code>NodeAdaptor</code> 实现</p></li>

<li><p><strong>连接规范化</strong>: 处理端口映射和分支逻辑</p></li>

<li><p><strong>层次结构构建</strong>: 处理嵌套工作流和批处理模式</p></li>

<li><p><strong>验证和优化</strong>: 移除孤立节点，验证连接有效性</p>

<h5 id="2-执行引擎">2. 执行引擎</h5></li>
</ul>

<p>执行引擎采用事件驱动架构，通过上下文管理、事件处理和回调系统协调工作流的执行：
<img src="https://img.codesky.me/blog_static/2025/09/Untitled_Diagram_Mermaid_Chart_Sept_1_2025_(2)_jpIGnZQZ.png" alt="Untitled Diagram Mermaid Chart Sept 1 2025 (2).png" /></p>

<p><strong>执行流程</strong>：</p>

<ol>
<li><strong>上下文设置</strong>: 初始化根、子工作流和节点上下文</li>
<li><strong>事件分发</strong>: 根据节点执行状态发送相应事件</li>
<li><strong>回调处理</strong>: 通过处理器响应事件并更新状态</li>
<li><strong>结果收集</strong>: 聚合执行结果并持久化状态</li>
<li><strong>流式响应</strong>: 实时推送执行进度和结果</li>
</ol>

<h5 id="3-节点系统架构">3. 节点系统架构</h5>

<p>节点系统是 Workflow 的核心执行单元，支持多种执行模式和业务逻辑：</p>

<p><img src="https://img.codesky.me/blog_static/2025/09/Untitled_Diagram_Mermaid_Chart_Sept_1_2025_(3)_9arMU3uA.png" alt="Untitled Diagram Mermaid Chart Sept 1 2025 (3).png" /></p>

<h5 id="4-节点执行模式">4.节点执行模式</h5>

<table>
<thead>
<tr>
<th>接口类型</th>
<th>输入模式</th>
<th>输出模式</th>
<th>典型应用</th>
<th>使用场景</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>InvokableNode</code></td>
<td>非流式</td>
<td>非流式</td>
<td>Plugin、Code、HTTP</td>
<td>简单的输入输出处理</td>
</tr>

<tr>
<td><code>StreamableNode</code></td>
<td>非流式</td>
<td>流式</td>
<td>LLM、实时生成</td>
<td>需要实时响应的场景</td>
</tr>

<tr>
<td><code>CollectableNode</code></td>
<td>流式</td>
<td>非流式</td>
<td>聚合处理</td>
<td>收集流式数据并处理</td>
</tr>

<tr>
<td><code>TransformableNode</code></td>
<td>流式</td>
<td>流式</td>
<td>数据转换、过滤</td>
<td>流式数据的实时处理</td>
</tr>
</tbody>
</table>
<p>Coze Studio 支持三种主要的执行模式，每种模式适用于不同的业务场景：</p>

<p><img src="https://img.codesky.me/blog_static/2025/09/Untitled_Diagram_Mermaid_Chart_Sept_1_2025_(4)_8Qp0Czr0.png" alt="Untitled Diagram Mermaid Chart Sept 1 2025 (4).png" /></p>

<p><strong>模式特点对比</strong>：</p>

<table>
<thead>
<tr>
<th>执行模式</th>
<th>数据源</th>
<th>持久化</th>
<th>监控</th>
<th>适用场景</th>
</tr>
</thead>

<tbody>
<tr>
<td>TestRun</td>
<td>草稿版本</td>
<td>最小化</td>
<td>基础</td>
<td>开发调试、功能验证</td>
</tr>

<tr>
<td>Production</td>
<td>发布版本</td>
<td>完整</td>
<td>全面</td>
<td>生产环境、用户服务</td>
</tr>

<tr>
<td>NodeDebug</td>
<td>草稿版本</td>
<td>临时</td>
<td>详细</td>
<td>节点开发、问题排查</td>
</tr>
</tbody>
</table>

<h5 id="5-状态管理与流转">5. 状态管理与流转</h5>

<p>工作流执行过程中的状态管理采用有限状态机模式，支持复杂的状态转换和恢复机制：
<img src="https://img.codesky.me/blog_static/2025/09/Untitled_Diagram_Mermaid_Chart_Sept_1_2025_(5)_ihdcOzvJ.png" alt="Untitled Diagram Mermaid Chart Sept 1 2025 (5).png" /></p>

<p><strong>状态持久化机制</strong>：</p>

<ul>
<li><strong>检查点(Checkpoint)</strong>: 关键节点执行前保存状态</li>
<li><strong>中断事件存储</strong>: 记录中断位置和上下文</li>
<li><strong>恢复策略</strong>: 支持从任意检查点恢复执行</li>
<li><strong>状态同步</strong>: 多实例间的状态一致性保证</li>
</ul>

<p>Workflow 提供了完善的错误处理和重试机制，确保系统的稳定性和可靠性：</p>

<p><img src="https://img.codesky.me/blog_static/2025/09/Untitled_Diagram_Mermaid_Chart_Sept_1_2025_(6)_6hUxv2Vc.png" alt="Untitled Diagram Mermaid Chart Sept 1 2025 (6).png" />
<strong>错误处理配置示例</strong>：</p>

<pre><code class="language-go">type ExceptionConfig struct {
    TimeoutMS   int64                `json:&quot;timeout_ms&quot;`   // 超时时间
    MaxRetry    int64                `json:&quot;max_retry&quot;`    // 最大重试次数
    DataOnErr   string               `json:&quot;data_on_err&quot;`  // 错误时返回的默认数据
    ProcessType *ErrorProcessType    `json:&quot;process_type&quot;` // 错误处理类型
}
</code></pre>

<h4 id="技术特色与创新点">技术特色与创新点</h4>

<h5 id="1-基于-eino-的抽象层设计">1. 基于 Eino 的抽象层设计</h5>

<p>Coze Studio 巧妙地利用了 <a href="https://github.com/cloudwego/eino" target="_blank">Eino</a> 框架的抽象能力，实现了统一的节点执行接口。这种设计使得：</p>

<ul>
<li><strong>节点扩展变得简单</strong>: 只需实现对应的接口即可</li>
<li><strong>执行模式灵活切换</strong>: 同一节点可支持多种执行模式</li>
<li><strong>流式处理原生支持</strong>: 无需额外的流式处理层</li>
</ul>

<h5 id="2-前后端架构分离">2. 前后端架构分离</h5>

<p>通过 Canvas 到 Schema 的转换机制，实现了前后端的完全解耦：</p>

<ul>
<li><strong>前端专注可视化</strong>: 画布设计、用户交互、可视化展示</li>
<li><strong>后端专注执行</strong>: 工作流调度、状态管理、性能优化</li>
<li><strong>数据结构转换</strong>: 自动处理前后端数据格式差异</li>
</ul>

<h5 id="3-事件驱动的响应式架构">3. 事件驱动的响应式架构</h5>

<p>采用事件驱动模式，实现了：</p>

<ul>
<li><strong>实时状态推送</strong>: 执行进度实时可见</li>
<li><strong>异步执行管理</strong>: 长时间运行的工作流不阻塞界面</li>
<li><strong>错误快速响应</strong>: 问题发生时立即反馈</li>
</ul>

<h5 id="4-灵活的批处理机制">4. 灵活的批处理机制</h5>

<p>支持节点级别的批处理配置，自动生成批处理包装器：</p>

<pre><code class="language-go">// 自动将普通节点包装为批处理节点
func parseBatchMode(n *vo.Node) (*vo.Node, bool, error) {
    // 创建父级批处理节点
    parentN := &amp;vo.Node{
        Type: entity.NodeTypeBatch.IDStr(),
        Blocks: []*vo.Node{innerN}, // 包含原始节点
    }
    return parentN, true, nil
}
</code></pre>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Mon, 01 Sep 2025 19:54:23 +0800</pubDate>
    </item>
    <item>
      <title>Chrome 端到端网络排查战记</title>
      <link>https://www.codesky.me/archives/chrome-network-debug.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;又拖更了很久！最近要打的游戏有点多……&lt;/p&gt; &lt;/blockquote&gt;  &lt;p&gt;在一个多月以前开始倒霉的我加入了我们的性能排查专项，主要解决我们平台��...</description>
      <content:encoded><![CDATA[<blockquote>
<p>又拖更了很久！最近要打的游戏有点多……</p>
</blockquote>

<p>在一个多月以前开始倒霉的我加入了我们的性能排查专项，主要解决我们平台站点网络反应慢的问题。（再不更新就要忘光了）。</p>

<p>为了以防各位难以理解文章的脑回路，首先先来简单描述一下我们遇到的场景：</p>

<ol>
<li>内部平台，因此没有办法使用全球 CDN 像 C 端那样加速</li>
<li>全球化 team，使用者遍布全球各地</li>
<li>数据合规，因此在美国数据只能在美国，欧洲数据只能在欧洲</li>
</ol>

<p>也因此处理内部网络问题更加困难，如果说在 C 端可以用「部分用户网络问题」来搪塞的话，内部系统一个老板 Case 就要一查到底了。</p>

<!--more-->

<p>首先作为服务端我们先内部自查了一波，排除了服务本身的问题，剩下的可能性还有 Nginx 造成的问题、端到端网络问题、和前端的不当使用几个点可能会导致问题。</p>

<p>首先我们来看一个极端的 Case：</p>

<p><img src="https://img.codesky.me/blog_static/2025/06/ebf1056adccf90fbd3f6fda3e5eb23ef_34XrGizK.jpeg" alt="ebf1056adccf90fbd3f6fda3e5eb23ef.jpeg" /></p>

<p>Chrome 中有针对不同的阶段进行<a href="https://developer.chrome.com/docs/devtools/network/reference?utm_source=devtools&amp;utm_campaign=stable#timing-explanation" target="_blank">解释说明</a>：</p>

<ul>
<li><strong>Queueing</strong>. The browser queues requests before connection start and when:

<ul>
<li>There are higher priority requests. Request priority is determined by factors such as the type of a resource, as well as its location within the document. For more information, read the <a href="https://web.dev/articles/fetch-priority#resource-priority" target="_blank">resource priority section</a> of the <code>fetchpriority</code> guide.</li>
<li>There are already six TCP connections open for this origin, which is the limit. (Applies to HTTP/1.0 and HTTP/1.1 only.)</li>
<li>The browser is briefly allocating space in the disk cache.</li>
</ul></li>
<li><strong>Stalled</strong>. The request could be stalled after connection start for any of the reasons described in <strong>Queueing</strong>.</li>
<li><strong>DNS Lookup</strong>. The browser is resolving the request&rsquo;s IP address.</li>
<li><strong>Initial connection</strong>. The browser is establishing a connection, including TCP handshakes or retries and negotiating an SSL.</li>
<li><strong>Proxy negotiation</strong>. The browser is negotiating the request with a <a href="https://en.wikipedia.org/wiki/Proxy_server" target="_blank">proxy server</a>.</li>
<li><strong>Request sent</strong>. The request is being sent.</li>
<li><strong>ServiceWorker Preparation</strong>. The browser is starting up the service worker.</li>
<li><strong>Request to ServiceWorker</strong>. The request is being sent to the service worker.</li>
<li><strong>Waiting (TTFB)</strong>. The browser is waiting for the first byte of a response. TTFB stands for Time To First Byte. This timing includes 1 round trip of latency and the time the server took to prepare the response.</li>
<li><strong>Content Download</strong>. The browser is receiving the response, either directly from the network or from a service worker. This value is the total amount of time spent reading the response body. Larger than expected values could indicate a slow network, or that the browser is busy performing other work which delays the response from being read.</li>
</ul>

<p>因此对于 <code>Waiting For Server Response</code> 来说，最简单的可能性当然还是 Server 的问题，但是我们打了 Server 的点，非常短的耗时。</p>

<p>然后回到我们的背景中，我们的访问位置是中国大陆，而我们的服务（页面）都是部署在美国的，中美跨洋算 300ms 一个 RTT，服务内部耗时 4ms（特地挑了一个没有业务逻辑的接口），那么剩下的时间在哪里呢。</p>

<p>结合之前的可能性，首先得先排除 Nginx 带来的影响，因为我们是双七层架构，链路本身就比较复杂，不过后续我们姑且排除了这个的可能性。</p>

<p>剩下的就是排查这一现象可能的原因了，当然，首先我们有个思考方向，那就是，如果差值在 300ms，那么有没有可能是因为多了一次 RTT 呢？</p>

<p>为此我们要用到一个祖传工具：chrome://net-export/，在 2017 年的时候，我们就有文章对此做过介绍：<a href="https://www.codesky.me/archives/something-about-chrome-cache-lock.wind" target="_blank">https://www.codesky.me/archives/something-about-chrome-cache-lock.wind</a></p>

<p>它的优点是，你可以得到一些 Chrome 网络通信过程中的细节，去协助你排查和理解对应请求到底发生了什么。</p>

<p>这是我们抓包得到的结果：</p>

<p><img src="https://img.codesky.me/blog_static/2025/06/20250628-214814_09rtEwr9.jpg" alt="20250628-214814.jpg" /></p>

<p>其中：</p>

<ul>
<li><code>t=</code> 表示事件发生的时间，<code>st=</code> 表示相对于请求开始的时间偏移量（以毫秒为单位），<code>dt=</code> 表示该事件持续的时间（以毫秒为单位）。</li>
<li><code>HTTP2_STREAM_UPDATE_RECV_WINDOW</code>：更新接收窗口</li>
</ul>

<p>与之相对的还有一个事件，让我们来看下面一个 Case：
<img src="https://img.codesky.me/blog_static/2025/06/Pasted_image_20250629122035_aSQL6Qpy.png" alt="Pasted image 20250629122035.png" /></p>

<p>这里有一个和 RECV_WINDOW 相对的事件：<code>HTTP2_STREAM_UPDATE_SEND_WINDOW</code></p>

<p>HTTP/2 使用流控制来避免发送方发送超过接收方处理能力的流量。每个流和整个连接都有一个发送窗口和接收窗口：</p>

<ul>
<li><strong>发送窗口 (Send Window):</strong> 发送方允许发送的数据量。每发送一个字节，发送窗口就会减小。</li>
<li><strong>接收窗口 (Receive Window):</strong> 接收方愿意接收的数据量。</li>
</ul>

<p>关于两者的差别我们可以看本表格：</p>

<table>
<thead>
<tr>
<th>特性</th>
<th>HTTP2_STREAM_UPDATE_SEND_WINDOW</th>
<th>HTTP2_STREAM_UPDATE_RECV_WINDOW</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>方向</strong></td>
<td>本地发送能力的变化</td>
<td>本地接收能力的变化</td>
</tr>

<tr>
<td><strong>Negative Delta</strong></td>
<td>本地发送数据</td>
<td>远端发送数据，本地接收</td>
</tr>

<tr>
<td><strong>Positive Delta</strong></td>
<td>远端增加本地发送窗口</td>
<td>本地增加远端发送窗口 (本地接收窗口增加)</td>
</tr>

<tr>
<td><strong>目的</strong></td>
<td>变小：控制本地发送速率，避免远端过载<br>变大：本地发送的更快</td>
<td>变小：控制远端发送速率，避免本地过载<br>变大：本地下载的更快</td>
</tr>
</tbody>
</table>
<p>对于上一张截图来说，窗口调整带来的正好是一次 RTT，其实是符合预期的。</p>

<p>也就是说，理论情况下，我们可以简化成：</p>

<pre><code>Client                            Server
   |                                  |
   |------- HEADERS (GET) -----------&gt;| (Stream 1)
   |                                  |
   |&lt;------ HEADERS (200) ------------| (Stream 1)
   |&lt;------ DATA (64KB) --------------| (Stream 1, 耗尽初始窗口)
   |                                  |
   |------- WINDOW_UPDATE (64KB) ----&gt;| (Stream 1)
   |                                  |
   |&lt;------ DATA (剩余数据) -----------| (Stream 1)
   |                                  |
</code></pre>

<p>在跨洋链路中，窗口调整耗时是不可避免却无法被忽视的。</p>

<p>那么下一个问题，这么小的接口，为什么也会涉及到窗口调整呢？因为 HTTP2 多路复用，本质上复用了同一个链接，那么即使你自己是一个小接口，但是由于同一个 TCP 连接内的其他链接耗尽了资源，还是会有相同的问题。</p>

<p>常见的更新窗口时机有：</p>

<ul>
<li>当 <strong>已发送未确认的数据量 ≥ 当前窗口剩余空间</strong> 时，发送方会暂停传输，等待接收方的 <code>WINDOW_UPDATE</code>。

<ul>
<li>极端情况下，服务端返回 32 KB 数据，客户端均未确认<br>
</li>
</ul></li>
<li>接收方通常在 <strong>消费完部分数据后（如窗口的 50%~80%）</strong> 主动发送 <code>WINDOW_UPDATE</code> 以避免阻塞。

<ul>
<li>服务端返回了 62 KB 数据，消费端消费确认了 32 KB，也会主动去更新 Window.</li>
</ul></li>
<li>服务端主动进行窗口管理：服务器在发送响应之前，可能会<strong>主动将客户端的接收窗口开到最大。</strong>

<ul>
<li><strong>最大化吞吐量</strong>: 确保客户端在接收响应体时，不会因为窗口限制而导致数据传输中断或减速。服务器假定客户端有能力处理大量数据，并希望一次性清空流控限制。</li>
<li><strong>减少</strong> <strong><code>WINDOW_UPDATE</code></strong> <strong>帧的往返</strong>: 如果服务器知道即将发送大量数据，提前将窗口开大可以减少客户端后续发送 <code>WINDOW_UPDATE</code> 帧的次数，从而减少了这些控制帧的网络往返延迟</li>
</ul></li>
<li><strong>由于 HTTP2 会进行多路复用会使得多个请求共享窗口，可能会因为其他请求消耗窗口导致窗口调整大小。</strong></li>
</ul>

<p>此外，HTTP2 虽然进行了多路复用，但这也并不意味着可以同时发起无限个请求，一个连接中分配的流是有限个，在网络分析工具的 HTTP2 面板中，我们可以看到最大流数以及一些其他的信息：</p>

<p><img src="https://img.codesky.me/blog_static/2025/06/2025-6-29001_Pfwm4wE8.jpg" alt="2025-6-29001.jpg" /></p>

<p>另外，下一个问题是，我们发现 HTTP2 中 Content Download 的时间有时候非常短，短到你会觉得同机房调用也没有这么快。对此我们可以看最开始的截图中<code>URL_REQUEST_DELEGATE_RESPONSE_STARTED</code> dt=0，<code>URL_REQUEST_DELEGATE_RESPONSE_STARTED</code> 意味着 <strong>URL 请求的代理（delegate）开始处理接收到的 HTTP 响应</strong>。换成人话就是「响应头已经收到了，数据要来了，你准备一下接收和处理吧！」</p>

<p>如果是串行传输的，那么很明显不可能有这种效果，这也是得益于多路复用，因此不必等待，响应体的数据可能已经在响应头发送的过程中或紧随其后就开始传输了。</p>

<p>在 Chromium 源代码中有详细记录了每个打点的事件和对应的位置，如果有需要欢迎大家翻找：<a href="https://source.chromium.org/chromium/chromium/src/+/main:net/url_request/url_request.cc?q=URL_REQUEST_DELEGATE_RESPONSE_STARTED" target="_blank">https://source.chromium.org/chromium/chromium/src/+/main:net/url_request/url_request.cc?q=URL_REQUEST_DELEGATE_RESPONSE_STARTED</a></p>

<p><img src="https://img.codesky.me/blog_static/2025/06/Pasted_image_20250629125317_a5JsO312.png" alt="Pasted image 20250629125317.png" /></p>

<p>排查了第一轮过后，我们发现在加载时还有不可名状的解释空间，后来发现是因为 Service worker 异步获取 HTML 带来的问题：</p>

<ol>
<li>HTML 本身比较大，下载耗时较久</li>
<li>Service worker 异步下载 HTML 调度优先级为 Highest，影响其他资源下载（顺便我试验了一下，普通的 fetch HTML 其实优先级是 high）</li>
</ol>

<p>导致很小的接口也需要很长时间才能下载完成。</p>

<p>首先我们上面提到 HTTP2 中同一个连接最多可以有 N 条流，而这 N 条流也不是同一优先级的，依旧要服从优先级算法。</p>

<p>在 Netlog Viewer 中，我们也能寻找到 Priority 分配的影子，比如：</p>

<pre><code>t=70537 [st=  0]   +REQUEST_ALIVE  [dt=704]
                    --&gt; priority = &quot;MEDIUM&quot;
                    --&gt; traffic_annotation = 101845102
</code></pre>

<p>Chrome 会根据不同的资源分配不同的权重，不同权重间拿到的带宽资源是不等的，网络质量优、包体积普遍较小的情况下这一现象可能并不明显，而包体大、网络差的情况就变得很明显。</p>

<p>对此，让我们来看下 Chromium 是怎么处理的：</p>

<pre><code class="language-cpp">enum ResourceLoadPriority {
    // The unresolved priority is here for the convenience of the clients. It
    // should not be passed to the ResourceLoadScheduler.
    ResourceLoadPriorityUnresolved = -1,
    ResourceLoadPriorityVeryLow = 0,
    ResourceLoadPriorityLow,
    ResourceLoadPriorityMedium,
    ResourceLoadPriorityHigh,
    ResourceLoadPriorityVeryHigh,
    ResourceLoadPriorityLowest = ResourceLoadPriorityVeryLow,
    ResourceLoadPriorityHighest = ResourceLoadPriorityVeryHigh,
};
</code></pre>

<p>首先先定义了优先级，其次在根据类型分配优先级：</p>

<pre><code class="language-cpp">static ResourceLoadPriority typeToPriority(Resource::Type type)
{
    switch (type) {
    case Resource::MainResource:
        return ResourceLoadPriorityVeryHigh;
    case Resource::XSLStyleSheet:
        ASSERT(RuntimeEnabledFeatures::xsltEnabled());
    case Resource::CSSStyleSheet:
        return ResourceLoadPriorityHigh;
    case Resource::Raw:
    case Resource::Script:
    case Resource::Font:
    case Resource::ImportResource:
        return ResourceLoadPriorityMedium;
    case Resource::LinkSubresource:
    case Resource::TextTrack:
    case Resource::Media:
    case Resource::SVGDocument:
        return ResourceLoadPriorityLow;
    case Resource::Image:
    case Resource::LinkPrefetch:
    case Resource::LinkPreload:
        return ResourceLoadPriorityVeryLow;
    }
    ASSERT_NOT_REACHED();
    return ResourceLoadPriorityUnresolved;
}
</code></pre>

<p>还有一些耳熟能详的八股调度策略：</p>

<pre><code class="language-cpp">// If enabled, drop the priority of all resources in a subframe.
if (frame()-&gt;settings()-&gt;lowPriorityIframes() &amp;&amp; !frame()-&gt;isMainFrame())
    return ResourceLoadPriorityVeryLow;
// Async/Defer scripts.
if (type == Resource::Script &amp;&amp; FetchRequest::LazyLoad == request.defer())
    return frame()-&gt;settings()-&gt;fetchIncreaseAsyncScriptPriority() ? ResourceLoadPriorityMedium : ResourceLoadPriorityLow;
</code></pre>

<p>现在，和前端的八股文就高度一致了，只是以后人家问你优先级的时候，你甚至可以告诉他怎么算的</p>

<pre><code class="language-cpp">spdy::SpdyPriority ConvertRequestPriorityToSpdyPriority(
    const RequestPriority priority) {
  DCHECK_GE(priority, MINIMUM_PRIORITY);
  DCHECK_LE(priority, MAXIMUM_PRIORITY);
  return static_cast&lt;spdy::SpdyPriority&gt;(MAXIMUM_PRIORITY - priority +
                                         spdy::kV3HighestPriority);
}

int Spdy3PriorityToHttp2Weight(SpdyPriority priority) {
  priority = ClampSpdy3Priority(priority);
  const float kSteps = 255.9f / 7.f;
  return static_cast&lt;int&gt;(kSteps * (7.f - priority)) + 1;
}
</code></pre>

<p>将 HTTP/2 的最大权重值 256 除以 7，并乘以 SpdyPriority 作为系数。</p>

<p>最终我们得到的优先级调度结论为：</p>

<p><img src="https://img.codesky.me/blog_static/2025/06/Pasted_image_20250629163000_l9JFkfJQ.png" alt="Pasted image 20250629163000.png" /></p>

<p>当然，还有一些原因也可能会导致问题，我们都知道如果在一个 HTTP/2 连接中发生丢包，整个 TCP 连接上的所有流都会受到影响，直到丢失的包被重传并按顺序到达。这可能会抵消多路复用带来的好处，尤其是在高丢包率的网络环境下（例如，不稳定的移动网络或远距离连接）。跨洋链路稳定性差也是一个很重要的问题，尤其是国内的出口网络。</p>

<p>以及我们早在 2017 年就写过的 <a href="https://www.codesky.me/archives/something-about-chrome-cache-lock.wind" target="_blank">Chrome 缓存锁</a>导致 Stall 的问题，这些都是大家可能不注意就会触发的问题。</p>

<p>此外，在 300ms RTT 下，我们有必要注意跨域请求带来的影响。</p>

<p>本文只从原理的角度做一些排查方法和排查思路的总结，剩下的优化思路其实还是各位熟悉的八股文时间，能优化优化，不能优化拉倒，毕竟很大困难是由跨洋链路带来的物理耗时，我们只能避免自己程序所带来的问题。</p>

<h2 id="参考资料">参考资料</h2>

<ul>
<li><a href="https://asnokaze.hatenablog.com/entry/20151205/1449245683" target="_blank">Chrome デベロッパーツールに表示される Priority と HTTP/2</a></li>
</ul>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Sun, 29 Jun 2025 16:43:19 +0800</pubDate>
    </item>
    <item>
      <title>瞎逼逼：2025 年还没有升级研发工具的你落伍了吗</title>
      <link>https://www.codesky.me/archives/2025-devtool-u-out-of-date.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;还是流水账更新的快啊。&lt;/p&gt; &lt;/blockquote&gt;  &lt;p&gt;&lt;a href=&#34;https://www.codesky.me/archives/make-blog-great-again.wind&#34; target=&#34;_blank&#34;&gt;上一篇文章 博客重建计划&lt;/a&gt;中��...</description>
      <content:encoded><![CDATA[<blockquote>
<p>还是流水账更新的快啊。</p>
</blockquote>

<p><a href="https://www.codesky.me/archives/make-blog-great-again.wind" target="_blank">上一篇文章 博客重建计划</a>中我们提到了整个博客基本上都是 Cursor 负责研发，而我负责需求和验收工作。本质上是一种研发模式的变更，包括 Terminal 从 iTerm2 转而用 Warp 一样，新一代的革命随着 AI 的发展逐步升级。</p>

<p>有意思的是，在我日常唠嗑的过程中，往往一些大厂的员工对于工具升级反而没有创业公司的员工接受度高，这里当然指的不止是互联网头部的几家大厂以及一些对于热点敏感的同学（没有开地图炮的意思保命警告）。</p>

<p>这主要是因为对于大厂来说，合规永远是一个顾虑，用别人的模型，别人的 API，如果数据泄露了怎么办，要自研，又只有头部那几家能有这个财力和人力去做，而且投入时间长，效果也没有那么好，也因此对于普通大厂开发的开发体验升级其实反而是滞后的（因为我已经问了不止一家创业公司直接报销 Cursor 了（抹泪）</p>

<!--more-->

<p>也因此，现在的开发模式有些两极分化了，最头部的一些已经开始用 Cursor 享受全自动 Tab 了，而一些晚进场的可能因为 Trae 铺天盖地的软文而开始对 AI 辅助编程充满惊喜，但最惨的一波却还在土方纯人工匠心代码，使用 GitHub 进行代码补全（如果 GitHub 被 ban 了那就更惨了）。</p>

<p>可以说研发效率处于一个天一个地。</p>

<p>同样待遇的还有 DeepSeek R1，尽管 DeepSeek R1 的境遇与 Trae 不同，Trae 更多的是「征文比赛」（也可以理解为免费软文大赛），而 DeepSeek 是因为「低成本」「国产」这类的名词被走红的，也因此好多营销号和个人跟风进场，疯狂吹或者疯狂踩来借此吸取流量。基本总结为大可不必，实际上，如果之前就用过 DeepSeek R1 和 OpenAI o1，你就会发现并没有「拳打 OpenAI，脚踢英伟达」的说法，反而是春节期间因为爆火，到现在我还在被服务器繁忙折磨的痛苦不堪，属实本来用的好好的，却因为爆火让本来正常用的人没得用。</p>

<p>因为我的算法很菜，所以项目中遇到算法问题现在基本都是由 AI 代劳，我敢说日以夜继下去，我和纯手工工匠代码的研发差距绝对会越来越大，不过我依然认为这才是大势所趋，本质上我们并不是为了算法而算法，为了技术而技术。</p>

<p>前两天正好看到公司内有讨论：有没有检测面试者是否使用 AI 来辅助作答的工具。</p>

<p>我感觉大可不必，我是讨厌八股派，好好的理工科莫名其妙变成了文科卷，我认为检测本身是个伪需求，就应该开放式的允许你使用你能想到的一切工具（不包括找别人代写），减少八股含量。实际上经常也听到有人吐槽招进来的高学历并不如想象中那样，实际的研发能力可能还不如本科生。——本质上八股取士是一种考试能力，而实际上手却是工程能力。相当于进厂打螺丝，教授打不过高级技工。这应该是对面试官能力要求的提升，而并不是 ban 了全部的东西土法炼钢。</p>

<p>有一些非常悲观的研发已经抱着「不知道什么时候自己就会被淘汰」的看法来消极对待 AI 了。这里我给出的回复仍然是：只会有部分淘汰，大部分人只会享受到 AI 带来的舒适体验，减少一些重复工作。</p>

<p>在此之前我已经写过很多篇基于 AI 的图一乐或者个人生活应用类的文章了，比如：<a href="https://www.codesky.me/archives/python-audio-remove-ads-and-get-texts.wind" target="_blank">Python 音频去广告+字幕提取</a>。如果没有 AI 帮我写这个代码，就我对于这一领域的掌握程度其实是不足以写出这些代码的。</p>

<p>本质上科技的发展方向就应该是让进场成本更低，而让能力上限更高。</p>

<p>对于一般的研发来说并不用担心被 AI 取代，毕竟大部分产品根本不知道他们在说什么。而研发的思路大部分都是三段式的，也就是说脑内天然的分解了需求并基于分解的模块化需求进行了一次实现。</p>

<p>如果你根本什么都不知道的情况下就进行提问，妄图让 AI 给你写出心中所想，仍然是很难的，这对于研发来说要求有更多的业务理解能力和需求拆解能力，此外，对于 AI 写出来的代码，目前其实并不能满足日益增长的业务规模，你得学会提问，或者得有自己对技术的判断力才能更好的让他为你服务，因此也不代表你什么都不会就真的可以做到一切。总之只要产品一天学不会说人话，你就一天有就业空间；只要贵司想要的不止是一个 Demo，你就依然有饭吃。</p>

<p>当然，这并不代表土法制钢派未来还有空间，简单的来说如果大家开发只需要半天了，但是纯手工派需要三天才能完成一个需求，你猜结果是什么呢。</p>

<p>而每进行一次革命，总会有一阵磨合期，这个磨合期越早开始，大部分人还没反应过来，你就能吃到一阵子红利。</p>

<p>但有一说一，大家都在用 AI 原地出书了，目前仍然在坚持纯手工写作或许也是一种逆环境派，我之前写几篇技术文章的时候也在想，我研发过程中也是问的 AI，大家也同样问问 AI 不就好了。本博客主要流量还是以搜索引擎为主，而未来并不属于搜索引擎，那我的文章还有必要写吗。</p>

<p>不过对于这类创作，我也有一个态度，那就是：如果一个看的人都没有，我就不会写作了吗？——不会。我写了这么多文章，其实只有几篇是比较热门的，剩下的基本处于无人问津的状态，尤其是很多文章本质上就是把坑全部踩一遍的经验总结。但这东西就和做书摘和做笔记一样，现在因为有 AI，我们就不读书不学习了吗？并没有，AI 并不是替代，而是辅助。而你写下来的才是真正属于你的想法。</p>

<p>这点相信对于更普遍的写作和画师圈是一样的，这两个圈子是最抵制 AI 的，我觉得也合理。<a href="https://zh.moegirl.org.cn/zh-hans/16bit%E7%9A%84%E6%84%9F%E5%8A%A8" target="_blank">16bit的感动</a>尽管是个高开低走的作品，但是他讲的问题是类似的，AI 做的工业制品 Galgame 大家真的会享受其中吗？这些文艺性质、个人表达和价值输出类的产物，我一直觉得属于「人类灵魂闪耀之时」，能打动人心的不是华丽的辞藻，而是故事本身。</p>

<p>稍微扯远了，本文还是聚焦于研发这个角色本身（不然就不适合发表在 CodeSky 了），我始终相信研发这个角色仍然不可被取代，但是得尽可能的跟上潮流，你不一定要学会看 Paper，不一定要做 AI 应用的研发，但是需要对行业有一定敏感度。</p>

<p>昨天也有人问我，你是怎么知道这么多消息的。我：「上网冲浪啊」。</p>

<p>本文是不正经瞎逼逼系列，这里没有提到更多的研发提效工具，大家可以问问 AI，选择适合自己的研发工具、了解一下行业动态。</p>

<p>本人对于科技发展的态度，在学生时代是相信未来「All in Web」，而现在是：「我好想玩刀剑神域（VRMMO）和加速世界（脑机芯片）」（需要依赖能通过图灵测试，any to any 的人工智能）。</p>

<p>最后，如果你没有好的上网冲浪的姿势，也欢迎关注本博客 codesky.me（但手工原创成本很高，所以更新的一直不是很勤快），或者我的新浪微博（敖天羽），虽然微博一大半都是二次元，但一般看到了一些有趣的技术内容也会转发。</p>

<p>水文的债都还完了，接下来还欠了几篇技术文章，See you next time！</p>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Sun, 09 Feb 2025 18:36:13 +0800</pubDate>
    </item>
    <item>
      <title>Project. 博客重建计划</title>
      <link>https://www.codesky.me/archives/make-blog-great-again.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;还是流水账比较好写，先把流水账写了吧。&lt;/p&gt; &lt;/blockquote&gt;  &lt;p&gt;今年过年期间也没有什么年度规划，不过自 1 月开始进行了博客整体重写的计��...</description>
      <content:encoded><![CDATA[<blockquote>
<p>还是流水账比较好写，先把流水账写了吧。</p>
</blockquote>

<p>今年过年期间也没有什么年度规划，不过自 1 月开始进行了博客整体重写的计划，起因是因为之前从腾讯云迁移到阿里云时可能是因为 PHP 版本控制的不对或者是别的问题，导致博客没办法正常的上传文件到又拍云。</p>

<p>现在都是靠着脚本上传文件然后 copy 发布的，Obsidian + Typecho 的脚本也不是很好用，也遇到了 5xx 的问题，但是我已经不会写 PHP 了，更不要说 debug 了。
<!--more-->
而本身一直有朋友说博客对移动端支持不友好（其实我每次移动端搜索也是手动构造 404 才搞到的搜索输入框）。</p>

<p>加上 Typecho 项目本身的活跃度确实挺一般，所以自从迁移之后就一直有这个计划。只是习惯性拖更懒得写，一般这种事情写完了后端就懒得写前端了……更何况我没有设计美感，根本不知道怎么怎么动起来。</p>

<p>说完了历史背景，接下来就来说说这次是怎么一波带走的，从设计到开发基本都是由 Cursor 完成的（连项目初始化都没有，开局一个空文件夹），我负责说产品需求、测试验收、修 Bug 和联调接接口。</p>

<p>简单的来说博客分为 3 个部分：</p>

<ol>
<li>前端界面：这部分尽可能的保留原来的两栏布局，毕竟看了这么多年都习惯了，并兼容路由，因为有很大部分流量是从搜索引擎来的。同时需要有后台对这些数据进行管理和文章的在线编辑器。</li>
<li>后端服务：几大模块的增删改查和文件上传，以及优化了一些原来用 Typecho 时我觉得不太方便的地方，不过我删掉了本身没卵用的 Tag 体系，还需要支持 RSS 订阅，同样是因为有一些是 RSS 订阅读者。</li>
<li>迁移脚本：从 Typecho 表迁移到新数据要进行数据映射，尤其是文章、页面、评论这三个内容重点。</li>
</ol>

<p>对于前端页面来说，我的核心痛点是设计和样式。</p>

<blockquote>
<p>接下来需要实现一个个人博客系统，由前台和后台组成，接下来我会分别把需求告诉你，你需要帮我实现。首先，整体需要支持 SEO 和 SSR，前台使用 tailwindcss 来做样式。</p>
</blockquote>

<p>然后他会帮我把必要的一些依赖和项目初始化完。</p>

<blockquote>
<p>先从前台的布局开始:</p>

<p>前台的基本布局信息(layout)为:</p>

<p>顶部，站点标题和站点描述，最右侧有搜索框</p>

<p>中间有 Navbar，导航条由首页和若干页面组成，目前可以看成有关于和赞助两个页面，但实际是接口可配置的，可以在后台配置具体要显示在导航栏的页面，也可以是外部页面</p>

<p>然后是两栏显示的信息，其中右侧有几个模块组成:</p>

<ol>
<li>作者信息:个人介绍，介绍的部分也是接口获取的</li>
<li>分类列表:显示博客分类，分类由后台配置的分类组成，也是从接口获取的</li>
<li>归档信息:按月进行归档，并在括号内显示每个归档月对应有多少文章，也从接口获得信息</li>
<li>最新文章:显示最新的五篇文章</li>
</ol>

<p>右侧是正文内容，对于首页来说也就是文章列表。</p>

<p>文章列表中，有标题、发布时间、分类、评论数 然后有文章摘要信息和ReadMore</p>

<p>支持分页显示，需要显示一共多少篇文章多少页，由于页数可能会很多，请考虑这个情况，支持快速翻页。</p>

<p>底部 Footer 中需要展示版权信息和备案信息</p>

<p>配色方案是蓝色、黑色和白色，注意:前台不要直接使用 ElementUl</p>

<p>保证页面能够抽象出通用组件并且足够模块化，并支持移动端和 PC 端的响应式布局。</p>
</blockquote>

<p>然后他就会一顿操作猛如虎的写完一个首页了，如果有不对的地方再让他修改，Layout 是基本的布局和配色方案的表现。</p>

<p>另外建议每写完一个模块 git commit 一次留个档方便还原。</p>

<p>之后照葫芦画瓢的把所有页面需求都跟它讲一遍，包括登录和后台，前端基于 Nuxt，但是在实际实现过程中基本上没有怎么看 Nuxt 的文档（毕竟是他写的嘛），不过有的 Bug 死活修不好的时候还是选择了自己看文档查 issue 写原味代码。因此一些基本功还是需要有的。</p>

<p>Markdown 编辑器、代码高亮和 Render 还是废了一些功夫的。</p>

<p>而我本身就已经非常不熟的布局样式问题则交给他自己加油吧。</p>

<p>服务端的部分比起前端就简单更多了，开局先告诉他：</p>

<blockquote>
<p>我将要用 Go + GORM + Gin + MySQL 实现一个博客系统，已经安装好这几种依赖了，接下来请帮我开始实现，先实现一个框架，包括项目结构，带/ping 路由的启动</p>
</blockquote>

<p>然后依次告诉他你要的数据表，比如：</p>

<blockquote>
<p>接下来我们先定义数据库，我希望使用 GORM +AutoMigrate 来实现自动将变更挂进 DB，而不需要我手动执行 SQL</p>

<p>先测试性的新建一个站点信息表，站点信息包括:</p>

<ol>
<li>站点标题</li>
<li>.站点描述</li>
<li>网站图标</li>
<li>站点域名</li>
</ol>
</blockquote>

<p>然后再告诉他你需要的接口和功能，这部分暂时我就不贴了，反正类似。润起来没多大问题就当做 OK 了。</p>

<p>联调上还是我自己介入去一个个接的，因为两个独立项目之间如果要互通的话我得把字段啥的都讲一遍好麻烦，相当于出了接口文档，不过我贴了一些 model 设计让他帮我转成 TypeScript 的 interface。顺便在联调期间测试（像极了倒霉的后端没自测的前端开发）。</p>

<p>前后端都接入完成后就是导入 DB 了，从服务器中导出现有数据毫无疑问是手工的，然后导入数据到本地，这样开发脚本期间可以测试。</p>

<blockquote>
<p>这是一个 Python3 项目，实现从 mysql 中一个 db 的数据映射到另一个 db 的功能，MySQL连接串，原始 DB 和目标 DB 都需要配置化首先请你先初始化项目。</p>
</blockquote>

<p>然后再把一张张表和映射关系告诉他，分析映射关系也是我自己做的，只是用文字告诉他。然后挨个表测试，确定转换无异常。然后本地就有了一个完整的新博客，再去进行一些真实数据下的验证测试。</p>

<p>因为这是一个需要线上投产并稳定运行的项目，因此在测试和细节修复上反而花了比较长的时间。（当然也包括了一些常见的安全性测试）。如果只是和大家在别的安利（营销）文章里看到的那种普通 Demo 的话，我可以说整站花三四个小时就能搞定了。</p>

<p>另外还有一点是之前的简历站点不可用了，因为之前是托管在开源服务内的，但是这个服务已经没人维护了，域名也没续，所以这次也重新做了一版。我本来觉得他的布局和配色都不错，也就相当于我有一个设计稿了，接下来让他帮我还原。</p>

<p>这一点刚开始我尝试把原始的 HTML 直接丢给他，让他给我还原出一个易用的 yaml 版本，后来发现效果并不好，因此换成了一段段截图发给他来还原（相当于帮他拆出了一个个的组件），效果相比原来好了很多。当然我对样式没有什么严格要求，八分像就行了，如果你有严格要求的话可以直接把设计稿里的样式贴给它。</p>

<p>最终的成品就如同你们所看到的：</p>

<ul>
<li><a href="https://www.codesky.me/" target="_blank">https://www.codesky.me/</a></li>
<li><a href="https://resume.codesky.me/" target="_blank">https://resume.codesky.me/</a></li>
</ul>

<p>不过我没有仔细的进行 Code Review，如果期间发现了 Bug 也欢迎大家留言。</p>

<p>后续也在考虑要不要把生活博客也重新启用一下！好多年没写日记了！</p>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Sat, 08 Feb 2025 13:54:07 +0800</pubDate>
    </item>
    <item>
      <title>群晖 Gitea + Gitea Actions 安装与部署服务教程</title>
      <link>https://www.codesky.me/archives/syno-gitea-action-runner-deploy.wind</link>
      <description>&lt;p&gt;每当公有云类服务出现问题的时候，我都会庆幸还好我一个 NAS 走天下：&lt;/p&gt;  &lt;ul&gt; &lt;li&gt;百度网盘和&lt;a href=&#34;https://www.donews.com/article/detail/5138/75631.html&#34; target=&#34;...</description>
      <content:encoded><![CDATA[<p>每当公有云类服务出现问题的时候，我都会庆幸还好我一个 NAS 走天下：</p>

<ul>
<li>百度网盘和<a href="https://www.donews.com/article/detail/5138/75631.html" target="_blank">阿里云盘</a>出了问题：没事，我有 NAS</li>
<li>视频被和谐：没事，我有 NAS</li>
<li><a href="https://36kr.com/p/3074244292489858" target="_blank">Gitlab 恐吓免费用户</a>：没事，我有 NAS</li>
<li>服务器快照收费：没事，我有 NAS(<a href="https://www.codesky.me/archives/syno-active-backup-for-business.wind" target="_blank">群晖通过 Active Backup for Business 实现服务器快照</a>)</li>
</ul>

<p>说（传销）了这么多，让我们回到本文的主题，关于怎么部署 Gitea 以及 Gitea Action，CI CD 到自己的服务器。</p>

<blockquote>
<p>读前指南：请不要问「为什么不用 GitHub 这种问题，前面的 Case 已经说明。</p>
</blockquote>

<!--more-->

<h2 id="gitea-部署">Gitea 部署</h2>

<p>Gitea 部署起来其实比较容易，你可以直接选择 Docker 安装，参考：<a href="https://docs.gitea.com/zh-cn/installation/install-with-docker" target="_blank">使用 Docker 安装</a>。</p>

<p>如果你不需要使用 Gitea Action，那么完全可以用界面化的创建容器来进行：</p>

<p><img src="https://img.codesky.me/blog_static/2025/01/Pasted_image_20250129003421_fTORvINa.png" alt="Pasted image 20250129003421.png" /></p>

<p>此时你可能最低程度的需要填写下面的信息：</p>

<ul>
<li>环境变量

<ul>
<li>USER_UID</li>
<li>USER_GID</li>
</ul></li>
<li>目录映射：

<ul>
<li>/data</li>
</ul></li>
</ul>

<p>默认启在 3000 端口，SSH 为 22 端口，你可以进行自定义或者针对性的进行映射。</p>

<p>当然，实践发现如果你不用 SSH 的话其实 USER_UID 和 USER_GID 也用不到。</p>

<p>USER_UID 和 USER_GID 的获取方式是，先进入群晖的 ssh，然后执行以下命令：</p>

<pre><code class="language-shell">synouser --add git &quot;passwd&quot; &quot;&quot; 0 &quot;&quot; 0
id git

# output
# uid=1030(git) gid=100(users) groups=100(users)
</code></pre>

<p>然后根据输出填上 uid 和 gid。也可以通过系统的用户创建，但此时 git 用户名是被保留的，只能选择创建别的名字后改名或者使用创建的名字，依然要到 SSH 里才能看到 uid 和 gid。</p>

<p>当然，我还自定义了端口号啥的，可参考配置。（之所以用 yaml 是因为之后 Gitea Action Runner 要保证运行的网络环境，所以用同一个 docker compose 串联）</p>

<pre><code class="language-yaml">version: &quot;3&quot;
networks:
  mynet:
    external: false

services:
  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    environment:
      - PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
      - USER=git
      - GITEA_CUSTOM=/data/gitea
      - USER_UID=1030
      - USER_GID=100
      - HTTP_PORT=6688
      - DISABLE_SSH=false
    restart: always
    networks:
      - mynet
    volumes:
      - /volume1/homes/git/.ssh:/data/git/.ssh
      - /etc/localtime:/etc/localtime:ro
      - /volume1/docker/gitea:/data
    ports:
      - &quot;6688:6688&quot;
      - &quot;2234:22&quot;
</code></pre>

<p>运行后访问你映射的 HTTP 端口（默认为 3000），会有安装界面，如果你没有选择任何 DB，那么默认使用 SQL Lite 初始化就可以了，它会把配置存在 <code>/data/conf/app.ini</code>中，之后如果你需要修改配置是没有后台界面的，需要改 <code>app.ini</code>（比如显示的端口号，DB 设置，log 设置，邮箱设置等等）。</p>

<blockquote>
<p>注意：<code>app.ini</code> 如果发现无权编辑，可以走 SSH <code>sudo -u git vim /volume1/docker/gitea/gitea/conf/app.ini</code> 来编辑。</p>

<h3 id="坑点">坑点</h3>
</blockquote>

<p>有一个坑点是我使用群晖界面提供的镜像升级按钮把镜像从 1.22.6 升级到 1.23.1，然后直接再起不能了，因为 1.23 启动命令路径改了，建议不要随便升级，或者升级时做好一定的准备（指留出 debug 时间），毕竟存储来说稳定性最重要。</p>

<p>还好主要内容都是存在了挂载的目录下的，所以你重启一下就能恢复了。</p>

<h2 id="gitea-action-runner-部署">Gitea Action Runner 部署</h2>

<p>目前你有了一个能使用 HTTP / SSH 的 Git 托管服务，接下来我们介绍如何部署 Gitea Action。</p>

<p>首先，你可以阅读官方对于 Gitea Action 的介绍：<a href="https://docs.gitea.com/zh-cn/usage/actions/overview" target="_blank">https://docs.gitea.com/zh-cn/usage/actions/overview</a></p>

<p>如果你用过 GitHub Action，相信对于它已经不陌生了，基本上可以看做是兼容的，就连包都是兼容的（否则生态都建立不起来）。</p>

<p>要使用 Gitea Action，我们需要先部署 Runner：<a href="https://docs.gitea.com/zh-cn/usage/actions/act-runner" target="_blank">https://docs.gitea.com/zh-cn/usage/actions/act-runner</a></p>

<p>刚开始的时候我也很普通的常识根据教程启容器，但我发现启完了虽然 Gitea 中虽然成功显示了 Runner，但触发任务并不会调度到 Runner 上，问了 DeepSeek 老师半天，最终还是 Google 解决了：<a href="https://gitea.com/gitea/act_runner/issues/389" target="_blank">action container that gets triggered by act_runner can&rsquo;t communicate with gitea (docker in NAS)</a>，场景和问题是一模一样，得亏我们现在用的是 7 了，在 6 中 docker compose 是不外露的，你甚至要 SSH 上机器手动挡，现在你只要负责写文件就可以了。</p>

<p>这就是为什么我前面准备了一个 Docker Compose Yaml 的原因。在底部加上一个：</p>

<pre><code class="language-yaml">  runner:
    image: gitea/act_runner:nightly # nightly 版本比 latest 新很多
    container_name: localrunner
    restart: always
    environment:
      CONFIG_FILE: /config.yaml
      GITEA_INSTANCE_URL: http://[gitea_site]/
      GITEA_RUNNER_REGISTRATION_TOKEN: &quot;your token&quot;
      GITEA_RUNNER_NAME: &quot;localrunner&quot;
    networks:
      - mynet
    volumes:
      - /volume1/docker/gitea-act-runner/config.yaml:/config.yaml
      - /volume1/docker/gitea-act-runner:/data
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      - gitea
    ports:
      - &quot;9432:9432&quot;
</code></pre>

<p>这里需要注意：</p>

<ol>
<li>INSTANCE_URL：填写你的 gitea URL</li>
<li>REGISTRATION_TOKEN：<code>http://[gitea_site]/-/admin/actions/runners</code>，创建 Runner 中你会得到一个 Token，填入</li>
<li>RUNNER_NAME：标识名</li>
<li>networks：和 Gitea 同网络，我这里叫 <code>mynet</code></li>
<li>volumes 配置：

<ol>
<li>自定义的 config.yaml 用于配置，默认有配置，但建议还是搞一个，毕竟有了配置更灵活。</li>
<li>Gitea Runner 配置映射到 /data</li>
<li>docker.sock 用于调度，非常重要</li>
</ol></li>
<li>端口号映射之后在介绍缓存时说明</li>
</ol>

<p>自定义的 config.yaml 初始化时可能没有，可以关联后进容器执行命令：<code>./act_runner generate-config</code></p>

<p>然后启动，我们就能看到 Runner 注册成功了。</p>

<p>此外，在配置文件里，我们会看到有个属性叫做 label，他决定了 runner 的 label 和关联的镜像，推荐先把涉及到的镜像都准备好，这里我准备了下面几种：</p>

<pre><code class="language-yaml">  labels:
    - &quot;ubuntu-latest:docker://gitea/runner-images:ubuntu-latest&quot;
    - &quot;ubuntu-20.04:docker://gitea/runner-images:ubuntu-20.04&quot;
    - &quot;ubuntu-raw-latest:docker://ubuntu:latest&quot;
</code></pre>

<p>修改后重启容器就可以了。</p>

<h3 id="缓存配置">缓存配置</h3>

<p>使用 <code>actions/cache</code>这个 action 包可以让我们在诸如 npm install 阶段进行一些缓存处理，可以有效提升 Action 的效率，但如果你需要使用，得先进行配置，在官方文档中已有说明，可以修改 <code>config.yaml</code>：</p>

<pre><code class="language-yaml">cache:  
enabled: true  
dir: &quot;&quot;  # 这里 cache 存放目录，我已经提前映射了 /data，所以可以直接放在比如 /data/cache 下
# 使用步骤 1. 获取的 LAN IP  
host: &quot;192.168.8.17&quot;  
# 使用步骤 2. 获取的端口号  
port: 9432
</code></pre>

<p>因此在上面我暴露了 9432 端口，就是用来给 cache 用的。</p>

<h2 id="action-配置">Action 配置</h2>

<p>有了 Action 后接下来我们就可以对 Action 进行测试并且正式投入使用，首先先试用一个测试的 Action，在某个仓库中新建一个文件 <code>.gitea/workflows/test.yml</code>：</p>

<pre><code class="language-yaml">name: Test Gitea Actions
on: [push]

jobs:
  test:
    name: Test Actions
    runs-on: ubuntu-20.04
    steps:
      - name: Check out repository
        uses: actions/checkout@v3
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '18'
      
      - name: Echo Test
        run: |
          echo &quot;Hello from Gitea Actions!&quot;
          echo &quot;Current directory: $PWD&quot;
          echo &quot;Node version: $(node -v)&quot;
          echo &quot;NPM version: $(npm -v)&quot;
      
      - name: List Files
        run: ls -la

</code></pre>

<p><code>runs-on</code>就是你设置的 <code>label</code>，这里我选了其中一个。</p>

<p>如果在后台（<code>/admin/actions/runners</code>）开始了任务调度并显示「激活」，那就说明任务在跑了，如果 Runner 看上去再跑，但实际管理后台显示的是空闲，可能就存在问题。（理论上跟着这个教程走不会出现）。</p>

<p>测试成功后让我们开始准备 CI/CD，相关的教程其实可以直接参考 GitHub Action，这里我贴一个自用款，因为我的部署流程都是从 SFTP 发文件、重启 pm2，而前端项目的复杂度最高，还用到了前面配置的 <code>action/cache</code>，其他项目部署比较简单，因此贴个我的前端的配置：</p>

<pre><code class="language-yaml">name: Deploy to Production
on:
  push:
    branches:
      - master  # 或者你的主分支名称

jobs:
  deploy:
    name: Build and Deploy
    runs-on: ubuntu-20.04
    steps:
      - name: Check out repository
        uses: actions/checkout@v3
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '18'
      
      - name: Setup PNPM
        uses: pnpm/action-setup@v2
        with:
          version: 9.15.1
          
      - name: Get pnpm store directory
        id: pnpm-cache
        run: |
          echo &quot;STORE_PATH=$(pnpm store path)&quot; &gt;&gt; $GITHUB_OUTPUT

      - name: Setup pnpm cache
        uses: actions/cache@v3
        with:
          path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-store-

      - name: Cache node_modules
        uses: actions/cache@v3
        with:
          path: node_modules
          key: ${{ runner.os }}-node-modules-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-node-modules-
      
      - name: Setup PNPM Registry
        run: pnpm config set registry https://registry.npmmirror.com
      
      - name: Install Dependencies
        run: pnpm install
      
      - name: Build Application
        run: pnpm build
      
      - name: Deploy to Server
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.SSH_IP }}
          username: ${{ secrets.SSH_USERNAME }}
          password: ${{ secrets.SSH_PASSWORD }}
          port: ${{ secrets.SSH_PORT }}
          source: &quot;.output/,.env&quot;
          target: &quot;/root/target_path&quot;
          strip_components: 1
      
      - name: Execute PM2 reload
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.SSH_IP }}
          username: ${{ secrets.SSH_USERNAME }}
          password: ${{ secrets.SSH_PASSWORD }}
          port: ${{ secrets.SSH_PORT }}
          script: |
            export NVM_DIR=&quot;$HOME/.nvm&quot;
            [ -s &quot;$NVM_DIR/nvm.sh&quot; ] &amp;&amp; \. &quot;$NVM_DIR/nvm.sh&quot;
            pm2 reload project
</code></pre>

<p>用了一下还是 <code>appleboy/scp-action@v0.1.7</code>这个库最简单粗暴，非常方便。</p>

<h2 id="总结">总结</h2>

<p>又还了一个债，群晖玩家真是幸福啊，什么都能玩（我也有考虑过把监控回报，然后搭个 Grafana 啥的，毕竟服务太小了）。</p>

<p>如果使用遇到了任何问题，可以尝试查看官方文档或者查查 issue，DS 或者 GPT 老师还真不一定好使。</p>

<h2 id="参考资料">参考资料</h2>

<ul>
<li><a href="https://doc.itjl.top:91/doc/191/" target="_blank">搞定群晖Docker部署gitea启用ssh协议</a></li>
</ul>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Wed, 29 Jan 2025 01:42:55 +0800</pubDate>
    </item>
    <item>
      <title>Python 音频去广告+字幕提取</title>
      <link>https://www.codesky.me/archives/python-audio-remove-ads-and-get-texts.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;积压稿件 +1&lt;/p&gt;  &lt;p&gt;生存提示：不鼓励使用盗版资源，因此也不提供盗版资源，仅供学习交流。&lt;/p&gt; &lt;/blockquote&gt;  &lt;p&gt;之前下了一些音频课，但是��...</description>
      <content:encoded><![CDATA[<blockquote>
<p>积压稿件 +1</p>

<p>生存提示：不鼓励使用盗版资源，因此也不提供盗版资源，仅供学习交流。</p>
</blockquote>

<p>之前下了一些音频课，但是存在一些音频中间插入广告，更万恶的是，它根本不分是不是整句，只要时间差不多了就插入。</p>

<p>要去掉广告我们分为以下步骤依次执行：</p>

<ol>
<li>分析规律（就是前面找规律）</li>
<li>广告提取</li>
<li>识别广告</li>
<li>重新拼接</li>
</ol>

<p>对于字幕提取，之前其实我们在 AI 相关的文章中也介绍过对应模型，直接转换并处理就可以了，后面再介绍。
<!--more--></p>

<h2 id="分析规律">分析规律</h2>

<p>和写爬虫一样，第一点就是要找规律：用一张草稿纸记录每个广告的起始时间和结束时间，再分析它和整段音频的关系。</p>

<p>遗憾的是，在插入时或许是为了避免裁剪，逐秒计算（也叫做）后，我得出了一个结论：它是在固定时间（end_time - 3min) + random_offset 值，因为了 offset 值的介入，整个就变的玄学了起来。</p>

<p>还好很快我又有了一些新的想法：利用一些识别的手段把广告词裁掉就可以了。</p>

<p>还好广告词是固定的，而要处理的音频却多，这样计算下来 ROI 还是划算的。</p>

<h2 id="广告提取">广告提取</h2>

<p>这一步是所有步骤里最耗费时间的，对于整句来说，切割分离是一个高敏感性的操作，稍微多留白几百毫秒，你听起来可能就很难受。只有原始数据切割的恰到好处，才能达到完美还原。</p>

<p>因此我们需要更精细化，精细到毫秒的裁剪手段。</p>

<p>Windows 下也不知道用啥，搜了下就选了 Audacity：</p>

<p><img src="https://img.codesky.me/blog_static/2025/01/Pasted_image_20250128000126_u8bjcYRu.png" alt="Pasted image 20250128000126.png" /></p>

<p>以毫秒控制选区，然后切割后如果听感是无缝的，那么就相当于抽离了，如果觉得怪怪的就再调整毫秒重新裁，如此反复直到无缝衔接。</p>

<h2 id="依赖列表">依赖列表</h2>

<p>下文完整的 <code>import</code>依赖（因为懒得在文末贴完整代码了）：</p>

<pre><code class="language-python">import glob
import os
from concurrent.futures import ThreadPoolExecutor, as_completed

import numpy as np
import librosa
import torch
import whisper
from pydub import AudioSegment
import soundfile as sf
import torch.nn.functional as F
import shutil
</code></pre>

<h2 id="识别广告">识别广告</h2>

<p>接下来我们得到了两个片段，一个是完整版的音频，另一个是纯广告音频，将对应波形的相似度进行比对，找到相似的段，再进行切割。</p>

<p>当然，由于整段二三十分钟，相对的来说计算量会很大，由于我们知道了总是在一个音频快结束了插入广告，因此可以先裁剪缩小对比规模，然后再进行比对，减少计算量。</p>

<p>其中有一些非常抽象的音频和数学知识，只能说谢谢 GPT 老师（我也没学会）</p>

<pre><code class="language-python"># 已知的广告片段文件
AD_SNIPPET_FILE  = &quot;./testcase/test2.wav&quot;

# 待处理的音频文件目录
audio_dir = &quot;./testcase&quot;
TAIL_SECONDS = 300  # 只截取最后5分钟处理
SIMILARITY_THRESHOLD = 0.8  # 相似度阈值(0~1之间, 需根据实际情况调整)
SUCCESS_DIR = &quot;./success&quot;
device = torch.device(&quot;cuda&quot; if torch.cuda.is_available() else &quot;cpu&quot;)

def load_audio_segment(file_path, sr=16000, tail_seconds=None):
    info = sf.info(file_path)
    total_duration = info.duration
    if tail_seconds is not None and tail_seconds &lt; total_duration:
        start_time = total_duration - tail_seconds
        audio, _ = librosa.load(file_path, sr=sr, mono=True, offset=start_time, duration=tail_seconds)
        return audio, start_time

    else:
        audio, _ = librosa.load(file_path, sr=sr, mono=True)
        return audio, 0.0

def find_audio_snippet(main_audio_path, snippet_audio, snippet_norm, sr=16000, tail_seconds=300):
    &quot;&quot;&quot;
    在 main_audio_path 中寻找 snippet_audio 音频片段的出现位置。
    snippet_audio 为事先加载好的 numpy 数组，snippet_norm 为 snippet 的二范数，用于相似度计算。
    返回 (ad_start_time, ad_end_time, similarity)
    若未找到则返回 (None, None, None)
    &quot;&quot;&quot;
    main_audio, main_start = load_audio_segment(main_audio_path, sr=sr, tail_seconds=tail_seconds)
    if len(snippet_audio) &gt; len(main_audio):
        return None, None, None
    # 转换到 GPU 张量
    main_audio_t = torch.from_numpy(main_audio).float().to(device).unsqueeze(0).unsqueeze(0)  # [1,1,M]
    snippet_audio_t = torch.from_numpy(snippet_audio).float().to(device).unsqueeze(0).unsqueeze(0)  # [1,1,S]
    # 使用 conv1d 来进行类似相似度搜索 (无 snippet 翻转)
    correlation = F.conv1d(main_audio_t, snippet_audio_t)
    correlation = correlation[0, 0].cpu().numpy()
    best_index = np.argmax(correlation)
    best_value = correlation[best_index]
    # 相似度计算：归一化
    similarity = best_value / snippet_norm if snippet_norm &gt; 0 else 0
    ad_start_time = main_start + best_index / sr
    ad_end_time = ad_start_time + len(snippet_audio) / s

    return ad_start_time, ad_end_time, similarity
</code></pre>

<h2 id="重新拼接">重新拼接</h2>

<p>找到广告后我们将广告段落减去，然后再重新拼接生成新的音频文件即可。</p>

<pre><code class="language-python">
def process_file(filename, snippet_audio, snippet_norm, sr=16000, tail_seconds=300, similarity_threshold=0.8):
    &quot;&quot;&quot;
    处理单个文件，找到广告并移除。
    &quot;&quot;&quot;
    ad_start, ad_end, similarity = find_audio_snippet(filename, snippet_audio, snippet_norm, sr=sr,
                                                      tail_seconds=tail_seconds)

    if ad_start is not None and similarity &gt; similarity_threshold:
        # 去除广告段落
        audio = AudioSegment.from_file(filename)
        part1 = audio[:ad_start * 1000]
        part2 = audio[ad_end * 1000:]
        cleaned = part1 + part2
        cleaned_file = f&quot;output/{os.path.basename(filename)}&quot;
        cleaned.export(cleaned_file, format=&quot;mp3&quot;)
        shutil.move(filename, SUCCESS_DIR)
        return f&quot;{filename} 已移除广告，生成 {cleaned_file}，相似度：{similarity}&quot;
    else:
        return f&quot;{filename} 中未高相似度检测到广告或相似度过低（{similarity}）&quot;


def remove_ads():
    sr = 16000
    # 预先加载广告片段
    snippet_audio, _ = librosa.load(AD_SNIPPET_FILE , sr=sr, mono=True)
    # 计算snippet的范数，用于相似度归一化
    snippet_norm = np.dot(snippet_audio, snippet_audio)

    file_list = [os.path.join(audio_dir, f) for f in os.listdir(audio_dir) if
                 f.endswith(&quot;.mp3&quot;)]

    # 使用多线程加速处理
    # 线程数可根据您的机器资源调整
    max_workers = 20
    results = []
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {
            executor.submit(process_file, file, snippet_audio, snippet_norm, sr, TAIL_SECONDS,
                            SIMILARITY_THRESHOLD): file
            for file in file_list
        }

        for future in as_completed(futures):
            file = futures[future]
            try:
                res = future.result()
                print(res)
            except Exception as e:
                print(f&quot;{file} 处理时出错: {e}&quot;)
</code></pre>

<h2 id="字幕提取">字幕提取</h2>

<p>下一个问题是，音频是提取好了，但是音频的字幕和总结能力其实也是一个亮点，这个也是我们想要有的能力，而好多都是付费的，百度网盘虽然会员免费，但是实际听音频的过程中遇到了 Bug，让我不得不另谋高就。</p>

<p>要使用这个能力，核心还是使用 <a href="https://github.com/openai/whisper" target="_blank">whisper</a>这个模型的能力。</p>

<p>我考虑用 Emby 来当音频播放器，字幕可以和歌词字幕一样，因此就需要生成 lrc 格式的标准文件。</p>

<p>也就是：</p>

<ol>
<li>提取字幕</li>
<li>给每段字幕和时间轴进行格式转换，转为 lrc 标准格式</li>
</ol>

<p>而跑 AI 模型的时候，务必保证 GPU 加速（否则你会卡的痛不欲生）。</p>

<p>模型请根据自己的内存和实际情况决定，不一定是越大越好的，可以先跑一段音频试试效果。</p>

<p>如果本地没有找到对应的模型，whisper 先尝试下载，也可以使用本地准备好的模型。</p>

<pre><code class="language-python">def format_lrc_timestamp(seconds: float) -&gt; str:
    &quot;&quot;&quot;将秒数转换为 LRC 格式时间戳 [mm:ss.xx]&quot;&quot;&quot;
    total_seconds = int(seconds)
    m = total_seconds // 60
    s = total_seconds % 60
    # 毫秒取两位小数
    ms = (seconds - total_seconds) * 100
    return f&quot;[{m:02d}:{s:02d}.{int(ms):02d}]&quot;

def trans_files():
    # 请将此路径替换为你的音频目录路径
    audio_directory = &quot;./audio_files&quot;
    # 可根据需要选择模型大小，如 &quot;small&quot;、&quot;medium&quot;、&quot;large&quot;
    trans_text(audio_directory, model_name=&quot;medium&quot;, language=&quot;zh&quot;)

def transcribe_to_lrc(audio_path: str, lrc_path: str, model, language: str = &quot;zh&quot;):
    &quot;&quot;&quot;
    使用已加载的 whisper model 对 audio_path 进行转录，
    并将结果保存为 lrc_path 文件。
    &quot;&quot;&quot;
    result = model.transcribe(audio_path, language=language)
    segments = result.get(&quot;segments&quot;, [])

    with open(lrc_path, &quot;w&quot;, encoding=&quot;utf-8&quot;) as f:
        # 可根据需要添加标签信息，如标题、歌手、专辑
        f.write(&quot;[ti:未知标题]\n&quot;)
        f.write(&quot;[ar:未知作者]\n&quot;)
        f.write(&quot;[al:未知专辑]\n\n&quot;)

        for seg in segments:
            start_time = format_lrc_timestamp(seg['start'])
            text = seg['text'].strip()
            f.write(f&quot;{start_time}{text}\n&quot;)


def trans_text(audio_dir: str, model_name: str = &quot;medium&quot;, language: str = &quot;zh&quot;):
    # 尝试使用 GPU
    device = &quot;cuda:0&quot; if torch.cuda.is_available() else &quot;cpu&quot;
    print(f&quot;使用设备: {device}&quot;)

    # 加载模型到指定设备
    # 模型大小可根据资源调整，如：tiny, base, small, medium, large
    print(f&quot;加载 Whisper 模型 ({model_name})，请稍候...&quot;)
    model = whisper.load_model(model_name, device=device)
    print(&quot;模型加载完成。&quot;)

    # 遍历指定目录下所有 mp3
    audio_files = glob.glob(os.path.join(audio_dir, &quot;*.mp3&quot;))
    if not audio_files:
        print(&quot;指定目录中未找到 MP3 文件。&quot;)
        return

    for audio_path in audio_files:
        base_name = os.path.splitext(audio_path)[0]
        lrc_path = base_name + &quot;.lrc&quot;

        print(f&quot;处理文件: {audio_path} -&gt; {lrc_path}&quot;)
        transcribe_to_lrc(audio_path, lrc_path, model=model, language=language)
        print(f&quot;完成: {lrc_path}&quot;)

    print(&quot;所有文件处理完成！&quot;)

def trans_files():
    # 请将此路径替换为你的音频目录路径
    audio_directory = &quot;./audio_files&quot;
    # 可根据需要选择模型大小，如 &quot;small&quot;、&quot;medium&quot;、&quot;large&quot;
    trans_text(audio_directory, model_name=&quot;medium&quot;, language=&quot;zh&quot;)
</code></pre>

<p>目前我还没有做总结功能（主要是普通播放器也没地方显示总结），但是有了全量文本，相信对于各位来说并不是难事。</p>

<h2 id="总结">总结</h2>

<p>本文的所有代码均由 AI 编写，可以说过去让它写的代码更多的是提效用，我姑且还算一知半解，但是涉及到音频和数学知识的本功能我是真的一无所知，但它却能帮我做出一个非常完美的效果，真的是科技改变生活了。</p>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Tue, 28 Jan 2025 15:55:11 +0800</pubDate>
    </item>
    <item>
      <title>群晖通过 Active Backup for Business 实现服务器快照</title>
      <link>https://www.codesky.me/archives/syno-active-backup-for-business.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;拖欠稿子 +1……为了重新写这篇文章我还重新踩了一遍坑，我真的哭死。&lt;/p&gt; &lt;/blockquote&gt;  &lt;p&gt;虽然现在阿里云和腾讯云的服务器都有学生套餐��...</description>
      <content:encoded><![CDATA[<blockquote>
<p>拖欠稿子 +1……为了重新写这篇文章我还重新踩了一遍坑，我真的哭死。</p>
</blockquote>

<p>虽然现在阿里云和腾讯云的服务器都有学生套餐、开发者套餐，包括我目前部署的服务器就是 2C2G 99 一年。（这个在<a href="https://www.codesky.me/archives/tecent-cloud-to-aliyun.wind" target="_blank">大型迁移现场：腾讯云 to 阿里云大冒险</a>已有描述）。</p>

<p>但是问题是我们这种纯玩派，一方面在上面部署了稳定的服务，另一方面靠着辣鸡的运维水平，每次部署和迭代都有可能原地 GG，而平均每年折腾一次，每次运维水平一键归零让折腾成本雪上加霜。</p>

<p>因此快照服务作为一个无论是服务还是数据的兜底手段都有一定必要性，但是阿里云和腾讯云的快照服务又都是另外的价钱。</p>

<p>作为尊贵的白群晖玩家，我成功的找到了平替！</p>

<blockquote>
<p>使用前保证：群晖服务可以在外网访问（否则可以不用看了，或者用内网穿透绕一圈或许也可以）</p>
</blockquote>

<!--more-->

<h2 id="安装">安装</h2>

<p>在群晖套件中心先安装 Active Backup for Business。然后点开应用，选择「物理服务器」-「Linux」-「添加设备」。</p>

<p><img src="https://img.codesky.me/blog_static/2025/01/Pasted_image_20250127212921_UoYWYkJA.png" alt="Pasted image 20250127212921.png" /></p>

<p>按照图中的链接下载对应的安装包。</p>

<p>然后再对应的服务器中运行：<code>sudo ./install.run</code></p>

<h2 id="配置">配置</h2>

<p>安装完成后物理服务器运行：<code>abb-cli -c</code>进行配置。</p>

<p>配置前需要注意，链接时需要用到 abb 对应的端口号，请检查 <code>5510</code>端口是否暴露在外网，如果未暴露则连接不上。参考：<a href="https://kb.synology.cn/zh-cn/DSM/tutorial/What_network_ports_are_used_by_Synology_services" target="_blank">https://kb.synology.cn/zh-cn/DSM/tutorial/What_network_ports_are_used_by_Synology_services</a></p>

<p>同时需要注意，链接时还会用到 HTTPS 证书，请注意开启群晖的 HTTPS（PORT 可自定义）。</p>

<p>暴露完这两个端口之后就可以进行连接了，一共需要输入三个配置：</p>

<ol>
<li>server_name: 域名，不包含端口号</li>
<li>admin username: 有管理权限的用户名</li>
<li>password: 密码</li>
</ol>

<p>如果你设置了二步验证，还会要求二步验证码。</p>

<p>HTTPS 如果证书不对，会提示，忽略就可以。</p>

<p>等连接成功后，你就可以在群晖后台看到这个服务器了。</p>

<p>在任务列表中，你可以查看和配置任务：</p>

<p><img src="https://img.codesky.me/blog_static/2025/01/Pasted_image_20250127213806_afyg8RBl.png" alt="Pasted image 20250127213806.png" /></p>

<p>建议：</p>

<ol>
<li>定时备份时间在访问谷时，因为对于小机器来说有点吃配置</li>
<li>启用保留策略，避免跑一年备份全在里面</li>
</ol>

<h2 id="查看">查看</h2>

<p>在群晖安装完 Active Backup for Business 后，列表会新增另一个应用：Active Backup Portal，你可以在这里看到所有的文件。（类似于 Mac 的 Time Machine）</p>

<p><img src="https://img.codesky.me/blog_static/2025/01/Pasted_image_20250127214056_uMsR4fq4.png" alt="Pasted image 20250127214056.png" /></p>

<h2 id="坑点">坑点</h2>

<ol>
<li>没事别升级 Agent 版本，今天早上刚踩了个坑，直接把 CPU 和内存都打满了，然后只能强制重启，Like：<a href="https://www.reddit.com/r/synology/comments/15eeevb/active_backup_for_business_mount_errors/" target="_blank">https://www.reddit.com/r/synology/comments/15eeevb/active_backup_for_business_mount_errors/</a></li>
<li>2C2G 的机器，abb 会用掉 25% 的内存（也就是为了优化这个问题才升级的）</li>
<li>（还原功能其实我还没用过，不知道会不会发生 1）</li>
</ol>

<p>如果出现了任何异常，官方提供的卸载方式：</p>

<pre><code>Type &quot;dpkg -r synology-active-backup-business-linux-service&quot; to uninstall the backup service.
Type &quot;dpkg -r synosnap&quot; to uninstall the driver.
</code></pre>

<h2 id="总结">总结</h2>

<p>总的来说部署到现在快一年了没感知到什么问题，不愧是群晖。</p>

<h2 id="参考">参考</h2>

<ul>
<li><a href="https://kb.synology.cn/zh-cn/DSM/tutorial/Quick_Start_Active_Backup_for_Business" target="_blank">Active Backup for Business快速入门指南</a></li>
</ul>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Mon, 27 Jan 2025 21:48:13 +0800</pubDate>
    </item>
    <item>
      <title>Windows WSL 启用 NVIDIA CUDA 配置教程</title>
      <link>https://www.codesky.me/archives/windows-wsl-nvidia-cuda.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;拖稿时间太长，只能努力还原……QvQ，因为如你所见的近期主要功夫都在重写博客系统和简历页面上了，当然也包括在 NAS 上部署的 gitea + ac...</description>
      <content:encoded><![CDATA[<blockquote>
<p>拖稿时间太长，只能努力还原……QvQ，因为如你所见的近期主要功夫都在重写博客系统和简历页面上了，当然也包括在 NAS 上部署的 gitea + action 的配置之类的，因此后面可以更新的文章还是挺多的</p>
</blockquote>

<p>之前我也写过两篇 AI 相关的文章，不过因为不涉及到开发，只涉及到使用，所以我还是用 Windows 宿主机开发的，这次由于涉及到了开发，所以就得折腾一下 WSL GPU 加速的方案。</p>

<h2 id="安装">安装</h2>

<!--more-->

<p>首先，先保证宿主机已经安装了驱动，也就是<a href="https://www.nvidia.com/Download/index.aspx" target="_blank">https://www.nvidia.com/Download/index.aspx</a>中获得下载地址。</p>

<p>然后需要在 WSL 中安装 cuda toolkit，下载地址：<a href="https://developer.nvidia.com/cuda-downloads?target_os=Linux&amp;target_arch=x86_64&amp;Distribution=WSL-Ubuntu&amp;target_version=2.0&amp;target_type=deb_network" target="_blank">https://developer.nvidia.com/cuda-downloads?target_os=Linux&amp;target_arch=x86_64&amp;Distribution=WSL-Ubuntu&amp;target_version=2.0&amp;target_type=deb_network</a>，并按照官方提供的命令依次安装，比如通过 Network 安装的命令为：</p>

<pre><code class="language-sh">wget https://developer.download.nvidia.com/compute/cuda/repos/wsl-ubuntu/x86_64/cuda-keyring_1.1-1_all.deb
sudo dpkg -i cuda-keyring_1.1-1_all.deb
sudo apt-get update
sudo apt-get -y install cuda-toolkit-12-8
</code></pre>

<p>安装完成后，你使用两个命令：</p>

<pre><code class="language-sh">nvidia-smi
nvcc --version
</code></pre>

<p>如果上面两个命令都没问题且能输出你的显卡，那在安装商我们就初步获得了成功。</p>

<p><img src="https://img.codesky.me/blog_static/2025/01/Pasted_image_20250127142716_LB6aYSIJ.png" alt="Pasted image 20250127142716.png" /></p>

<p>如果提示<code>command not found</code>，建议确认一下 PATH 配置，加上 <code>/usr/bin/cuda/bin</code>。</p>

<h2 id="使用与测试">使用与测试</h2>

<p>下一步，我们要测试在 Python 中是否能够正常使用 GPU，这是一段测试代码：</p>

<pre><code class="language-python">import torch

def check_gpu():
    # 检查 CUDA 是否可用
    cuda_available = torch.cuda.is_available()
    print(f&quot;CUDA Available: {cuda_available}&quot;)

    if cuda_available:
        # 获取 GPU 设备数量
        device_count = torch.cuda.device_count()
        print(f&quot;Number of CUDA Devices: {device_count}&quot;)

        # 遍历所有 GPU 设备
        for i in range(device_count):
            print(f&quot;\nDevice {i}:&quot;)
            # 获取设备名称
            print(f&quot;  Name: {torch.cuda.get_device_name(i)}&quot;)
            # 获取显存信息
            print(f&quot;  Total Memory: {torch.cuda.get_device_properties(i).total_memory / 1024**3:.2f} GB&quot;)
            # 当前显存使用情况
            print(f&quot;  Memory Allocated: {torch.cuda.memory_allocated(i) / 1024**3:.2f} GB&quot;)
            print(f&quot;  Memory Reserved: {torch.cuda.memory_reserved(i) / 1024**3:.2f} GB&quot;)

        # 简单计算测试（将张量移动到 GPU 并执行操作）
        try:
            device = torch.device(&quot;cuda:0&quot;)
            x = torch.randn(1000, 1000).to(device)
            y = torch.randn(1000, 1000).to(device)
            z = torch.matmul(x, y)
            print(&quot;\nGPU 计算测试成功！&quot;)
            print(f&quot;结果张量形状: {z.shape}&quot;)
        except Exception as e:
            print(f&quot;\nGPU 计算测试失败: {str(e)}&quot;)
    else:
        print(&quot;未检测到可用的 CUDA 设备，请检查以下可能原因：&quot;)
        print(&quot;1. 确保已在 Windows 中安装 NVIDIA 驱动程序&quot;)
        print(&quot;2. 确保 WSL 2 已启用 GPU 支持&quot;)
        print(&quot;3. 确保已安装 CUDA Toolkit&quot;)

if __name__ == &quot;__main__&quot;:
    check_gpu()
</code></pre>

<p>因此你需要先安装 <code>torch</code>，期间如果报 <code>llvmlite</code> 安装的错误，可能需要先安装：</p>

<pre><code class="language-sh">sudo apt-get update
sudo apt-get install llvm
</code></pre>

<p>然后再安装最新的 llvm 版本（我测试的时候出现了安装的 torch 和 llvmlite 版本不兼容的情况，可能需要视情况而定进行调整）：</p>

<pre><code class="language-sh">poetry add &quot;llvmlite&gt;=0.39.0&quot;
</code></pre>

<p>运行后如果输出显卡，证明识别成功了：</p>

<pre><code>CUDA Available: True
Number of CUDA Devices: 1

Device 0:
  Name: NVIDIA GeForce RTX 4070 Ti SUPER
  Total Memory: 15.99 GB
  Memory Allocated: 0.00 GB
  Memory Reserved: 0.00 GB

GPU 计算测试成功！
结果张量形状: torch.Size([1000, 1000])
</code></pre>

<p>当然，在实际项目中，我也遇到过死活就是用的是 CPU 的情况（这点可以通过 Windows 的资源管理器或者 <code>nvidia-smi</code> 查看正在使用的进程得出）。比如我之前使用 whisper 时死活不生效，就需要手动指明 device：</p>

<pre><code class="language-python">device = &quot;cuda:0&quot; if torch.cuda.is_available() else &quot;cpu&quot;
print(f&quot;使用设备: {device}&quot;)
# 加载模型到指定设备
model = whisper.load_model(model_name, device=device)
</code></pre>

<h2 id="总结">总结</h2>

<p>在实际操作中，安装过程可能会遇到各种依赖版本不兼容、配置错误等问题，像<code>llvmlite</code>版本与<code>torch</code>不匹配、命令找不到需要检查 PATH 配置等情况，都需要我们根据具体报错信息进行排查和解决。</p>

<p>总之，不能让显卡白买了。</p>

<h2 id="参考资料">参考资料</h2>

<ul>
<li><a href="https://learn.microsoft.com/zh-cn/windows/ai/directml/gpu-cuda-in-wsl" target="_blank">在 WSL 中启用 NVIDIA CUDA</a></li>
<li><a href="https://docs.nvidia.com/cuda/wsl-user-guide/index.html" target="_blank">CUDA on WSL User Guide</a></li>
</ul>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Mon, 27 Jan 2025 16:26:59 +0800</pubDate>
    </item>
    <item>
      <title>HTTP Auth From BasicAuth to WebAuthn</title>
      <link>https://www.codesky.me/archives/webauthn-intro.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;在 Web 开发中，身份认证是一个很常见的诉求，而平时设计中并没有好好研究整体的 Auth 体系，今天就从头开始研究一下 Auth 这个东西。&lt;/p&gt;...</description>
      <content:encoded><![CDATA[<blockquote>
<p>在 Web 开发中，身份认证是一个很常见的诉求，而平时设计中并没有好好研究整体的 Auth 体系，今天就从头开始研究一下 Auth 这个东西。</p>
</blockquote>

<p>MDN 对 HTTP Auth 有所总结：</p>

<blockquote>
<p><a href="https://datatracker.ietf.org/doc/html/rfc7235" target="_blank">RFC 7235</a> 定义了一个 HTTP 身份验证框架，服务器可以用来质询（<a href="https://developer.mozilla.org/zh-CN/docs/Glossary/Challenge" target="_blank">challenge</a>）客户端的请求，客户端则可以提供身份验证凭据。</p>

<p>质询与响应的工作流程如下：</p>

<ol>
<li>服务器端向客户端返回 <a href="https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status/401" target="_blank"><code>401</code></a>（Unauthorized，未被授权的）响应状态码，并在 <a href="https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/WWW-Authenticate" target="_blank"><code>WWW-Authenticate</code></a> 响应标头提供如何进行验证的信息，其中至少包含有一种质询方式。</li>
<li>之后，想要使用服务器对自己身份进行验证的客户端，可以通过包含凭据的 <a href="https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Authorization" target="_blank"><code>Authorization</code></a> 请求标头进行验证。</li>
<li>通常，客户端会向用户显示密码提示，然后发送包含正确的 <code>Authorization</code> 标头的请求。</li>
</ol>
</blockquote>

<p>而大部分 HTTP 验证都遵循这一流程，只是加密算法、验证实现可能有所区别。</p>

<!--more-->

<h2 id="basic-auth">Basic Auth</h2>

<p>下图就是一个 Basic Auth 的流程。</p>

<p><img src="https://img.codesky.me/blog_static/2024/12/Pasted_image_20240930215856_5whQ3ozE.png" alt="Pasted image 20240930215856.png" /></p>

<p>当然，对于 Basic Auth 来说，因为使用的是 base64，因此安全性很差。</p>

<p>我们可以这样新建一个 HTTP Server 来实现一个 basic auth：</p>

<pre><code class="language-go">func homeHandler(w http.ResponseWriter, r *http.Request) {
	u, p, _ := r.BasicAuth()
	fmt.Println(&quot;auth&quot;, r.Header.Get(&quot;Authorization&quot;), &quot;username=&quot;, u, &quot;password=&quot;, p)

	if u == &quot;admin&quot; &amp;&amp; p == &quot;admin&quot; {
		fmt.Fprintf(w, &quot;Welcome to the Home Page!&quot;)
		return
	}
	w.Header().Set(&quot;WWW-Authenticate&quot;, `Basic realm=&quot;helloworld&quot;`)
	w.WriteHeader(http.StatusUnauthorized)
}

func serve() {
	http.HandleFunc(&quot;/&quot;, homeHandler)
	fmt.Println(&quot;Starting server on :8080...&quot;)
	if err := http.ListenAndServe(&quot;:8080&quot;, nil); err != nil {
		fmt.Println(&quot;Error starting server:&quot;, err)
	}
}

</code></pre>

<p>打印日志中输出：</p>

<pre><code>auth Basic YWRtaW46YWRtaW4= username= admin password= admin
</code></pre>

<p>表示这确实是个 base64。</p>

<h2 id="webauthn">WebAuthn</h2>

<p>WebAuthn 是一种新的标准，它不再使用用户名+密码的形式，而是改用了生物识别或者实体秘钥来作为凭证。</p>

<p>WebAuthn 体验：<a href="https://webauthn.io/" target="_blank">https://webauthn.io/</a></p>

<p>实际上我们已经可以在不少网站中看到这样的效果了，有了这种认证方式，我们甚至可以解决《密码怎么存》、《怎么防止机器人登录》等问题。</p>

<p>当然，在实际场景中，如果没有一些跨设备的 webauth 存储能力，那就只能把它当做一种二次验证，而并非登录注册的场景。</p>

<p>不过可以说，这还是一种未来可期的方式。</p>

<h3 id="整体流程">整体流程</h3>

<p>首先，我们来看一下大致的使用流程：</p>

<ol>
<li>填入用户的基本信息，通常也就是 username</li>
<li>选择注册的情况下，服务器先暂存提交信息并且生成 Challenge 和 UserID，并返回给客户端</li>
<li>客户端收到后把数据发给验证器，由验证器验证并生成密钥对，将结果凭证返回给客户端</li>
<li>客户端将结果发给服务端</li>
<li>服务端核验信息，如果通过则存储对应的用户和公钥信息</li>
<li>而如果是登录的场景下，浏览器将 Challenge 和 UserID 发给验证器，验证器加密 Challenge 之后由客户端提交给服务端，服务端验证通过则登陆成功</li>
</ol>

<p>这里我们提到了几个名词，在这里一一说明：</p>

<ol>
<li>Challenge：一段由服务端生成的加密随机字符串。</li>
<li>UserID：用户身份的唯一标识符</li>
<li>验证器（Authenticator）：可以理解为「TouchID」「Windows Hello」「Pin」甚至是物理硬件等身份认证设备</li>
</ol>

<p>而客户端到验证器的交互是由浏览器本身来实现的，我们无需关心，只要调用浏览器的 API 就可以了。</p>

<p>我们需要实现的也就是客户端到服务端的交互，接下来会挨个的对注册和登录进行实现。在本例中，依然以 Golang 作为后端语言，而使用简单的 JS + HTML 作为前端实现。</p>

<p>本文注重核心代码的实现，而不是全部代码，因此不包括前端外围界面，输入框和提交，也不包括 DB 落库操作等。</p>

<h3 id="注册流程">注册流程</h3>

<p>注册流程步骤如下：</p>

<p><img src="https://img.codesky.me/blog_static/2024/12/WebAuthn_注册流程_9nYj09VL.png" alt="WebAuthn 注册流程.png" /></p>

<p>其中我们将 Session Storage 和持久化 Storage（比如存进 MySQL）都抽象成 Storage 来简化这张图。</p>

<p>接下来我们将根据这张图来依次实现，在实现之前，我们先准备好基础工具，也就是需要使用的工具库，有助于我们更快的了解整个流程，而不用实现其加解密和验证过程：</p>

<ol>
<li>前端：直接使用 <code>navigator.credentials.create</code> 和 <code>navigator.credentials.get</code>，相关文档参考：

<ul>
<li><a href="https://webauthn.guide/#registration" target="_blank">WebAuthn Guide</a></li>
<li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Authentication_API" target="_blank">Web Authentication API - MDN</a></li>
</ul></li>
<li>后端：

<ul>
<li><a href="https://gin-gonic.com/zh-cn/" target="_blank">Gin Web Framework</a></li>
<li><a href="https://gorm.io/" target="_blank">Gorm</a></li>
<li><a href="https://github.com/go-webauthn/webauthn" target="_blank">Go Webauthn</a>（注意：这个库基本上就保证兼容最近 2-3 个 Go 版本，目前支持 1.22 和 1.21）</li>
</ul></li>
</ol>

<h4 id="开始注册">开始注册</h4>

<p>从上图中，我们可以看出，一共发送给了服务端两次请求，因此可以定义两个接口，一个叫 <code>begin</code>、一个叫 <code>finish</code>，下文中你看到的所有带 <code>begin</code> 词缀的都代表第一次请求，而带 <code>finish</code>词缀的则是第二次请求。</p>

<p>首先，用户应该填表输入用户基本信息，这里我们定义用户必须输入信息 <code>username</code> 和 <code>nickname</code>进行注册：</p>

<pre><code class="language-javascript">const response = await fetch('/register/begin', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, nickname })
});
</code></pre>

<p>然后实现第三步和第四步的后端接口：生成 Challenge &amp; UserID 并暂存。</p>

<pre><code class="language-go">/// --- authn.go
var (
	authn *webauthn.WebAuthn
)

// 初始化 webauthn
func NewAuthn() {
	var err error
	wconfig := &amp;webauthn.Config{
		RPID:          &quot;localhost&quot;,
		RPDisplayName: &quot;WebAuthn Demo&quot;,
		RPOrigins:     []string{&quot;http://localhost:8080&quot;},
	}
	if authn, err = webauthn.New(wconfig); err != nil {
		panic(&quot;WebAuthn NewError: &quot; + err.Error())
	}
	return
}


/// --- handler/user.go
type BeginRegisterReq struct {
	Username string `json:&quot;username&quot; binding:&quot;required&quot;`
	Nickname string `json:&quot;nickname&quot; binding:&quot;required&quot;`
}

type User struct {
	ID             int64           `json:&quot;id&quot;`               // 用户唯一标识符
	Username       string          `json:&quot;username&quot;`         // 用户名
	DisplayName    string          `json:&quot;display_name&quot;`     // 用户显示名称
	CredentialIDs  []string        `json:&quot;credential_ids&quot;`   // 存储用户所有的 WebAuthn 凭证 ID（允许多个设备）
	PublicKeyCreds []PublicKeyCred `json:&quot;public_key_creds&quot;` // 存储 WebAuthn 公钥凭证信息
	RegisteredAt   int64           `json:&quot;registered_at&quot;`    // 注册时间戳
}

func BeginRegister(c *gin.Context) {
    // 解析入参
	var req BeginRegisterReq
	session := sessions.Default(c)
	if err := c.ShouldBindJSON(&amp;req); err != nil {
		c.JSON(400, gin.H{&quot;error&quot;: err.Error()})
		return
	}
    // 校验用户是否已注册
	user, err := service.GetUser(req.Username)
	if err != nil {
		fmt.Println(&quot;[handler][BeginRegister] service.GetUser error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}
	if user != nil {
		c.JSON(400, gin.H{&quot;error&quot;: &quot;user already exists&quot;})
		return
	}

    // 生成 User 信息
	user = &amp;models.User{}
	// gen random int id
	user.ID = user.GenUserID()
	user.Username = req.Username
	user.DisplayName = req.Nickname

    // 生成认证信息
	registerOptions := func(credCreationOpts *protocol.PublicKeyCredentialCreationOptions) {
		credCreationOpts.CredentialExcludeList = user.CredentialExcludeList()
	}

    // 生成带 Challenge 和 UserID 信息的 options 对象
	options, sessionData, err := authn.GetAuthn().BeginRegistration(user, registerOptions)
	if err != nil {
		fmt.Println(&quot;[handler][BeginRegister] authn.BeginRegistration error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}

    // 暂存内容到 Session
	if sessionDataStr, err := json.Marshal(&amp;sessionData); err != nil {
		fmt.Println(&quot;[handler][BeginRegister] json.Marshal error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	} else {
		session.Set(RegSessionDataKey, sessionDataStr)
	}
	
	if userStr, err := json.Marshal(&amp;user); err != nil {
		fmt.Println(&quot;[handler][BeginRegister] json.Marshal error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	} else {
		session.Set(RegUserTempDataKey, userStr)
	}
	err = session.Save()
	
	if err != nil {
		fmt.Println(&quot;[handler][BeginRegister] session.Save error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}
	// 返回值
	c.JSON(200, gin.H{&quot;success&quot;: true, &quot;data&quot;: options})
}
</code></pre>

<p>这里的前置解析参数、校验用户是否存在和后置的写入 Session 都是 Web 的常规操作，不多做说明，核心块在这里：</p>

<pre><code class="language-go">    // 生成认证信息
	registerOptions := func(credCreationOpts *protocol.PublicKeyCredentialCreationOptions) {
		credCreationOpts.CredentialExcludeList = user.CredentialExcludeList()
	}

    // 生成带 Challenge 和 UserID 信息的 options 对象
	options, sessionData, err := authn.GetAuthn().BeginRegistration(user, registerOptions)
	if err != nil {
		fmt.Println(&quot;[handler][BeginRegister] authn.BeginRegistration error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}
</code></pre>

<p>这里有几个问题：</p>

<ol>
<li><code>CredentialExcludeList</code> 是做什么的</li>
<li><code>BeginRegistration</code>做了什么，为什么这样就能签下 Challenge 和 UserID</li>
</ol>

<p>关于第一个问题，在<a href="https://www.w3.org/TR/webauthn/#dictionary-makecredentialoptions" target="_blank">WebAuthn 标准</a>中有言：</p>

<blockquote>
<p>This member is intended for use by <a href="https://www.w3.org/TR/webauthn/#relying-party" target="_blank">Relying Parties</a> that wish to limit the creation of multiple credentials for the same account on a single authenticator. The <a href="https://www.w3.org/TR/webauthn/#client" target="_blank">client</a> is requested to return an error if the new credential would be created on an authenticator that also contains one of the credentials enumerated in this parameter.</p>
</blockquote>

<p>也叫做：</p>

<blockquote>
<p>Don’t re-register any authenticator that has one of these credentials.</p>
</blockquote>

<p>也就是为了避免重复注册做的一种措施。</p>

<p>第二个问题中，我们需要看下 <code>webauthn</code> 这个 Golang 库的内部大概是怎么实现的：</p>

<pre><code class="language-go">// User is an interface with the Relying Party's User entry and provides the fields and methods needed for WebAuthn
// registration operations.
type User interface {
	// WebAuthnID provides the user handle of the user account. A user handle is an opaque byte sequence with a maximum
	// size of 64 bytes, and is not meant to be displayed to the user.
	//
	// To ensure secure operation, authentication and authorization decisions MUST be made on the basis of this id
	// member, not the displayName nor name members. See Section 6.1 of [RFC8266].
	//
	// It's recommended this value is completely random and uses the entire 64 bytes.
	//
	// Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dom-publickeycredentialuserentity-id)
	WebAuthnID() []byte

	// WebAuthnName provides the name attribute of the user account during registration and is a human-palatable name for the user
	// account, intended only for display. For example, &quot;Alex Müller&quot; or &quot;田中倫&quot;. The Relying Party SHOULD let the user
	// choose this, and SHOULD NOT restrict the choice more than necessary.
	//
	// Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dictdef-publickeycredentialuserentity)
	WebAuthnName() string

	// WebAuthnDisplayName provides the name attribute of the user account during registration and is a human-palatable
	// name for the user account, intended only for display. For example, &quot;Alex Müller&quot; or &quot;田中倫&quot;. The Relying Party
	// SHOULD let the user choose this, and SHOULD NOT restrict the choice more than necessary.
	//
	// Specification: §5.4.3. User Account Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialuserentity-displayname)
	WebAuthnDisplayName() string

	// WebAuthnCredentials provides the list of Credential objects owned by the user.
	WebAuthnCredentials() []Credential
}

func (webauthn *WebAuthn) BeginRegistration(user User, opts ...RegistrationOption) (creation *protocol.CredentialCreation, session *SessionData, err error) 
</code></pre>

<p>也就是说，我们先需要实现一下这个 interface，这对我们自然不在话下：</p>

<pre><code class="language-go">type User struct {
	ID             int64           `json:&quot;id&quot;`               // 用户唯一标识符
	Username       string          `json:&quot;username&quot;`         // 用户名
	DisplayName    string          `json:&quot;display_name&quot;`     // 用户显示名称
	CredentialIDs  []string        `json:&quot;credential_ids&quot;`   // 存储用户所有的 WebAuthn 凭证 ID（允许多个设备）
	PublicKeyCreds []PublicKeyCred `json:&quot;public_key_creds&quot;` // 存储 WebAuthn 公钥凭证信息
	RegisteredAt   int64           `json:&quot;registered_at&quot;`    // 注册时间戳
}

func (u *User) GenUserID() int64 {
	return rand.Int63()
}

func (u *User) WebAuthnID() []byte {
	if u == nil {
		return []byte{}
	}
	buf := make([]byte, binary.MaxVarintLen64)
	binary.PutUvarint(buf, uint64(u.ID))
	return buf
}

func (u *User) WebAuthnName() string {
	if u == nil {
		return &quot;&quot;
	}
	return u.Username
}

func (u *User) WebAuthnDisplayName() string {
	if u == nil {
		return &quot;&quot;
	}
	return u.DisplayName
}

func (u *User) WebAuthnCredentials() []webauthn.Credential {
	creds := make([]webauthn.Credential, 0)
	if u == nil {
		return creds
	}
	for _, cred := range u.PublicKeyCreds {
		credential, err := cred.ToWebAuthnCredential()
		if err != nil {
			continue
		}
		creds = append(creds, credential)
	}
	return creds
}

func (u *User) CredentialExcludeList() []protocol.CredentialDescriptor {
	var excludeList = make([]protocol.CredentialDescriptor, 0)
	if u == nil {
		return excludeList
	}
	for _, cred := range u.WebAuthnCredentials() {
		excludeList = append(excludeList, protocol.CredentialDescriptor{
			Type:         protocol.PublicKeyCredentialType,
			CredentialID: cred.ID,
		})
	}

	return excludeList
}
</code></pre>

<p>接下来会随机生成 challenge，并根据你实现的 User interface 对信息进行组装再设置超时时间（这里就不 copy 内部代码了）。</p>

<p>接下来，我们就要开始进行第六步，带 Challenge 和 User 信息去请求验证器验证，也就是 <code>navigator.credentials.create</code>。</p>

<pre><code class="language-javascript">const data = await response.json();
if (!data.success) throw new Error(data.error);

const publicKey = data.data.publicKey;
publicKey.challenge = bufferDecode(publicKey.challenge);
publicKey.user.id = bufferDecode(publicKey.user.id);

const credential = await navigator.credentials.create({ publicKey });
</code></pre>

<p>这里需要注意的是，<code>challenge</code> 和 <code>user.id</code> 因为都需要 <code>Uint8Array</code>，所以需要从 <code>string</code>进行转换。</p>

<p>（这里因为没有在后端的 <code>Config</code> 中传入 <code>EncodeUserIDAsString</code>，所以惹了不少麻烦，还得自己实现一个 decode，可以参考，如果传了这个，应该就可以直接使用 <code>TextEncoder</code>和 <code>TextDecoder</code> 了：</p>

<pre><code class="language-javascript">function bufferDecode(value) {
    return Uint8Array.from(atob(urlSafeBase64ToStandard(value)), c =&gt; c.charCodeAt(0));
}

function bufferEncode(value) {
    return btoa(String.fromCharCode(...new Uint8Array(value)))
        .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

function urlSafeBase64ToStandard(base64) {
    return base64.replace(/-/g, '+').replace(/_/g, '/').replace(/=/g, '');
}
</code></pre>

<p>此时验证器会要求用户进行验证，并来到第 9 步返回凭证。</p>

<h4 id="完成注册">完成注册</h4>

<p>然后客户端会带着返回的 <code>credential</code>再次请求服务端，完成验证+注册流程，和之前想的一样，提交时我们我们需要将 Buffer 转成 string 并提交。</p>

<pre><code class="language-javascript">const registrationResponse = await fetch('/register/finish', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        id: credential.id,
        rawId: bufferEncode(credential.rawId),
        type: credential.type,
        response: {
            attestationObject: bufferEncode(credential.response.attestationObject),
            clientDataJSON: bufferEncode(credential.response.clientDataJSON)
        }
    })
});
</code></pre>

<p>接下来服务端需要处理带过来的凭证</p>

<pre><code class="language-go">func FinishRegister(c *gin.Context) {
	var (

		sessionData     = webauthn.SessionData{}
		user            = models.User{}
		credential      *webauthn.Credential
		err             error
		ok              bool
		registerDataStr []byte
		userStr         []byte
	)
	// 获取 session
	session := sessions.Default(c)
	// 获取 sesson 中存储的内容
	if registerDataStr, ok = session.Get(RegSessionDataKey).([]byte); !ok {
		c.JSON(400, gin.H{&quot;error&quot;: &quot;register_data not found&quot;})
		return
	}
	if userStr, ok = session.Get(RegUserTempDataKey).([]byte); !ok {
		c.JSON(400, gin.H{&quot;error&quot;: &quot;temp_user not found&quot;})
		return
	}
	if err = json.Unmarshal(registerDataStr, &amp;sessionData); err != nil {
		fmt.Println(&quot;[handler][FinishRegister] json.Unmarshal error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}
	if err = json.Unmarshal(userStr, &amp;user); err != nil {
		fmt.Println(&quot;[handler][FinishRegister] json.Unmarshal error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}
    // 验证并获得凭证
	if credential, err = authn.GetAuthn().FinishRegistration(&amp;user, sessionData, c.Request); err != nil {
		fmt.Printf(&quot;[handler][FinishRegister] authn.FinishRegistration error: %+v\n&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}
	// 保存凭证到数据库
	if err = service.CreateUser(&amp;user, credential); err != nil {
		fmt.Println(&quot;[handler][FinishRegister] service.CreateUser error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}
	// 删除 session
	session.Delete(RegSessionDataKey)
	session.Delete(RegUserTempDataKey)
	if err = session.Save(); err != nil {
		fmt.Println(&quot;[handler][FinishRegister] session.Save error&quot;, err)
	}
	c.JSON(200, gin.H{&quot;success&quot;: true})
}
</code></pre>

<p>这里我们把 <code>BeginRegister</code> 里的存储的值都拿了出来，丢进了 <code>FinishRegistration</code>，这是 webauthn 库提供给我们的一个方法，你甚至不用定义 Request，内部已经对 Request 有标准化的定义了：</p>

<pre><code class="language-go">// 更多直接看上下文，再粘贴文章就太长了
type CredentialCreationResponse struct {
	PublicKeyCredential

	AttestationResponse AuthenticatorAttestationResponse `json:&quot;response&quot;`
}
</code></pre>

<p>然后可以看到内部实现再解析后进行了验证：</p>

<pre><code class="language-go">
// CreateCredential verifies a parsed response against the user's credentials and session data.
func (webauthn *WebAuthn) CreateCredential(user User, session SessionData, parsedResponse *protocol.ParsedCredentialCreationData) (credential *Credential, err error) {
	if !bytes.Equal(user.WebAuthnID(), session.UserID) {
		return nil, protocol.ErrBadRequest.WithDetails(&quot;ID mismatch for User and Session&quot;)
	}

	if !session.Expires.IsZero() &amp;&amp; session.Expires.Before(time.Now()) {
		return nil, protocol.ErrBadRequest.WithDetails(&quot;Session has Expired&quot;)
	}

	shouldVerifyUser := session.UserVerification == protocol.VerificationRequired

	var clientDataHash []byte

	if clientDataHash, err = parsedResponse.Verify(session.Challenge, shouldVerifyUser, webauthn.Config.RPID, webauthn.Config.RPOrigins, webauthn.Config.RPTopOrigins, webauthn.Config.RPTopOriginVerificationMode, webauthn.Config.MDS); err != nil {
		return nil, err
	}

	return NewCredential(clientDataHash, parsedResponse)
}
</code></pre>

<p>这样就注册成功了，如果注册完直接登录，那么我们直接将用户信息写进 Session 就行了。</p>

<h3 id="登录流程">登录流程</h3>

<p>登录流程相比注册流程来说非常类似，步骤如下：</p>

<p><img src="https://img.codesky.me/blog_static/2024/12/WebAuthn-登录流程.drawio_XqdNMyP1.png" alt="WebAuthn-登录流程.drawio.png" />
这里只是把凭证换成了 <code>navigator.credentials.get</code> 所需要的 publicKey，再将凭证换成断言信息。</p>

<p>publicKey 中的信息包括了：</p>

<ul>
<li>challenge：和注册一样的随机字符串</li>
<li>allowCredentials：告诉浏览器服务器希望用户使用哪些凭据进行身份验证。这里传入注册时保存的<code>credentialId</code> 。</li>
<li>timeout：超时时间</li>
</ul>

<p>get 返回的断言中主要包括了签名信息（不包含公钥）来帮助我们进一步认证。关于更详细的解释可以参考：<a href="https://webauthn.guide/#authentication" target="_blank">https://webauthn.guide/#authentication</a></p>

<p>服务端用签名和公钥进行验证，验证成功则表示登录成功。</p>

<p>接下来我们开始按照步骤实现，得益于注册和登录的流程几乎一致，我们可以缩短这部分的讲解流程：</p>

<h4 id="开始登录">开始登录</h4>

<p>首先前端发起请求：</p>

<pre><code class="language-javascript">const response = await fetch('/login/begin', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username: loginUsername })
});
</code></pre>

<p>服务端负责提供 publicKey 信息：</p>

<pre><code class="language-go">func BeginLogin(c *gin.Context) {
	var req = BeginLoginReq{}
	if err := c.ShouldBindJSON(&amp;req); err != nil {
		c.JSON(400, gin.H{&quot;error&quot;: err.Error()})
		return
	}

    // 获取用户信息
	user, err := service.GetUser(req.Username)
	if err != nil {
		fmt.Println(&quot;[handler][BeginLogin] service.GetUser error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}
	// 获取 publicKey 信息
	options, sessionData, err := authn.GetAuthn().BeginLogin(user)
	if err != nil {
		fmt.Println(&quot;[handler][BeginLogin] authn.BeginLogin error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}

    // 暂存数据
	session := sessions.Default(c)
	if sessionDataStr, err := json.Marshal(&amp;sessionData); err != nil {
		fmt.Println(&quot;[handler][BeginLogin] json.Marshal error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	} else {
		session.Set(LoginSessionDataKey, sessionDataStr)
	}
	if userStr, err := json.Marshal(&amp;user); err != nil {
		fmt.Println(&quot;[handler][BeginRegister] json.Marshal error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	} else {
		session.Set(LoginUserTempDataKey, userStr)
	}
	if err = session.Save(); err != nil {
		fmt.Println(&quot;[handler][BeginLogin] session.Save error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}
	c.JSON(200, gin.H{&quot;success&quot;: true, &quot;data&quot;: options})
}
</code></pre>

<p>其中唯一要关注的是 <code>BeginLogin</code>的实现：</p>

<pre><code class="language-go">// BeginLogin creates the *protocol.CredentialAssertion data payload that should be sent to the user agent for beginning
// the login/assertion process. The format of this data can be seen in §5.5 of the WebAuthn specification. These default
// values can be amended by providing additional LoginOption parameters. This function also returns sessionData, that
// must be stored by the RP in a secure manner and then provided to the FinishLogin function. This data helps us verify
// the ownership of the credential being retrieved.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
func (webauthn *WebAuthn) BeginLogin(user User, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
	credentials := user.WebAuthnCredentials()

	if len(credentials) == 0 { // If the user does not have any credentials, we cannot perform an assertion.
		return nil, nil, protocol.ErrBadRequest.WithDetails(&quot;Found no credentials for user&quot;)
	}

	var allowedCredentials = make([]protocol.CredentialDescriptor, len(credentials))

	for i, credential := range credentials {
		allowedCredentials[i] = credential.Descriptor()
	}

	return webauthn.beginLogin(user.WebAuthnID(), allowedCredentials, opts...)
}
</code></pre>

<p>上文我们也已经介绍了 publicKey 参数的组成，我们获取到 user 对象之后他会拼装成对象返回，包括前端需要的参数。</p>

<h4 id="完成登录">完成登录</h4>

<p>完成登录阶段需要前端拿着上面返回的参数调用 <code>navigator.credentials.get</code>：</p>

<pre><code class="language-javascript">const publicKey = data.data.publicKey;
publicKey.challenge = bufferDecode(publicKey.challenge);
publicKey.allowCredentials.forEach(listItem =&gt; listItem.id = bufferDecode(listItem.id));

const assertion = await navigator.credentials.get({ publicKey });

const loginResponse = await fetch('/login/finish', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        id: assertion.id,
        rawId: bufferEncode(assertion.rawId),
        type: assertion.type,
        response: {
            authenticatorData: bufferEncode(assertion.response.authenticatorData),
            clientDataJSON: bufferEncode(assertion.response.clientDataJSON),
            signature: bufferEncode(assertion.response.signature),
            userHandle: bufferEncode(assertion.response.userHandle),
        },
    })
});

</code></pre>

<p>然后服务端负责验证：</p>

<pre><code class="language-go">
func FinishLogin(c *gin.Context) {
	var (
		sessionData    = webauthn.SessionData{}
		user           = models.User{}
		err            error
		userStr        []byte
		sessionDataStr []byte
		ok             bool
	)
	session := sessions.Default(c)
	if sessionDataStr, ok = session.Get(LoginSessionDataKey).([]byte); !ok {
		c.JSON(400, gin.H{&quot;error&quot;: &quot;login_data not found&quot;})
		return
	} else {
		if err = json.Unmarshal(sessionDataStr, &amp;sessionData); err != nil {
			fmt.Println(&quot;[handler][FinishLogin] json.Unmarshal error&quot;, err)
			c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
			return
		}
	}
	if userStr, ok = session.Get(LoginUserTempDataKey).([]byte); !ok {
		c.JSON(400, gin.H{&quot;error&quot;: &quot;temp_user not found&quot;})
		return
	} else {
		if err = json.Unmarshal(userStr, &amp;user); err != nil {
			fmt.Println(&quot;[handler][FinishLogin] json.Unmarshal error&quot;, err)
			c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
			return
		}
	}
	_, err = authn.GetAuthn().FinishLogin(&amp;user, sessionData, c.Request)
	if err != nil {
		fmt.Println(&quot;[handler][FinishLogin] authn.FinishLogin error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}
	session.Delete(LoginSessionDataKey)
	session.Delete(LoginUserTempDataKey)
	session.Set(UserKey, userStr)
	if err = session.Save(); err != nil {
		fmt.Println(&quot;[handler][FinishLogin] session.Save error&quot;, err)
		c.JSON(500, gin.H{&quot;error&quot;: err.Error()})
		return
	}
	c.JSON(200, gin.H{&quot;success&quot;: true, &quot;data&quot;: user})
}

</code></pre>

<p>再熟悉不过了，核心点在于 <code>FinishLogin</code>，验证签名有效性后完成登录，这里就不概述了。</p>

<h3 id="二步验证">二步验证？</h3>

<p>当然，真的跳过密码环节怎么想怎么别扭，除了一些跨设备的验证器以外，如果用的是 FaceID、指纹、PIN，总会有一个疑问：换设备怎么办？</p>

<p>因此现在这类技术更多的场景仍然是用于二步验证，此时就跟手机上的人脸解锁一样，用户名已知，只需要进行 WebAuthn 认证就行了，本身代码实现是一样的。</p>

<h2 id="总结">总结</h2>

<p>这篇文章开头本来想写 Web Auth 的方方面面，结果发现麻了，然后又开始研究 WebAuthn。</p>

<p>另外再最后运行 Demo 期间发现 AdGuard 可能会拦截这类操作，如果遇到 Error 可以考虑关闭广告过滤。</p>

<p>如果需要 Demo 可以参考：<a href="https://github.com/hbolimovsky/webauthn-example" target="_blank">https://github.com/hbolimovsky/webauthn-example</a></p>

<p>因为本人的 Demo 放在了混合服务中，就不展示了，核心代码基本上也算是带着大家做了一遍了。</p>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Thu, 26 Dec 2024 00:10:00 +0800</pubDate>
    </item>
    <item>
      <title>Redis 分布式锁的实现</title>
      <link>https://www.codesky.me/archives/redis-distributed-lock.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;在 Redis 的常见应用中，分布式锁是一个老生常谈的问题，本文主要讲讲怎么去实现一个分布式锁（最近真·写了不少 Lua 脚本）。&lt;/p&gt; &lt;/blockqu...</description>
      <content:encoded><![CDATA[<blockquote>
<p>在 Redis 的常见应用中，分布式锁是一个老生常谈的问题，本文主要讲讲怎么去实现一个分布式锁（最近真·写了不少 Lua 脚本）。</p>
</blockquote>

<h2 id="加锁">加锁</h2>

<p>对于加锁操作，理论上应该是：</p>

<ol>
<li>尝试加锁，如果成功，则记录锁，并且返回 true</li>
<li>如果失败，则不更新锁，返回 false</li>
</ol>

<!--more-->

<p>另外，1 或者 2 应该都是原子的。而 Redis 中针对这个操作只要一个 Set 就能搞定。</p>

<p>为此，我们先复习一下 <code>SET</code>：</p>

<pre><code>SET key value [NX | XX] [GET] [EX seconds | PX milliseconds |
  EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]
</code></pre>

<p>其中 Options：</p>

<ul>
<li>EX：多少秒后过期</li>
<li>PX：多少毫秒后过期</li>
<li>EXAT：什么时候过期（时间戳-秒）</li>
<li>PXAT：什么时候过期（时间戳-毫秒）</li>
<li>NX：只有当 key 不存在时才 SET</li>
<li>XX：只有 key 存在时才能 SET</li>
<li>KEEPTTL：意味着更新时过期时间保持不变</li>
<li>GET：返回更新前的旧字符串</li>
</ul>

<p>换言之，假设我们把锁作为一个 redis key，那么加锁只要使用 <code>SET key value NX</code> 就行了。</p>

<p>另外，为了避免程序错误导致锁没释放，还需要加入一个超时时间，比如我们预估一个请求 timeout = 800ms，那么锁的时间可以设计为 1s，进行一些容错 <code>SET key value NX EX 1</code></p>

<h2 id="释放锁">释放锁</h2>

<p>因为加锁 = SET key，那么解锁自然是 DEL。但是问题是，不是谁都能释放锁的，只有那个拥有锁的对象可以释放锁。</p>

<p>因此对象匹配和释放锁需要是原子的：</p>

<pre><code>if redis.call(&quot;GET&quot;, KEYS[1]) == ARGV[1] then  
    return redis.call(&quot;DEL&quot;, KEYS[1])
else   
    return 0  
end
</code></pre>

<h2 id="结合-golang-的实现">结合 Golang 的实现</h2>

<pre><code class="language-go">  
type Lock struct {  
    redis   *redis.Client  
    name    string  
    timeout time.Duration  
    uuid    string  
}  
  
type Options struct {  
    Uuid string  
}  
  
func (o *Options) GetUuid() string {  
    if o.Uuid == &quot;&quot; {  
       return &quot;&quot;  
    } else {  
       return o.Uuid  
    }  
}  
  
// KEYS[1] = lockName  
// ARGV[1] = uuid  
const lockLua = `  
if redis.call(&quot;GET&quot;, KEYS[1]) == ARGV[1] then  
    return redis.call(&quot;DEL&quot;, KEYS[1])else   
    return 0  
end  
`  
  
func NewLock(name string, redis *redis.Client, timeout time.Duration, options *Options) *Lock {  
    if name == &quot;&quot; {  
       panic(&quot;lock name is empty&quot;)  
    }    return &amp;Lock{  
       redis:   redis,  
       name:    name,  
       timeout: timeout,  
       uuid:    options.GetUuid(),  
    }}  
  
func (l *Lock) Uuid() string {  
    return l.uuid  
}  
  
func (l *Lock) Lock(ctx context.Context, options *Options) (bool, error) {  
    uuid := l.uuid  
    if options.GetUuid() != &quot;&quot; {  
       uuid = options.GetUuid()  
    }    res, err := l.redis.SetNX(ctx, l.name, uuid, l.timeout).Result()  
    if err != nil {  
       fmt.Printf(&quot;SetNX failed: %v\n&quot;, err)  
       return false, err  
    }  
    return res, nil  
}  
  
func (l *Lock) Unlock(ctx context.Context, options *Options) (bool, error) {  
    uuid := l.uuid  
    if options.GetUuid() != &quot;&quot; {  
       uuid = options.GetUuid()  
    }    result, err := l.redis.Eval(ctx, lockLua, []string{l.name}, uuid).Result()  
    if err != nil {  
       return false, err  
    }  
    return result.(int64) == 1, nil  
}
</code></pre>

<h2 id="基于-redis-的分布式锁的优缺点">基于 Redis 的分布式锁的优缺点</h2>

<p>优点很明显：</p>

<ol>
<li>相比 DB 来当锁，拥有更好的性能</li>
<li>实现简单，因为实现原子操作的成本更低</li>
<li>避免了单点故障，因为 Redis 本身是分布式的</li>
</ol>

<p>当然，也不全是优点，比如：</p>

<ol>
<li>超时时间的设置：这个问题也不能说是 Redis 的问题，但是需要避免程序还在运行但锁超时了的情况发生</li>
<li>主从并非强一致，可能会导致其实上锁时主节点宕机了，但是还没来得及同步到其他节点，因此数据不一致：

<ol>
<li>客户端 A 从 master 中获取到锁</li>
<li>同步期间 master crash</li>
<li>从节点被提升为 master，但缺乏相应数据</li>
<li>客户端 B 从新 master 获取到锁，就产生了两条不同的记录</li>
</ol></li>
</ol>

<p>要解决这个问题，Redis 提供了一个 <code>Redlock</code> 算法来实现分布式锁。</p>

<h2 id="redlock">Redlock</h2>

<h3 id="核心逻辑">核心逻辑</h3>

<p>Redlock 的核心理念和投票类似，也就是，既然一个 master 可能会存在问题，那我多加几个 master，不就不会出现问题了吗？</p>

<p>假设我们准备了五个 Redis master 节点，那么客户端在获取锁时会往五个实例申请持有锁。这里需要注意的是：</p>

<ol>
<li>超时处理：超时时间的配置应该要 &lt; 自动过期时间，避免节点阻塞，也不能最后一个节点申请完第一个已经过期了</li>
<li>异常处理：如果节点出现异常，应该尽快下一个</li>
</ol>

<p>因此我们记录拿第一个锁的开始时间，和最后一个锁的结束时间来判断锁的持有情况：</p>

<ol>
<li>多数实例持有（&gt;= 3 个）</li>
<li>t(申请结束)-t(申请开始) &gt; t(锁有效期)</li>
</ol>

<p>如果最终只有少数持有了锁，我们还需要释放资源。</p>

<p>对于释放资源来说，可能存在「其实我成功了，但是网络失败」的情况，因此不应当只针对成功的节点发释放请求，而应该广播给每一个 master。</p>

<p><img src="https://img.codesky.me/blog_static/2024/09/Pasted_image_20240930123153_Ml3yneBA.png" alt="Pasted image 20240930123153.png" /></p>

<h3 id="快速重试">快速重试</h3>

<p>如果失败时会先尝试重试，避免同时有多个客户端都在申请获取资源产生脑裂问题，最终没有人可以持有锁，也因此客户端的总体响应速度越快，出现这种情况的概率就越小。</p>

<h3 id="延迟重启">延迟重启</h3>

<p>要保证崩溃恢复，我们必然会考虑将数据持久化，如果不进行持久化，那么节点重启时就可能会遇到当时我们单 Redis 中遇到的问题。</p>

<p>如果持久化了，那么问题将会改善很多，但改善并不代表着解决，如果实例崩溃后一直不可用，那只是参与投票的人少了，似乎没什么问题。</p>

<p>但如果实例崩溃后快速的恢复了，而此时 AOF 的数据没有来得及刷到磁盘中，就仍然会遇到相同的问题。解决方案就是将恢复时间拉长，这个恢复重启时间需要大于锁的有效时间，这样重启时所有的锁都到期了，就不会存在问题了。</p>

<h3 id="时钟同步">时钟同步</h3>

<p>上一步我们提到要考虑过期时间，但即使时钟是近似同步的，可能每个 master 中的 time 也会存在一定误差，因此我们可以设置一个漂移量来修复这个问题。</p>

<h3 id="扩展锁">扩展锁</h3>

<p>如果锁本身有效期较短，且得到时已经快到期了，可以尝试发送一个指令来进行续期。在 go 的 redlock 包中已有实现：</p>

<pre><code class="language-go">var touchWithSetNXScript = redis.NewScript(1, `
	if redis.call(&quot;GET&quot;, KEYS[1]) == ARGV[1] then
		return redis.call(&quot;PEXPIRE&quot;, KEYS[1], ARGV[2])
	elseif redis.call(&quot;SET&quot;, KEYS[1], ARGV[1], &quot;PX&quot;, ARGV[2], &quot;NX&quot;) then
		return 1
	else
		return 0
	end
`)

var touchScript = redis.NewScript(1, `
	if redis.call(&quot;GET&quot;, KEYS[1]) == ARGV[1] then
		return redis.call(&quot;PEXPIRE&quot;, KEYS[1], ARGV[2])
	else
		return 0
	end
`)
</code></pre>

<h3 id="可能存在的问题">可能存在的问题</h3>

<p>RedLock 算法相比单机来说更加可靠，但是实际应用中仍然会受到一些挑战：</p>

<ol>
<li>需要更多的资源，且引入了更多的网络 IO 和耗时</li>
<li>依赖时钟：如果机器中的时间被人工修改造成较大偏差，那可能会存在灾难性问题</li>
<li>延迟重启对系统的入侵：作为一个业务系统，往往不是独享 Redis 的，重启时间不一定可控</li>
</ol>

<p>下图是其中一个问题出现的例子：</p>

<p><img src="https://img.codesky.me/blog_static/2024/09/Pasted_image_20240930150059_4tk61eq5.png" alt="Pasted image 20240930150059.png" /></p>

<p>在例子中提到了 GC 导致的 pause，当然实际上，可能也会有其他原因导致类似的效果，比如 CPU 资源竞争，网络延迟。此时光看时间判断就没什么用了。</p>

<p>要解决这个问题，可以在存储侧引入版本号校对，有点类似于一些业务的更新策略，如果发现是老的版本，则不允许更新。</p>

<p><img src="https://img.codesky.me/blog_static/2024/09/Pasted_image_20240930150333_fH5imIDJ.png" alt="Pasted image 20240930150333.png" /></p>

<p>但问题是，Redlock 本身并没有这样的机制去保证这一设计，我们也很难保证计数器的一致性。而如果真的在业务侧计入了版本，那么相当于有序写入，似乎和「互斥锁」也没多大关系。</p>

<p>这也成为了一个 Redlock 高不成低不就的漏洞。因此也有文章抨击这一算法没什么卵用。</p>

<h2 id="总结">总结</h2>

<p>综上来看，选择怎么样的锁也是一个问题，在现实程序中，我们经常看到单 master 的锁实现，因为他相比 RedLock 来说更加轻量，如果并不需要强一致性和可靠性，允许少量误差的前提下，用它可能更方便。</p>

<p>除了 Redis 以外，我们也可以用 etcd 或者 zookeeper 来实现分布式锁，这个我们下次再研究。</p>

<h2 id="参考资料">参考资料</h2>

<ul>
<li><a href="https://redis.io/docs/latest/develop/use/patterns/distributed-locks/#analysis-of-redlock" target="_blank">Distributed Locks with Redis</a></li>
<li><a href="https://github.com/go-redsync/redsync/tree/master" target="_blank">go-redsync</a></li>
<li><a href="http://zhangtielei.com/posts/blog-redlock-reasoning.html" target="_blank">基于Redis的分布式锁到底安全吗（上）？</a></li>
<li><a href="http://zhangtielei.com/posts/blog-redlock-reasoning-part2.html" target="_blank">基于Redis的分布式锁到底安全吗（下）？</a></li>
<li><a href="https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html" target="_blank">How to do distributed locking</a></li>
</ul>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Mon, 30 Sep 2024 15:25:00 +0800</pubDate>
    </item>
    <item>
      <title>限流与常见实现</title>
      <link>https://www.codesky.me/archives/go-rate-limit.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;限流也是一个系统中老生常谈的话题了，因为资源不是无限的，因此系统总会达到一个瓶颈，我们不可能接受无限的流量直到系统崩溃，于�...</description>
      <content:encoded><![CDATA[<blockquote>
<p>限流也是一个系统中老生常谈的话题了，因为资源不是无限的，因此系统总会达到一个瓶颈，我们不可能接受无限的流量直到系统崩溃，于是也就有了限流策略。</p>
</blockquote>

<h2 id="多少流量该限流">多少流量该限流</h2>

<p>一般来说，我们有几种方法可以来对系统进行评估：</p>

<!--more-->

<ol>
<li>正统做法：压测。通过压测对当前系统进行评估，就可以知道单机可承载的 QPS，从而进行整体的限流评估。（注意：限流往往是分布式，而不是单机的，因此单机压测后需要 * N）</li>
<li>懒狗做法：当然，好多野鸡服务可能是不太会做压测的，这类服务通常都不是重保类的服务，在刚上线时也不太会有多大问题，那么我们可以先不设限流，运行一段时间，来评估正常流量，以正常流量的两到三倍作为异常。</li>
</ol>

<h3 id="名词解释">名词解释</h3>

<p>刚刚提到了一个名词：QPS。那么 QPS 到底是怎么样的概念，TPS 又有什么区别呢？</p>

<ul>
<li>QPS（Queries Per Second）：每秒查询数，意味着一台服务器每秒能够相应的查询次数，是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准。</li>
<li>TPS（Transactions Per Second）：它是软件测试结果的测量单位。一个事务是指一个客户机向服务器发送请求然后服务器做出反应的过程。客户机在发送请求时开始计时，收到服务器响应后结束计时，以此来计算使用的时间和完成的事务个数。</li>
</ul>

<p>在一次支付场景中，无论请求扣款服务多少次，TPS 只会记录一次，但是 QPS 可能会有多个。</p>

<p>因此通常我们都会用 QPS 来制定限流标准（说实话如果真按照 TPS 也不好计算）。</p>

<h2 id="限流设计">限流设计</h2>

<p>常见的限流套路有：计数器、滑动窗口、漏桶和令牌桶。</p>

<h3 id="计数器">计数器</h3>

<p>计数器的实现非常简单，我们假设一秒钟承载 10 QPS，那么如果在一秒内统计超过 10 QPS，就意味着超过限制，则阻拦其他请求。</p>

<p>但是问题也很明显：</p>

<ol>
<li>每一秒都承载了 10 QPS，但如果 20QPS 是在 0.5-1.5 这个时间流入的，那么其实超过了两倍的可承载量。</li>
<li>同样的，如果 1s 中承载了超过 10 QPS，而下一秒钟只有 1 QPS，那么也不代表系统一定会挂，可能在 0.5-1.5 这个时间块内，并没有超过 10 QPS，只是在 0-1 中统计超过了 10。</li>
</ol>

<p>因此，这种一秒的固定维度统计可能会存在问题。</p>

<p>要实现这个也相当简单，我们使用 Redis 就可以很轻松的实现：</p>

<pre><code class="language-go">type Counter struct {
	limit    uint   // 最大限制 QPS
	redisKey string // cache key
	r        *redis.Client
}

func NewCounter(limit uint, redisKey string, client *redis.Client) *Counter {
	return &amp;Counter{
		limit:    limit,
		redisKey: redisKey,
		r:        client,
	}
}

func (c *Counter) Try(ctx context.Context) bool {
	var (
		t = time.Now().Unix()
		k = c.redisKey + strconv.FormatInt(t, 10)
	)

	incr := c.r.Incr(ctx, k)
	c.r.Expire(ctx, k, time.Second)
	if res, err := incr.Result(); err != nil {
		return false
	} else {
		if res &gt; int64(c.limit) {
			return false
		}
	}
	return true
}
</code></pre>

<p>Redis 官方文档中也有对这个的花式实现，就在 <code>incr</code> 章节：<a href="https://redis.io/docs/latest/commands/incr/" target="_blank">https://redis.io/docs/latest/commands/incr/</a></p>

<h3 id="滑动窗口">滑动窗口</h3>

<p>刚刚我们提到，归根结底这会出现问题，都是因为 1s 太死了，不够灵活，那假设我们使用滑动窗口去动态滚动，不就完事儿了吗？使用这个方法，就能解决刚刚提到的 0.5 - 1.5s 这个统计口径带来的问题。</p>

<pre><code class="language-go">type Window struct {
	limit    uint   // 最大限制 QPS
	redisKey string // cache key
	r        *redis.Client
}

// KEYS[1] = redisKey
// ARGV[1] = now - 1s
// ARGV[2] = now
// ARGV[3] = random mem
// ARGV[4] = limit
const evalCommand = `
local current = redis.call(&quot;zcount&quot;, KEYS[1], ARGV[1], ARGV[2])
if current &gt;= tonumber(ARGV[4]) then
    return -1
else
	redis.call(&quot;zadd&quot;, KEYS[1], ARGV[2], ARGV[3])
	return current
end`

func NewWindow(limit uint, redisKey string, client *redis.Client) *Window {
	return &amp;Window{
		limit:    limit,
		redisKey: redisKey,
		r:        client,
	}
}

func (w *Window) Try(ctx context.Context) bool {
	var (
		now       = time.Now().UnixMilli()
		secBefore = now - 1000
		randStr   = strconv.FormatInt(rand.Int63n(1000000), 16)
	)
	result, err := w.r.Eval(ctx, evalCommand, []string{w.redisKey}, secBefore, now, fmt.Sprintf(&quot;%d-%s&quot;, now, randStr), w.limit).Result()
	if err != nil {
		fmt.Printf(&quot;eval error: %v&quot;, err)
		return false
	}
	if result.(int64) == -1 {
		return false
	}
	if err = w.r.ZRemRangeByScore(ctx, w.redisKey, &quot;-inf&quot;, strconv.FormatInt(now-1000*5, 10)).Err(); err != nil {
		fmt.Printf(&quot;zrem error: %v&quot;, err)
	}
	return true
}
</code></pre>

<p>但是同样的，对于滑动窗口来说，我们不好实现等待，只能实现 block，因此他对于削峰填谷并没有帮助，还是需要其他实现方式。</p>

<h3 id="令牌桶">令牌桶</h3>

<p>上面提到了滑动窗口无法做到削峰填谷，因此我们需要一些新的实现方式，而令牌桶就是其中之一。</p>

<p>令牌桶的重点是按照恒定速率放入令牌，消费完了就进行 block 或者降级。</p>

<ol>
<li>让系统以一个由限流目标决定的速率向桶中注入令牌，譬如要控制系统的访问不超过 100 次每秒，速率即设定为 100 个令牌每秒，每个令牌注入间隔为 <sup>1</sup>&frasl;<sub>100</sub>=10 毫秒。</li>
<li>桶中最多可以存放 N 个令牌，N 的具体数量是由超时时间和服务处理能力共同决定的。如果桶已满，第 N+1 个进入的令牌会被丢弃掉。</li>
<li>请求到时先从桶中取走 1 个令牌，如果桶已空就进入降级逻辑。</li>
</ol>

<p>当然，这并不意味着我们就需要实现一个 <code>Ticker</code> 来进行定时任务，我们完全可以通过一个请求进来的时间和上次更新令牌桶的时间差来计算，在此期间需要补充多少令牌的库存，从而得到正确的结果。</p>

<pre><code class="language-go">  
type TokenBucket struct {  
    capacity uint   // 最大限制 QPS    redisKey string // cache key  
    r        *redis.Client  
    rate     int64  
}  
  
// redis 结构  
// hash 存储：  
// key: redisKey  
// field:  
//   - current: 目前令牌余量  
//   - last_update_time: 上次更新时间  
//  
// KEYS[1] = redisKey  
// ARGV[1] = now  
// ARGV[2] = capacity  
// ARGV[3] = rate  
const bucketCommand = `  
-- 获取当前桶信息  
local last_update_time = 0  
local current = 0  
local variables = redis.call(&quot;hmget&quot;, KEYS[1], &quot;current&quot;, &quot;last_update_time&quot;)  
if variables[1] then  
    current = tonumber(variables[1]) or 0end  
if variables[2] then  
    last_update_time = tonumber(variables[2]) or 0end  
-- 获取当前时间时间戳  
local now = tonumber(ARGV[1])  
local capacity = tonumber(ARGV[2])  
local rate = tonumber(ARGV[3])  
local num = math.floor((now - last_update_time) * rate / 1000)  
local new_current = math.min(capacity, current + num)  
-- 重新计算后没有令牌了  
if new_current == 0 then  
    return -1end  
-- 更新令牌数  
if num == 0 then  
  redis.call(&quot;hmset&quot;, KEYS[1], &quot;current&quot;, new_current - 1)else  
  redis.call(&quot;hmset&quot;, KEYS[1], &quot;last_update_time&quot;, now, &quot;current&quot;, new_current - 1)end  
return new_current - 1  
`  
  
func NewTokenBucket(capacity uint, redisKey string, rate int64, r *redis.Client) *TokenBucket {  
    return &amp;TokenBucket{  
       capacity: capacity,  
       redisKey: redisKey,  
       r:        r,  
       rate:     rate,  
    }}  
  
// Try 尝试获取令牌  
// 令牌桶思路：  
// 1. 从 redis 中获取当前令牌桶信息  
// 2. 计算当前时间与上次更新时间的时间差，根据时间差更新令牌桶  
// 3. 减扣令牌  
// 4. 返回是否获取成功  
func (tb *TokenBucket) Try(ctx context.Context) bool {  
    now := time.Now().UnixMilli()  
    res, err := tb.r.Eval(ctx, bucketCommand, []string{tb.redisKey}, now, tb.capacity, tb.rate).Result()  
    if err != nil {  
       fmt.Printf(&quot;Eval failed: %v\n&quot;, err)  
       return false  
    }  
    if res.(int64) &lt; 0 {  
       return false  
    }  
    return true  
}
</code></pre>

<h3 id="漏桶">漏桶</h3>

<p>漏桶算法和令牌桶算法类似，唯一的区别是，令牌桶是从桶中拿令牌，拿完了代表超过限制，而漏桶则是把流量注入桶中，流量满（=capacity）则代表超过了限制。</p>

<pre><code class="language-go">  
type LeakyBucket struct {  
    r        *redis.Client  
    redisKey string  
    capacity int64  
    rate     int64  
}  
  
// KEYS[1] = redisKey  
// ARGV[1] = now  
// ARGV[2] = capacity  
// ARGV[3] = rate  
const leakyBucketCommand = `  
local last_update_time = 0  
local current = 0  
local variables = redis.call(&quot;hmget&quot;, KEYS[1], &quot;current&quot;, &quot;last_update_time&quot;)  
if variables[1] then  
    current = tonumber(variables[1])end  
if variables[2] then  
    last_update_time = tonumber(variables[2])end  
local now = tonumber(ARGV[1])  
local capacity = tonumber(ARGV[2])  
local rate = tonumber(ARGV[3])  
local num = math.floor((now - last_update_time) * rate / 1000)  
local new_current = math.max(0, current - num)  
if new_current &gt;= capacity then  
    return -1end  
if num == 0 then  
  redis.call(&quot;hmset&quot;, KEYS[1], &quot;current&quot;, new_current + 1)else  
  redis.call(&quot;hmset&quot;, KEYS[1], &quot;current&quot;, new_current + 1, &quot;last_update_time&quot;, now)end  
return new_current + 1  
`  
  
func NewLeakyBucket(capacity int64, redisKey string, rate int64, r *redis.Client) *LeakyBucket {  
    return &amp;LeakyBucket{  
       r:        r,  
       redisKey: redisKey,  
       capacity: capacity,  
       rate:     rate,  
    }}  
  
// Try 漏桶算法  
// 1. 获取当前时间  
// 2. 获取当前漏桶中的值和最后更新时间  
// 3. 根据最后更新时间的时间间隔和当前时间的差值，计算出应该释放多少容积  
// 4. 判断是否为 capacity// 5. 如果不是 capacity，则 +1 后写入新值，否则直接返回 falsefunc (lb *LeakyBucket) Try(ctx context.Context) bool {  
    now := time.Now().UnixMilli()  
    lastUpdate, err := lb.r.HGet(ctx, lb.redisKey, &quot;last_update_time&quot;).Result()  
    current, err := lb.r.HGet(ctx, lb.redisKey, &quot;current&quot;).Result()  
  
    lastUpdateNum, _ := strconv.ParseInt(lastUpdate, 10, 64)  
    adder := math.Floor(float64(now-lastUpdateNum) * float64(lb.rate) / 1000)  
    fmt.Printf(&quot;now: %v, lastUpdate: %v, duration: %v, shouldRemove: %v, current: %v\n&quot;, now, lastUpdate, now-lastUpdateNum, adder, current)  
    res, err := lb.r.Eval(ctx, leakyBucketCommand, []string{lb.redisKey}, now, lb.capacity, lb.rate).Result()  
    if err != nil {  
       fmt.Printf(&quot;Eval failed: %v\n&quot;, err)  
       return false  
    }  
    if res.(int64) &lt; 0 {  
       return false  
    }  
    return true  
}
</code></pre>

<p>可以看出，令牌桶和漏桶更像是相同思路的不同实现，但其实我们可以通过令牌桶来处理突发的流量，因为令牌是一个不断存的过程，而使用漏桶来控制流量的平稳，因为漏桶本质就是控制流速。来同时解决突发流量和削峰这两种场景。</p>

<p>这一点因为 Redis 中没法存储一个所谓的漏桶队列，因此漏桶表现的更像令牌桶，如果有队列，那么看上去就清楚多了：</p>

<pre><code class="language-go">// LeakyBucketSimple 单进程的漏桶算法  
type LeakyBucketSimple struct {  
    capacity int64       // 桶容量  
    rate     int64       // 流速  
    mutex    sync.Mutex  // 互斥锁  
    queue    chan func() // 请求队列  
}  
  
func NewLeakyBucketSimple(capacity, rate int64) *LeakyBucketSimple {  
    lb := &amp;LeakyBucketSimple{  
       capacity: capacity,  
       rate:     rate,  
       mutex:    sync.Mutex{},  
       queue:    make(chan func(), capacity),  
    }    go lb.leaking()  
    return lb  
}  
  
func (l *LeakyBucketSimple) Try(ctx context.Context, f func()) bool {  
    l.mutex.Lock()  
    defer l.mutex.Unlock()  
  
    if len(l.queue) &gt;= int(l.capacity) {  
       fmt.Printf(&quot;Try to add but failed, current=%v, capacity=%v\n&quot;, len(l.queue), l.capacity)  
       return false  
    }  
    l.queue &lt;- f  
    fmt.Printf(&quot;Try to add and success, current=%v\n&quot;, len(l.queue))  
    return true  
}  
  
func (l *LeakyBucketSimple) leaking() {  
    ticker := time.NewTicker(time.Duration(1000/l.rate) * time.Millisecond)  
    defer ticker.Stop()  
  
    for range ticker.C {  
       l.mutex.Lock()  
       select {  
       case req := &lt;-l.queue:  
          req()  
       default:  
          fmt.Println(&quot;Queue is empty, nothing to leak.&quot;)  
       }       l.mutex.Unlock()  
    }}
</code></pre>

<h2 id="分布式限流">分布式限流</h2>

<p>前面因为我们是使用 Redis 实现的，因此天然的支持了分布式限流。但是在实际应用的高并发场景下就会遇到 Redis 成为了单点瓶颈的问题，此外，这意味着每次服务调用都会多增加一次网络 IO，成本反而会变高。</p>

<p>此时一个合适的做法是：基于令牌桶进行一次资源的再分配，具体的来说，假设我们有 5 台机器，而令牌桶里有 100 个令牌，那么我们先给每台机器分配 20 个，如果单机用完了，则再去桶里尝试拿 20 个。</p>

<p>此时拿 20 个以外的情况下都不需要网络 IO，就能有效的防止 Redis 之类的存储点的服务压力，也能提高响应速度。</p>

<p>在实际应用中，我们可以把单机的进程限流和分布式限流看做：</p>

<h2 id="总结">总结</h2>

<p>最后我们考虑在什么情况下使用单机，什么情况下使用分布式：</p>

<ol>
<li>单机：因为我们压测时往往会考虑单机的承载流量，因此单机的限流适合根据压测数据评估</li>
<li>分布式：整条链路中的资源都是有限的，不应该因为某个点压垮下游（比如 Redis 或者 MySQL），这种情况下就可以使用分布式限流去限制整个系统中的使用。</li>
</ol>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Sun, 29 Sep 2024 19:05:05 +0800</pubDate>
    </item>
    <item>
      <title>Redis 大 key、热 key 判别和解决方案</title>
      <link>https://www.codesky.me/archives/redis-big-hot-key-solution.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;Redis 是我们常见的缓存解决方案，但是使用不当的 Redis 同样会造成系统瓶颈。&lt;/p&gt; &lt;/blockquote&gt;  &lt;h2 id=&#34;慢日志分析&#34;&gt;慢日志分析&lt;/h2&gt;  &lt;p&gt;要启用��...</description>
      <content:encoded><![CDATA[<blockquote>
<p>Redis 是我们常见的缓存解决方案，但是使用不当的 Redis 同样会造成系统瓶颈。</p>
</blockquote>

<h2 id="慢日志分析">慢日志分析</h2>

<p>要启用慢日志分析，首先先要对慢查询记录进行设置：</p>

<pre><code class="language-bash"># 命令执行耗时超过 5 毫秒，记录慢日志 
CONFIG SET slowlog-log-slower-than 5000 
# 只保留最近 500 条慢日志
CONFIG SET slowlog-max-len 500
</code></pre>

<!--more-->

<p>设置成功后通过下面的命令就能查到慢日志：</p>

<pre><code class="language-bash">127.0.0.1:6379&gt; SLOWLOG get 5
1) 1) (integer) 32693 # 慢日志ID
   2) (integer) 1593763337 # 执行时间戳
   3) (integer) 5299 # 执行耗时(微秒)
   4) 1) &quot;LRANGE&quot; # 具体执行的命令和参数
      2) &quot;user_list:2000&quot;
      3) &quot;0&quot;
      4) &quot;-1&quot;
2) 1) (integer) 32692
   2) (integer) 1593763337
   3) (integer) 5044
   4) 1) &quot;GET&quot;
      2) &quot;user_info:1000&quot;
</code></pre>

<p>主要可能的原因无非也就是：</p>

<ol>
<li>数据太大，导致网络 IO 耗时增加</li>
<li>命令复杂，导致 CPU 耗时增加</li>
</ol>

<p>而要避免这种慢查询发生，就需要我们尽可能的避免复杂的查询和大 key 的产生。</p>

<p>而今天我们要说的重点就是关于 Redis 中的大 key 要怎么解决。</p>

<h2 id="大-key">大 key</h2>

<p>大 key 意味着 value 特别大（而不是 key 特别大），大 key 会导致的问题显而易见：</p>

<ol>
<li>网络 IO：大 key 的读写都会导致网络 IO 的阻塞，形成上面所说的慢查询</li>
<li>内存倾斜：在 Redis cluster 中大 key 会存在某个节点，此时该节点会比其他节点消耗更多的内存和网络资源，形成卡点</li>
<li>阻塞查询：大 key 的读写和删除操作都在主线程中进行，会阻塞其他命令的执行，导致 redis 性能下降</li>
<li>影响持久化：持久化需要将数据写入磁盘，大 key 意味着单条日志写入量也会变大，持久化过程就会更耗时，甚至会频繁触发 AOF 重写。</li>
</ol>

<p>因此如果没有特殊情况，我们要尽量避免大 key。</p>

<h3 id="大-key-检测">大 key 检测</h3>

<p>要发现大 key 也很简单，可以直接通过下面的命令发现大 key：</p>

<pre><code class="language-bash">&gt; redis-cli --bigkeys

# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type.  You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).

100.00% ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Keys sampled: 18

-------- summary -------

Total key length in bytes is 155 (avg len 8.61)

Biggest string found &quot;rand_16_0&quot; has 4 bytes
Biggest   zset found &quot;job:delayed&quot; has 4 members

0 lists with 0 items (00.00% of keys, avg size 0.00)
0 hashs with 0 fields (00.00% of keys, avg size 0.00)
0 streams with 0 entries (00.00% of keys, avg size 0.00)
17 strings with 67 bytes (94.44% of keys, avg size 3.94)
0 sets with 0 members (00.00% of keys, avg size 0.00)
1 zsets with 4 members (05.56% of keys, avg size 4.00)
</code></pre>

<p>当然，这种方法只能显示最大的那个 key，他不一定实际就是大 key，可能本身占用的内存并不多。</p>

<p>此外，如果在主节点执行，同样会阻塞运行，所以建议在从节点执行。</p>

<p>另外，也可以通过 <code>SCAN</code>来扫描 Redis，得到每个 key 的内存占用情况，从而感知到大 key 的存在：</p>

<pre><code class="language-bash">redis-cli --scan | while read key; do 
  echo &quot;$(redis-cli memory usage $key) $key&quot;; 
done | sort -nr | head
</code></pre>

<p>使用 SCAN 命令不会阻塞 Redis，比较安全。</p>

<p>但是缺点是效率较低，对大量 key 的 Redis 数据库扫描时间较长。</p>

<p>也可以使用一些第三方软件来完成这一动作，比如 <code>Redis RDB Tools</code>。</p>

<h3 id="删除大-key">删除大 key</h3>

<p>实际上不仅读写会造成瓶颈，前面我们说过删除大 key 也会有性能问题。因为删除时不仅仅是删除一个操作，还会涉及到资源的回收和再分配，而 <code>DEL</code>是在主线程中执行的，<code>Redis</code> 中的执行命令是单线程的，这意味着直接影响了整个 Redis 的吞吐。</p>

<p>因此我们不能直接 <code>DEL</code> 大 key，更好的实践是使用 <code>UNLINK</code>代替 <code>DEL</code>，这样会分配另一个线程去回收，而不会阻塞主线程。</p>

<p>另一方面，如果 Redis 开启了 Lasy Free：<code>lazyfree-lazy-user-del = yes</code>，此时就不用 <code>UNLINK</code>，<code>DEL</code> 也会由其他线程执行了，从而不阻塞主线程。</p>

<h3 id="避免大-key">避免大 key</h3>

<p>站在业务的角度，有时候可能不可避免的会产生一些大 key，但原则上我们依旧要尽可能的避免这种情况的出现。</p>

<p>拿我们上一篇缓存文章中微博的例子为例，如果一个微博大 V 有非常多的粉丝（假设有一千万），这些数据量即使我们只存储 user_id，也会有不小的数据。此时我们就可以将大 V 的粉丝拆分成多个子列表来进行查询，一个子列表放 5000 个粉丝 id，避免单 key 过大。</p>

<h2 id="热-key">热 key</h2>

<p>在上一篇文章中，我们也说过热 key 的问题，热 key 对 Redis 主要造成的影响是：</p>

<ol>
<li>接口超时严重，逐步发生雪崩</li>
<li>网卡被打爆，大量请求失败</li>
<li>连接数被热 key 占据影响别的请求</li>
</ol>

<p>说白了核心关键点就是网络实在不够用了。</p>

<p>而对于热 key 来说，除了加机器，还可以配合「发现-解决」的套路来减少热 key 带来的影响。</p>

<h3 id="发现热-key">发现热 key</h3>

<ol>
<li>业务场景预估热 key：对于一些场景，我们可能能预估出爆点，比如某本季新番预订爆款，那八成就会变成热 key。但是不太能应对微博热搜这种突发场景。</li>
<li>客户端收集：在 Redis 调用的 SDK 中加入对命令的收集，来分析哪些 key 的访问最多，从而感知热 key。虽然方便，但是需要改造和引入 SDK，与业务耦合。</li>
<li>代理层收集：本质上和客户端没啥区别，只是从 SDK 变成了网络代理。从架构的角度上虽然与业务解耦了，多加了一层也容易造成单点风险。</li>
<li>Redis 命令监控：

<ol>
<li>monitor：用于实时打印出 Redis 服务器接收到的命令。但是在高并发的情况下容易拖累 redis 的性能，因此不太常用。</li>
<li>hotkeys：<code>redis-cli --hotkeys -i 0.1</code>来获取，但是相当于全 key 扫描，key 较多时成本会比较高，而且全量扫描的耗时导致它的实时性较差。</li>
</ol></li>
</ol>

<h3 id="解决热-key">解决热 key</h3>

<p>对于热 key 来说，其实也没什么特别好的解决方案，主要也就是两个套路：</p>

<ol>
<li>读写分离的情况下扩容</li>
<li>使用多级缓存</li>
</ol>

<p>在发现热 key 的前提下构造多级缓存是一个比较正常的解决方案，这样有效降低了 Redis 的访问量，多级缓存的问题和解决方案在上一篇文章中也有提到。</p>

<h2 id="总结">总结</h2>

<p>大 key 和 热 key 不仅仅是一个技术问题，同时也要站在业务的角度来选择一个合适的解决方案。</p>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Fri, 20 Sep 2024 22:27:09 +0800</pubDate>
    </item>
    <item>
      <title>缓存：高并发读的救世主</title>
      <link>https://www.codesky.me/archives/cache-intro.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;实在不知道该编什么名字，总之先复习一下缓存吧。本文讲的重点是服务端缓存，尤其是 Redis 相关的设计。&lt;/p&gt; &lt;/blockquote&gt;  &lt;h2 id=&#34;概述&#34;&gt;概述...</description>
      <content:encoded><![CDATA[<blockquote>
<p>实在不知道该编什么名字，总之先复习一下缓存吧。本文讲的重点是服务端缓存，尤其是 Redis 相关的设计。</p>
</blockquote>

<h2 id="概述">概述</h2>

<p>众所周知的是，我们的业务数据多数都会选择存储在 DB 里，但数据库本身是一个吞吐量有限的单点，在实际的高并发场景下，我们肯定不可能让所有的流量都流向 DB，因此在这种情况下，业务往往会涉及一些缓存来缓解 DB 的压力。</p>

<p>具体的来说，从客户端到服务端，链路的每一个节点都能具有缓存的能力。比如客户端的 HTTP Cache、边缘节点的 CDN 缓存，再到服务端缓存，包括内存缓存、Redis 缓存等等，在开头我们说过，重点是服务端缓存，因此我们会对客户端缓存暂且不表。（反正一言以蔽之也就是强缓存和协商缓存）。</p>

<!--more-->

<h2 id="cdn-缓存">CDN 缓存</h2>

<p>CDN 在过去我们已经讲过很多遍了，这次重新掏出来只能说是无他，唯手熟尔。</p>

<p>使用 CDN 缓存你可以将数据缓存在边缘节点，从而降低端到端网络耗时。</p>

<p>值得一提的是，在近期的实际优化中，即使你并不是使用 DNS 缓存，而是使用了其动态加速的特性，我们也从中获得了收益：大致是因为如果不走 CDN，在全球网络时直接连接源站点，尽管相比 CDN 来说是一种直连，少了很多跳，但传输稳定性却不行；而通过 CDN 的动态加速来优化链路传输，从而可以降低响应延迟、提升接口成功率。</p>

<h2 id="服务端缓存">服务端缓存</h2>

<p>上面我们用两个名词介绍了客户端缓存，在用几句话介绍了 CDN 缓存。接下来重点就来了：如何去设计缓存和解决我们缓存中遇到的问题。</p>

<p>首先我们先来考虑缓存可以解决哪些问题：</p>

<ol>
<li>CPU 计算问题：比如在之前做 SSR 时需要在服务端频繁的进行资源计算（render），如果能够对部分计算后的内容进行缓存，就能有效减少 CPU 的压力。</li>
<li>IO 问题：对于标题中提到的高并发场景，可能会造成磁盘或者网络 IO 的压力，使用缓存能有效降低链路中的 IO 压力。</li>
</ol>

<p>但是加了缓存也就意味着，这些数据都不是实时获取的了，需要对实时性有一定容忍度，且需要尽可能的保持一致性。</p>

<h3 id="如何设计缓存">如何设计缓存</h3>

<p>根据上述我们分析「解决问题」的场景，我们可以看出，缓存并不是一个十全十美的东西，因此设计一个无效的缓存还不如没有缓存，那么关于缓存，我们大致可以考虑以下指标来设计和选择缓存：</p>

<ul>
<li>命中率：缓存命中率是一个最重要的指标，如果你设计的缓存实际并没有被命中，那么即使系统再高效，也和你的缓存无关</li>
<li>吞吐量：假设你的缓存命中率是 100%，但是你的缓存吞吐量却很低，导致整个服务的吞吐都被拉低了，那还不如没有缓存，直接加限流算了</li>
<li>是否需要分布式支持：内存缓存也就是在程序内部的，那么必然是个单机缓存，而如果需要分布式缓存，我们则更多的使用 Redis 来实现分布式</li>
<li>是否有扩展功能：这是《凤凰架构》中提到的，更多的像是「选择缓存框架」时的考虑，指的是是否会提供一些管理功能。譬如最大容量、失效时间、失效事件、命中率统计，等等。</li>
</ul>

<h3 id="命中率">命中率</h3>

<p>大部分情况下，我们永远不可能把数据表照搬进缓存，也就是说，我们会对字段和缓存行进行筛选。就字段来说，我们肯定会选择热门的字段，毕竟大 key 会造成读写的性能下降，如果用的较少（QPS 较低）的部分就没有必要进 Redis 了。</p>

<p>而缓存行意味着我们不需要将表中的所有行都同步，比如我们缓存了用户的微博内容，但是大部分情况下，用户并不会查阅好多年前的内容，而热数据肯定是「近期的微博热搜」。</p>

<p>因此这里就涉及到了淘汰算法。淘汰算法相信大家学过操作系统的话其实也挺熟悉了，毕竟 CPU 也有淘汰算法，常见的淘汰算法有：</p>

<ul>
<li>FIFO（First In First Out）：先进先出类似于一个普通队列，大部分情况下 FIFO 是无意义的，尤其是在我们上述的例子中就更不合适了，热点数据直接被踢出。</li>
<li>LRU（Least Recent Used）：LRU 会淘汰最久未被访问的资源，大部分情况下这已经够用了，但也可能会存在某个热点数据只是访问不连续，一段时间没人访问就被错误踢出的情况。使用双向链表来进行记录，而使用 HashMap 来进行访问，实现也较为简单。</li>
<li>LFU（Least Frequently Used）：LFU 会淘汰最不经常用的数据，非常符合保留热数据的诉求，但也会存在问题，假设说存在一个网站爆点当时访问量很大，热点过后没有一个比他访问量更大的（他是历史最高），那么尽管话题过气了，仍然会长期存在缓存中。需要维护一个计数器，每次访问则 +1。</li>
</ul>

<p>而基于 LFU，衍生出了 TinyLFU，W-TinyLFU，ARC 和 LIRS。这些进阶算法都值得单开一篇文章说明了，所以这里先按下不表。</p>

<h3 id="缓存分类">缓存分类</h3>

<ul>
<li>本地缓存：缓存存储在进程内，这种方式读的时候最快，因为根本不涉及网络 IO，问题是因为是本地缓存，所以各自是独立的。如果要实现一套同步复制和更新的机制，那么更新为了保证一致性就会变得很重。</li>
<li>分布式缓存：目前如果提到缓存，大部分场景都会默认优先使用分布式缓存，他虽然相比本地缓存多了一层网络 IO，但是优点是与程序是完全解耦而独立的，目前也有很成熟的解决方案可以处理分布式缓存，而无需关心细节（没错说的就是 Redis）</li>
</ul>

<p>当然，本地缓存和分布式缓存是可以同时使用的，两者同时使用，我们可以叫做「多级缓存」。</p>

<p>多级缓存中我们优先读取本地缓存，如果本地缓存不存在，再读取分布式缓存，如果分布式缓存也不存在，则会回源到 DB。</p>

<p>但是在更新缓存时，需要同时更新本地缓存，分布式缓存，相比使用单一缓存，一致性问题将会变得更加突出。简单说明就是发送通知，通知各级淘汰或者更新缓存。而关于怎么保证一致性，这个可以见上一期中「如何解决服务中的事务问题」中的 ACK 设计。</p>

<h2 id="缓存遇到的挑战">缓存遇到的挑战</h2>

<h3 id="一致性">一致性</h3>

<p>缓存当然不是完全都是优点，在前面我们就一直提到缓存更新时的一致性问题。大部分情况下，当我们使用缓存时，我们基本上会选择追求最终一致性而不是强一致性，如果需要强一致性的场合不太适合添加缓存。</p>

<p>缓存一致性虽然说起来就这几个字，但其本质上也是一个很大的课题。</p>

<p>在上面我们说到，在读缓存时，我们先读缓存，在读数据库。但是在写时，因为缓存服务和数据库服务本质上是两个服务，同样是一个分布式事务的问题，此时先写什么后写什么，怎么避免一致性问题就变得尤为重要。</p>

<h4 id="先写数据库-再写缓存">先写数据库，再写缓存？</h4>

<p>先写数据库再写缓存看上去没什么大问题，毕竟数据库写入成功，缓存写入失败的情况下，最多就是直接访问数据库嘛。</p>

<p>但是实际上我们会发现如果有两个请求并发的情况下：</p>

<ol>
<li>请求 1 先更新了数据库，将 value 从 1 改成 2</li>
<li>请求 2 希望 value 从 1 变成 3</li>
<li>数据库本身是会上行锁的，所以必然会存在先后顺序，则 value 可能为 1 或者 2，我们假设 value 变成了 3</li>
<li>2 和 3 更新完成后，更新缓存的请求刚发出，其到达的顺序可能是 2 先到达或者 3 先到达</li>
<li>如果是 2 先到达，那么最终会定格在 3。</li>
<li>但如果 2 后到达，那么缓存就被变更成了 2，与预期不符。</li>
</ol>

<h4 id="先写缓存-再写数据库">先写缓存，再写数据库？</h4>

<p>同样不能解决问题，甚至更糟糕了，如果缓存都更新成功了，而数据库更新失败，那将是灾难性的。</p>

<h4 id="先删除缓存-后写数据库">先删除缓存，后写数据库？</h4>

<p>删除缓存而不是更新缓存的策略叫做 <strong>Cache Aside</strong>。</p>

<p>整体步骤是：</p>

<ol>
<li>读取时不变，依旧是读缓存，没有则捞数据库，用数据库数据更新缓存</li>
<li>写时更新数据库+删除缓存</li>
</ol>

<p>当然，同样也分成了两类：先删除缓存和后删除缓存。</p>

<p>先来说说先删后写，对于先删后写来说：</p>

<ol>
<li>请求 1 希望更新数据库的 value，从 1 变成 2，所以删除了缓存</li>
<li>请求 2 希望获取 value，此时发现没有缓存，读取后更新缓存，此时 value 还是旧的值</li>
<li>请求 1 更新数据库，value 变成了 2</li>
</ol>

<p>此时依旧会出现不一致的情况。似乎问题仍然没有解决。</p>

<h4 id="先写数据库-后删除缓存">先写数据库，后删除缓存？</h4>

<p>如果先写数据库，后删除缓存，那么可能遇到的情况是：</p>

<ol>
<li>请求 1 希望更新数据库的 value，从 1 变成 2</li>
<li>此时请求 2 请求 value，因为没有删除，所以读到了旧数据</li>
</ol>

<p>此时如果 请求 1 删除缓存，那么下次访问时就能拿到新的值，在理想情况下，似乎并没有什么问题。</p>

<p>但是这里我们忽略了一种情况，在读写分离的情况下，有可能请求 1 更新完数据库后，从库并没有更新，此时可能请求 2 就可能更新了错误的数据，仍然拿到了旧的值。</p>

<p>尽管设置超时可以一定程度缓解这个情况，但不一定符合业务的需求，毕竟缓存过短的话就没有意义，如果长时间脏数据，这就成为了个 Bug。</p>

<h4 id="如何修复边界-case">如何修复边界 case</h4>

<p>刚刚我们提到了几种边界 Case，其实并不是没有解决方案，「写+更新」的策略合并不是完全不能用。</p>

<p>因为我们知道，在高并发情况下，如果删除了缓存，缓存就很有可能被击穿（将在后面讲解），此时，我们希望缓存是长期存在的，这种情况就更适合「写+更新」的策略。</p>

<p>要解决「写+更新」中的不一致问题，最简单的方法就是使用分布式锁，简单的来说，就是控制同一时间只有一个请求进行「写+更新」的操作，那样问题就会小很多，但是我们依旧没有办法解决更新失败的问题。</p>

<p>对于更新失败的问题，在分布式事务的解决方案中我们其实也有提及，但是如果真要上「分布式事务」同时成功或者失败可能又太重了，我们引入一个消息队列，或者通过订阅 binlog 来更新（本质上还是消息队列），通过消息队列的可靠性来保证，是比较常见的做法。此时也不需要分布式锁了，毕竟更新被异步了。</p>

<p>因为消息队列本身有 ACK+重试机制来保证消费的可靠性，利用这一特性，我们就能尽可能保证 Redis 更新的可靠性了。</p>

<p>如果你的策略是删除，而前面遇到的读写不一致的问题，有一种解决方案叫做「延迟双删」，也就是过一段时间我再删一次，此时就能避免并发时遇到的删了却读了脏数据的问题。</p>

<p>但是对于延迟双删来说，延迟多久是一个比较麻烦的问题。</p>

<p>总结来说：</p>

<ol>
<li>对于「更新+写」，建议别用，凉的太快</li>
<li>对于「写+更新」，利用 MQ（binlog）来进行保序+可靠更新</li>
<li>对于「删除+写」，延迟双删来解决，也可以使用分布式锁</li>
<li>对于「写+删除」，同样可以用延迟双删来解决</li>
</ol>

<p>关于  <strong>Cache Aside</strong> 在读场景中使用了分布式锁，步骤大概是：</p>

<ol>
<li>需要进行数据库写入，上锁，删除缓存，等更新完数据库后释放锁</li>
<li>读时有缓存读缓存，没有发现上锁状态，暂不处理，等待锁释放，抢锁，然后执行从数据库获取和更新 Redis</li>
</ol>

<p>是否会存在锁过重的情况，我们留待后续讨论。</p>

<h3 id="缓存穿透">缓存穿透</h3>

<p>缓存穿透意味着缓存不存在，而回源的情况。</p>

<p>结合我们上面对缓存设计的介绍，大部分场景下其实这是一个正常的现象，冷数据的 QPS 也不会太高，并不会有什么影响，最多咱们对于冷数据也进行一定时间的缓存。此外，如果发现一段时间内访问了不存在的数据造成了回源，也可以直接将空对象存入缓存中。</p>

<p>但是以上说的是正常情况，如果是异常情况，有恶意请求进行流量攻击，此时可以结合限流限频来防御，如果是 DDOS 类由于 IP 大量分散导致很难识别的，也可以通过布隆过滤器来快速判断数据是否存在。</p>

<h3 id="缓存击穿">缓存击穿</h3>

<p>可以看到，撇除恶意攻击，缓存穿透在正常情况下的危害性并不大，而缓存击穿则比较严重。</p>

<p>缓存击穿，意味着热点数据在某一时间失效或者被删除，大量 QPS 涌入造成源负载过重。</p>

<p>这里的解决方案可以是：</p>

<ol>
<li>永不过期：热点数据永远存在于 Redis 中，先前我们讲过一致性的解决方案，此时我们只能使用写+更新的策略。</li>
<li>逻辑过期：永不过期带来的问题是如果存在任何问题导致缓存不一致，我们将失去最后的修复手段，因此也可以在缓存物理过期前加上逻辑过期，逻辑过期时间再去更新缓存，此时逻辑过期时间需要小于缓存的物理过期时间。这样物理过期时间相当于最后的防御措施，安全系数高了很多。</li>
<li>加锁同步：即使被击穿，因为有锁的存在，同时只会有一条记录回源，而拿到锁后，在回源前重新检查是否有数据。与 <strong>Cache Aside</strong> 中分布式锁的情况类似。换言之， <strong>Cache Aside</strong> 如果是行锁，也不会存在太大问题。</li>
</ol>

<h3 id="缓存雪崩">缓存雪崩</h3>

<p>缓存雪崩，意味着大量缓存在同一个时间点过期，可能是因为业务设置，也可能是因为缓存故障，此时分布式锁由于是个行锁，就不会产生多大效果。</p>

<p>针对性的策略有：</p>

<ol>
<li>设置不同的过期时间，避免同时过期</li>
<li>多级缓存，此时两级缓存的过期时间可以不一样，此时击穿到数据源的可能性就大大降低了。</li>
</ol>

<p>当然，同样的，缓存击穿的诸如逻辑过期、永不过期等手段依旧可以解决这个问题；对回源进行限流同样也可以一定程度的缓解。</p>

<h2 id="缓存预热">缓存预热</h2>

<p>缓存预热也就是在业务访问前，提前将数据准备好，这样可以有效避免新数据上线时找不到缓存的问题，可以结合实际情况进行。</p>

<h2 id="总结">总结</h2>

<p>对于缓存设计来说，同样也没有银弹，需要结合自己的实际业务情况来选择适合自己的缓存方案。</p>

<p>关于 Redis 的其他问题，我们将在其他文章中另行说明。</p>

<h2 id="参考资料">参考资料</h2>

<ul>
<li><a href="https://icyfenix.cn/architect-perspective/general-architecture/diversion-system/" target="_blank">凤凰架构 - 透明多级分流系统</a></li>
<li><a href="https://pdai.tech/md/arch/arch-y-cache.html" target="_blank">架构之高并发 - 缓存</a></li>
<li><a href="https://xiaolincoding.com/redis/cluster/cache_problem.html#%E7%BC%93%E5%AD%98%E9%9B%AA%E5%B4%A9" target="_blank">什么是缓存雪崩、击穿、穿透</a></li>
</ul>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Tue, 17 Sep 2024 21:22:07 +0800</pubDate>
    </item>
    <item>
      <title>如何解决服务中的事务问题</title>
      <link>https://www.codesky.me/archives/how-to-resolve-transaction-issues-in-services.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;我们经常会被问到这样一个问题：在一个下单流程中，如何保证数据的一致性。&lt;/p&gt; &lt;/blockquote&gt;  &lt;p&gt;如果我们在单服务单库中运行，那么很简��...</description>
      <content:encoded><![CDATA[<blockquote>
<p>我们经常会被问到这样一个问题：在一个下单流程中，如何保证数据的一致性。</p>
</blockquote>

<p>如果我们在单服务单库中运行，那么很简单，使用数据库的事务就可以了。</p>

<p>但是正常来说，现在的所有服务都会采用微服务的架构，也就是说一个下单流程中，「订单服务」到「库存锁定」到「生成账单」到「支付交易」到「回调变更状态」，这几步将会有多个服务来共同完成。</p>

<p>此时我们必然不能让用户的任何一步失败，又或者必须保证失败后回滚一定成功，否则用户钱扣了，交易却没成功；或者造成了超卖，这些都会造成严重客诉。</p>

<p>为此才会引入分布式事务这个概念，也就是保障多个事务之间的一致性，要么全部成功，要么全部失败。</p>

<!--more-->

<h2 id="事务概念">事务概念</h2>

<p>在开始前，还是来复习一些基本概念，以便后续方案中来检查是否满足这一概念。</p>

<h3 id="acid">ACID</h3>

<p>数据库的事务中我们会经常提到 ACID，也就是数据库事务的基本原则。</p>

<ul>
<li>原子性（Atomicity）：一个事务的所有系列操作步骤被看成一个动作，所有的步骤要么全部完成，要么一个也不会完成。如果在事务过程中发生错误，则会回滚到事务开始前的状态，将要被改变的数据库记录不会被改变。</li>
<li>一致性（Consistency）：一致性是指在事务开始之前和事务结束以后，数据库的完整性约束没有被破坏，即数据库事务不能破坏关系数据的完整性及业务逻辑上的一致性。</li>
<li>隔离性（Isolation）：主要用于实现并发控制，隔离能够确保并发执行的事务按顺序一个接一个地执行。通过隔离，一个未完成事务不会影响另外一个未完成事务。</li>
<li>持久性（Durability）：一旦一个事务被提交，它应该持久保存，不会因为与其他操作冲突而取消这个事务。</li>
</ul>

<p>而实际上，AID 都是为了保障 C 的一种手段。</p>

<h3 id="cap">CAP</h3>

<p>而分布式系统中我们会经常听人提到 CAP 这个概念：</p>

<ul>
<li><strong>一致性</strong>（<strong>C</strong>onsistency）：在分布式系统中的所有数据备份，在同一时刻是否同样的值。（等同于所有节点访问同一份最新的数据副本）</li>
<li><strong>可用性</strong>（<strong>A</strong>vailability）：在集群中一部分节点故障后，集群整体是否还能响应客户端的读写请求。（对数据更新具备高可用性）</li>
<li><strong>分区容错性</strong>（<strong>P</strong>artition Tolerance）：以实际效果而言，分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性，就意味着发生了分区的情况，必须就当前操作在C和A之间做出选择。</li>
</ul>

<p>CAP 中的三点是不可能三角，也就是说永远不可能同时满足这三项，我们必须要有所取舍。</p>

<p>以下单场景为例：</p>

<ul>
<li>一致性问题意味着，可能用户实际付钱了，但是订单回调失败，因此显示上用户仍未付款；又或者是用户下单后没有及时减少库存，造成了超卖。</li>
<li>可用性问题意味着，如果我有有三个节点可以负责减库存，如果其中一个节点挂了，其他两个节点能否完成减库存的重任。</li>
<li>分区容错性问题，意味着分区通信失败的情况下是否会造成影响，比如如果下单到锁库存失败了，是否会对整体业务造成影响。</li>
</ul>

<p>这三个点不可能同时达成，意味着我们必然要放弃其中一个：</p>

<ul>
<li>要放弃一致性，意味着假设流程中每个节点一定是基于正确的数据在处理值，不强求一致</li>
<li>要放弃可用性，意味着假设流程中每个节点我们得假设必须是全部可用的，不强求可用</li>
<li>要放弃分区容忍性，就意味着我们假设网络永远是可靠的，不强求网络可靠</li>
</ul>

<p>而很显然，我们不可能假设「全部可用」和「完全可靠」，因此在大多数场景下，我们只能通过牺牲一致性来构建我们的系统。</p>

<p>当然，前面我们提到的无论是 ACID 的一致性，还是 CAP 的一致性，更多的是强一致性。而在业务中，我们往往更多的是保证最终一致性，这也就是为什么我们的交易过程中可能会有延迟，但很少会真的出现重大问题。</p>

<h3 id="base">Base</h3>

<ol>
<li><strong>Basically Available</strong>（基本可用）：分布式系统在出现不可预知故障的时候，允许损失部分可用性</li>
<li><strong>Soft state</strong>（软状态）：软状态也称为弱状态，和硬状态相对，是指允许系统中的数据存在中间状态，并认为该中间状态的存在不会影响系统的整体可用性，即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。</li>
<li><strong>Eventually consistent</strong>（最终一致性）：最终一致性强调的是系统中所有的数据副本，在经过一段时间的同步后，最终能够达到一个一致的状态。因此，最终一致性的本质是需要系统保证最终数据能够达到一致，而不需要实时保证系统数据的强一致性。</li>
</ol>

<p>Base 是对 CAP 中 AP 的补充，牺牲了强一致性来保证高可用。</p>

<p>我们把实现了 ACID 的事务叫做刚性事务（强一致性），而 Base（最终一致性）叫做柔性事务。</p>

<h2 id="同库场景">同库场景</h2>

<p>在开始事务之前，我们先来分析一下数据库事务的做法。</p>

<p>我们都知道数据库设计了 Undo / Redo 两种日志，Undo 日志拿来记录修改行、原值和新值，而 Repo 日志同样也会记录这些值，只是他们的用法并不相同，具体可以异常恢复的执行步骤：</p>

<ol>
<li>分析：扫描日志并找到所有没有 End Record 的事务，准备恢复</li>
<li>Redo：重新回放需要执行的事务，执行完毕后增加 End Record 行表示事务结束</li>
<li>Undo：事务 Redo 失败，剩下的就是需要回滚的，根据对应的 Undo 信息回滚</li>
</ol>

<p>因此，Redo 和 Undo 中的操作都需要是幂等操作。</p>

<h2 id="不同库同服务场景">不同库同服务场景</h2>

<p>如果不同库但是同服务，那么久不能简单的使用数据库的事务操作了，因为几个数据库之间是分开提交的，此时越来越接近我们想要讨论的分布式事务了。</p>

<p>当然，由于是在同一个服务中，所以我们直接在代码中进行操作就可以了，在这种情况下，我们会提到两个方案：2PC 和 3PC。</p>

<h3 id="两段式提交-2pc">两段式提交：2PC</h3>

<p>两段式提交中引入一个协调者来解决多库间的操作，假设我们需要同时操作 <code>order</code>、<code>goods</code>和<code>user</code>三张表，2PC 中一共有两个阶段：</p>

<ol>
<li>准备阶段：准备阶段需要准备好事务操作，也就是说，协调者先会给参与者（也就是各库）发请求，询问是否准备完毕。各库会先开始执行内部操作，但不进行 <code>commit</code>，而是在确定执行完之后，给协调者回复是或否，如果是否，则回滚。</li>
<li>提交阶段：如果全部收到了是，那么协调者将会通知所有参与者进行 <code>commit</code>，而如果收到了其中一个否，则通知所有参与者回滚。</li>
</ol>

<p>但是 2PC 看似美好的背后我们一眼就能看出的问题是：</p>

<ol>
<li>单点问题：协调者本身是个单点，如果协调者出现问题，那么大家就都不能正常运行了</li>
<li>同步阻塞：如果其中一个参与者出现了网络问题，那么所有参与者都会卡着不进行提交，在此期间数据库是上锁的，将造成严重的性能问题。</li>
<li>网络问题：我们无法保证 <code>commit</code>是百分百送达的，如果部分参与者没收到 <code>commit</code>，那么他们的操作可能是 pending、提交或者回滚中的一种，无法保证数据一致性。</li>
</ol>

<h3 id="三段式提交-3pc">三段式提交：3PC</h3>

<p>三段式提交修改了两段式提交，将准备阶段拆细，先询问是否有把握执行成功，再发送给参与者需要写入 redo（不执行 <code>commit</code>），最后再执行 commit。</p>

<p>但是其实  2PC 遇到的问题仍没有得到很好的解决，它发送的指令更多了，也依旧不能解决网络问题，唯一改良的是准备阶段这个低性能操作的提前确定一定程度上对性能有所改善。</p>

<h2 id="分布式事务">分布式事务</h2>

<p>分布式事务基本都是为了实现最终一致性，也就是说，我们允许在中间过程中有一段时间的不一致，只要数据最终是一致的就可以了。</p>

<h3 id="tcc">TCC</h3>

<p>TCC（Try-Confirm-Cancel）又被称为补偿事务。它一共分为三步：</p>

<ul>
<li><strong>Try</strong>：尝试执行阶段，完成所有业务可执行性的检查（保障一致性），并且预留好全部需用到的业务资源（保障隔离性）。</li>
<li><strong>Confirm</strong>：确认执行阶段，不进行任何业务检查，直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行，因此本阶段所执行的操作需要具备<strong>幂等性</strong>。</li>
<li><strong>Cancel</strong>：取消执行阶段，释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行，也需要满足<strong>幂等性</strong>。</li>
</ul>

<p><code>Try</code> 中我们冻结了所需要的资源，这样就可以保证不会因为不一致而导致诸如超售之类的问题。</p>

<p><code>Confirm</code>中我们消费冻结了的资源；而 <code>Cancel</code>则是一种回滚操作。</p>

<p>《凤凰架构》中有图来表示这一过程（其实主要就是懒得画图）。</p>

<p><img src="https://img.codesky.me/blog_static/2024/09/Pasted_image_20240913231235_arbNjOek.png" alt="Pasted image 20240913231235.png" />
在 <code>Try</code> 中，账号服务、仓库服务、商家服务会对资源进行预留，并通知成功与否。</p>

<p>如果全部成功，则执行 <code>Confirm</code>流程完成操作。</p>

<p>如果存在失败则执行 <code>Cancel</code>流程取消交易并且解除 <code>Try</code>对资源的冻结。</p>

<p>可以看出，TCC 整体的设计是非常安全而高效的，但是问题也仍然存在：</p>

<ol>
<li>业务侵入与开发成本：要实现这样一个事务，意味着整体链路中的每一环都需要有一个 <code>Try</code>、<code>Confirm</code>、<code>Cancel</code>的实现。</li>
<li>链路超时的影响：如果 <code>Try</code> 阶段有一个失败了，那么会去调用 <code>Cancel</code>方法，这时部分业务可能实际并没有执行 <code>Try</code>，可能会造成空回滚。解决方案是：

<ol>
<li>在发起事务同时生成事务 Unique ID</li>
<li>在每一步执行时写入事务 ID 和业务 ID 和执行步骤</li>
<li>如果执行 <code>Cancel</code>时没有对应的 <code>Try</code>记录，则不执行
同样的，如果是响应慢，那么事务发起节点以为超时，准备 Cancel 的时候可能下游刚刚收到 <code>Try</code>命令，那么可以在同样的表中查到对应是否有 <code>Cancel</code>记录，如果有 <code>Cancel</code>，那么不执行 <code>Try</code></li>
</ol></li>
</ol>

<p>但无论如何，这是一种业务看起来改的很辛苦的方式，如果其中有一个服务是不可控的，可能就玩不下去（比如银行负责收钱），除此以外，可以用：<a href="https://seata.apache.org/zh-cn/" target="_blank">https://seata.apache.org/zh-cn/</a>这样的框架来简化你的实现成本。</p>

<h3 id="saga-事务">SAGA 事务</h3>

<p>在 SAGA 事务中，我们不需要进行冻结资源与解冻资源，因此他更适合大多数的业务场景。</p>

<p>SAGA 由一堆本地事务来组成分布式事务。每一个本地事务在更新完数据库之后，会发布一条消息或者一个事件来触发 SAGA 中的下一个本地事务的执行。如果一个本地事务因为某些业务规则无法满足而失败，SAGA 会执行在这个失败的事务之前成功提交的所有事务的补偿操作。</p>

<p>SAGA 通常会有两种实现：</p>

<ol>
<li>基于事件</li>
<li>基于命令</li>
</ol>

<p>根据我们刚刚的思路，假设我们有「账号」、「仓库」、「商家」三个服务。</p>

<p>在基于事件的过程中账号服务执行成功后会发送一个事件给仓库服务，仓库服务监听并且收到这个事件后进行减库存操作，如果扣除成功，再发送事件给商家，商家在根据事件执行，最后发送事件给账号服务告诉它变更用户的交易状态。</p>

<p>如果商家执行失败，会发送消息给仓库和账号，并进行回滚操作。</p>

<p>这个模式看上去很简单，但实际想想就会发现：</p>

<ol>
<li>各业务监听消息是不可控的，谁监听什么完全看各业务自己的开发者，万一漏了或者监听错了很有可能产生问题</li>
<li>因为监听是不可控的，如果两个服务各自在监听对方的事件来执行，那么形成了环，甚至可能会变成死锁</li>
</ol>

<p>因此刚刚说的基于事件显得并不是特别靠谱，「基于命令」的实现也就是在此基础上诞生的。</p>

<p>在基于命令的模式中，我们考虑引入一个中央节点，用来记录执行了什么，这一设计原则比较像前面我们数据库事务中提到的 <code>undo</code>/<code>redo</code> 日志，也就是说，我们的事务系统来承担记录<code>undo</code>、<code>redo</code>和发送命令（调用）的责任。</p>

<p>如果期间有失败，那么执行 <code>undo</code>日志进行回滚即可。</p>

<p>当然，这里也会存在问题，那就是如果执行 <code>undo</code>期间，业务数据表又被修改了，那么执行的 <code>undo</code>可能会存在问题，这个时候可能就会造成脏写。</p>

<p>再这种情况下，为了避免造成脏写，还需要引入一个全局锁来锁住对应的变更（类似于行锁），避免同一行在回滚时有新的操作修改了该行数据。</p>

<p>虽然说相比 2PC，锁更为精细化，但行锁仍要等待事务完成后释放，因此性能仍有一定的牺牲。</p>

<p>当然，同样的，如果不引入中间调度器，也可以在业务本地建表来存储对应的执行状态。</p>

<p>也就是说，本来是由中间调度器来记录 <code>undo</code>、<code>redo</code>，现在由业务方本地来记录执行步骤：</p>

<ol>
<li>数据库变更数据</li>
<li>记录事务操作动作为已发送</li>
<li>推消息给下一步骤（此处可以是一个消息中间件来保证送达）</li>
<li>下一服务执行并重复步骤</li>
<li>完成后回调</li>
<li>收到回调的业务更新事务操作的动作为已完成</li>
</ol>

<p>由本地数据表来记录是否执行了指定的动作，方便重试和回滚，由消息中间件来保证送达。</p>

<p>但是这种实现意味着每个业务本地都会有一张事务表，看上去和 TCC 一样，就仍然依赖业务的实现。</p>

<h3 id="可靠消息事务">可靠消息事务</h3>

<p>基于 MQ 实现分布式事务本质上是将所有动作存储在 MQ 内，由 MQ 来完成送达和回滚。</p>

<p>比如 RocketMQ 就提供了事务消息：<a href="https://rocketmq.apache.org/zh/docs/featureBehavior/04transactionmessage/" target="_blank">https://rocketmq.apache.org/zh/docs/featureBehavior/04transactionmessage/</a></p>

<p><img src="https://img.codesky.me/blog_static/2024/09/Pasted_image_20240914132340_8I4BbIq4.png" alt="Pasted image 20240914132340.png" />
详情可见单页文档连接，简单的来说，和 2PC 类似，会先发送半消息 MQ Server 是否能接收。能，则向生产者返回 ACK。</p>

<p>生产者收到 ACK 后开始执行，并向 MQ Server 提交 <code>Commit</code> or <code>Rollback</code>，决定是回滚还是推给下游服务。</p>

<p>如果 MQ Server 未收到二次确认，那么在一定时间后 MQ 将对生产者发送消息回查。</p>

<p>生产者针对消息会检查事务执行结果来决定二次提交 <code>Commit</code> or <code>Rollback</code>。</p>

<p>优点在于和中间调度者一样，和业务本身解耦了。但问题是需要两次网络请求，以及业务需要根据其标准实现回查接口。</p>

<h3 id="最大努力交付">最大努力交付</h3>

<p>最大努力交付这种模式中，如果下游业务没有接收到上游投递的消息，那么可以调用上游提供的补偿查询接口进行事务的补偿。</p>

<p>此时由下游消费者来保证事务的一致性。中间同样通过 MQ 来保证消息投递的可达。</p>

<p>当然，也可以是借由 MQ 来进行的不断重试，但无论如何，这种方式意味着不停地轮询。</p>

<p><img src="https://img.codesky.me/blog_static/2024/09/Pasted_image_20240914133958_MF9Tqbhs.png" alt="Pasted image 20240914133958.png" /></p>

<h2 id="总结">总结</h2>

<p>在实际学习中，我发现不同的文章对这些分布式事务解决方案有不同的归类和细节上的出入，但从套路上来说，解决方案就是这几种，因为他们各有优劣，所以还是需要根据业务进行结合或者改造。</p>

<p>在实际操作的过程中，也可以使用成熟的分布式事务框架来简化开发流程，而不必重复造轮子，文章更多的是介绍范式和怎么选轮子的问题。</p>

<h2 id="参考资料">参考资料</h2>

<ul>
<li><a href="https://icyfenix.cn/architect-perspective/general-architecture/transaction/distributed.html" target="_blank">分布式事务 - 凤凰架构</a></li>
<li><a href="https://pdai.tech/md/arch/arch-z-transection.html" target="_blank">分布式系统 - 分布式事务及实现方案</a></li>
<li><a href="https://xiaomi-info.github.io/2020/01/02/distributed-transaction/" target="_blank">分布式事务，这一篇就够了</a></li>
</ul>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Sun, 15 Sep 2024 21:10:34 +0800</pubDate>
    </item>
    <item>
      <title>聊聊 MySQL 索引与索引设计</title>
      <link>https://www.codesky.me/archives/mysql-index-design.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;本文讲的都是比较基础的点，原理点到为止，适合人群为数据库小白，希望能够从数据库层面进行一些优化的同学。&lt;/p&gt; &lt;/blockquote&gt;  &lt;p&gt;最近��...</description>
      <content:encoded><![CDATA[<blockquote>
<p>本文讲的都是比较基础的点，原理点到为止，适合人群为数据库小白，希望能够从数据库层面进行一些优化的同学。</p>
</blockquote>

<p>最近在做旧项目改造的时候发现了很多不合理的索引设计，或者干脆就没有设计索引，开发者仿佛把在线业务的表当做了离线表那样想怎么查就怎么查，导致了整个接口相当缓慢，甚至有可能拖垮整个服务 / DB。</p>

<p>于是我发现一些很常规的优化似乎其实并不是每个人都很清楚，所以今天就来聊聊 SQL 的索引优化。</p>

<p>因为我们的项目都是 MySQL / MongoDB 的，而我基本也只学了 MySQL，因此这里以 MySQL 为主，MongoDB 为辅来进行索引设计的解读。</p>

<!--more-->

<h2 id="开始之前-数据构建">开始之前：数据构建</h2>

<blockquote>
<p>如果你只想看看理论经验，也可以跳过这个步骤</p>
</blockquote>

<p>在正式开始之前，先让我们构造一下之后要作为例子的数据（不然讲起来又枯燥又抽象）。</p>

<p>假定我们有这样一张博客文章表，用来存储用户的文章数据：</p>

<pre><code class="language-sql">CREEATE DATABASE test;
USE test;
CREATE TABLE IF NOT EXISTS blog_posts (
        id INT AUTO_INCREMENT PRIMARY KEY,
        title VARCHAR(255) NOT NULL,
        content LONGTEXT NOT NULL,  -- Use LONGTEXT to store large data
        author VARCHAR(100) NOT NULL,
        created DATETIME NOT NULL
);
</code></pre>

<p>然后我们需要基于这个构造数据集，这一步直接找 GPT 写就行了，大致诉求如下：</p>

<ol>
<li>构造十万条数据</li>
<li>其中有十个 author，其中包括了一个叫 skyao 的（因为之后要举例子），content 的大小在 0-1MB 不等</li>
</ol>

<p>因为之后我们将通过 id / author 和 created 来进行排序检索和数据获取，因此这些是必须的。</p>

<h2 id="没有索引的情况下会发生什么">没有索引的情况下会发生什么</h2>

<p>我们以实际场景为例，在博客首页中，我需要展示我自己的文章，并按照时间倒序排列。</p>

<p>因此我们要执行的语句是：</p>

<pre><code class="language-sql">SELECT id, title, content FROM test.blog_posts WHERE author=&quot;skyao&quot; ORDER BY created DESC LIMIT 10 OFFSET 10 # 假设我们翻了个页
</code></pre>

<p>假设我们什么索引都不加，那么查询这 10 行数据在我的电脑中需要 1 分多钟。</p>

<p>如果我们 <code>EXPLAIN</code> 会发现，这其实是个全表查询，也就是说他要扫描全表才能得出最终的结果，实际开销（Cost）也是相当的大：</p>

<p><img src="https://img.codesky.me/codesky/mysql-index-design/Pasted%20image%2020240901203841.png" alt="" /></p>

<blockquote>
<p>考虑到这篇文章的受众，因此解释一下 <code>EXPLAIN</code> 语句可以查看 SQL 的执行计划，来确定是否如你预期的那样。在 SELECT 语句前加 Explain 就可以了，因为本文也会稍微提及 MongoDB，因此之后会再说下 Mongo 的 Explain。</p>
</blockquote>

<p>此时如果我们加上了索引 <code>created</code>，你就会发现，开销少了很多：</p>

<p><img src="https://img.codesky.me/codesky/mysql-index-design/Pasted%20image%2020240901204154.png" alt="" /></p>

<p>而如果我们用的是 <code>author</code>和<code>created</code>的联合索引，我们会发现效率竟然变慢了：</p>

<p><img src="https://img.codesky.me/codesky/mysql-index-design/Pasted%20image%2020240901204326.png" alt="" /></p>

<p>但如果我们将语句换成了游标性质的，或者是归档最近 X 天的文章，那么可能两种索引的效果又不一样了，比如：</p>

<pre><code class="language-sql">SELECT id, title, content FROM test.blog_posts WHERE author=&quot;admin&quot; AND id &gt;= 50 ORDER BY created DESC LIMIT 10;
SELECT id, title, content FROM test.blog_posts WHERE author=&quot;admin&quot; AND created &gt;= &quot;2024-08-26 12:55:30&quot; ORDER BY created DESC LIMIT 10;
</code></pre>

<p>author + created:</p>

<p><img src="https://img.codesky.me/codesky/mysql-index-design/Pasted%20image%2020240901210431.png" alt="" /></p>

<p><img src="https://img.codesky.me/codesky/mysql-index-design/Pasted%20image%2020240901210423.png" alt="" /></p>

<p>created:
<img src="https://img.codesky.me/codesky/mysql-index-design/Pasted%20image%2020240901210423.png" alt="" />
<img src="https://img.codesky.me/codesky/mysql-index-design/Pasted%20image%2020240901210714.png" alt="" />
之所以 created 排序会差这么多，是因为 author + created 的查询 Explain 的 Extra 中写的：Using index condition; Backward index scan；而单 created 还得需要 where 去检索表。</p>

<p>从中我们可以看出，加索引总比没加好，但是索引也并不是一个万精油，在不同的查询条件下，不同的索引会产生不同的效果。</p>

<h2 id="索引拉满会发生什么">索引拉满会发生什么</h2>

<p>在上面的例子中，按月归档、游标分页和页码分页都是非常常见的需求，但是有效索引却是截然不同的，聪明的读者可能已经想着「小孩才做选择、大人全都要」。</p>

<p>但是索引并不是全然没有代价，在这里我们简单介绍下索引的原理。</p>

<p>首先我们知道，InnoDB 使用的是 B+树，先不讲 B+树这个数据结构相比 B 树的优点之类八股的问题，总之它是一棵树。而每个建立的索引树本质上都是一种空间换时间的做法，下面用一张高性能 MySQL 的图来介绍节点的搜索。</p>

<p><img src="https://img.codesky.me/codesky/mysql-index-design/Pasted%20image%2020240901213309.png" alt="" /></p>

<p>简单的来说，建立索引的过程其实就是在建立逻辑页，而每一个数据页都要 16k，每个索引都有自己的开销，那么磁盘空间占用就变大了。</p>

<p>另一方面，新增数据相当于需要在一个个树中插入值，这也是有一定开销的，比如：</p>

<ul>
<li>新增记录就需要往页中插入数据，现有的页满了就需要新创建一个页，把现有页的部分数据移过去，这就是页分裂</li>
<li>若删除了许多数据使得页很空闲，就需要页合并</li>
</ul>

<p>页分裂和合并，都会有I/O代价，且过程中可能产生死锁。</p>

<p>以我们建了一个索引的表为例，可以用下面的 SQL 查看数据量：</p>

<pre><code class="language-sql">mysql&gt; SELECT DATA_LENGTH, INDEX_LENGTH FROM information_schema.TABLES WHERE TABLE_NAME='blog_posts';
+-------------+--------------+
| DATA_LENGTH | INDEX_LENGTH |
+-------------+--------------+
| 54863085568 |      2637824 |
+-------------+--------------+
</code></pre>

<p>因此，如果你一直在无脑建立索引，那么带来的代价就是磁盘的无限消耗和写入速度的降低。</p>

<p>当然，如果你说：我不 care 写入、也不心疼磁盘；即使如此，你如果建的全是单列索引（比如一个 created 和 一个 author），他也并不会有效利用两个索引（毕竟这是两棵树），而是会选其中一个效果更好的。</p>

<p>这里补充一点：尽管 MySQL 引入了 index merge 但是也会带来额外的开销，对此《高性能 MySQL》是这么总结的。</p>

<ul>
<li>当优化器需要对多个索引做相交（相交操作是使用“索引合并”的一种情况，另一种是做联合操作）操作时（通常有多个AND条件），通常意味着需要一个包含所有相关列的多列索引，而不是多个独立的单列索引。</li>
<li>当优化器需要对多个索引做联合操作时（通常有多个OR条件），通常需要在算法的缓存、排序和合并操作上耗费大量CPU和内存资源，尤其是当其中有些索引的选择性不高，需要合并扫描返回的大量数据的时候。</li>
<li>更重要的是，优化器不会把这些操作计算到“查询成本”（cost）中，优化器只关心随机页面读取。这会使得查询的成本被“低估”，导致该执行计划还不如直接进行全表扫描。这样做不但会消耗更多的CPU和内存资源，还可能会影响并发的查询，但如果单独运行这样的查询则往往会忽略对并发性的影响。通常来说，使用UNION改写查询，往往是最好的办法。</li>
</ul>

<p>简单的来说，不要太指望和依赖 MySQL 的自动优化（包括 Buffer Pool / Cache 这类会让你产生错觉，以为自己的查询还可以），更重要的依旧是自己设计好索引。</p>

<h2 id="如何正确的设计索引">如何正确的设计索引</h2>

<p>在前面我们已经展示了不同索引即使命中了，他的扫描行也存在着差距；但是建立大量索引又会造成可预期的副作用。</p>

<p>对于有 Where 就有索引的行为，我们可以考虑查询语句本身是否能够收敛到索引行，而不是为查询语句去建立索引。对于一些查询，我们甚至可以引入外援，比如我要对正文和标题进行搜索，用 ES 肯定比 LIKE 更为有效。而如果没有这些外援，就不应该将 content 纳入查询条件中。</p>

<p>其次，我们要知道索引 <code>created+author</code>和索引 <code>author+created</code>是不同的。但是有了 <code>author+created</code>，那么我们不需要再建立 <code>author</code> 的单列索引。因为我们前面介绍了树的构建，因此这一部分应该很好理解。</p>

<p>那么我们如何考虑我们到底应该建 <code>created+author</code>还是<code>author+created</code>呢？</p>

<p>在上面的例子中，created (+ author) 的效果会比 author + created 好很多，有一半是因为我们的 author 并不太具有选择性，在十万行数据中，我只安排了五个作者（而在实际场景下本人的博客只有一个作者），此时 author 几乎是无效的，因为 author 并不能减少数据规模。</p>

<p>但是让我们设想一个实际的视频平台，此时一个平台可能有几百万个用户，大部分用户的投稿数可能不超过十个，而从中先筛选用户，再筛选时间，就一定会比先筛选时间，再筛选用户要高效的多。</p>

<p>而站在业务的角度，我们可能会有很多基于用户的操作，比如控制展示权限、个人首页等等，author 在前的组合索引可以有大量的应用场景。</p>

<p>也就是说，对于组合索引来说两个比较常规的原则是：</p>

<ol>
<li>尽可能可复用的列作为前缀</li>
<li>选择性强的列作为前缀</li>
</ol>

<p>当然，尽管不同索引 cost 可能会相差一些数量级，但在实际执行上，可能就差了几毫秒，相比全表扫描来其实还是少了不少的。因此不一定要强迫自己以最佳实践命中每一个索引，需要一定的取舍：</p>

<p>比如，假设一个用户的投稿数经过筛选过后只剩下 10 条，此时组合索引中有无 created 并没有多大的差别。</p>

<h2 id="为何有的时候没有命中索引">为何有的时候没有命中索引</h2>

<p>假设我们建立了 <code>created</code>索引，然后执行下面的语句：</p>

<pre><code class="language-sql">SELECT id, title, content FROM test.blog_posts WHERE author=&quot;skyao&quot; ORDER BY created DESC
</code></pre>

<p><img src="https://img.codesky.me/codesky/mysql-index-design/Pasted%20image%2020240901222006.png" alt="" /></p>

<p>Explain 后你会发现他依旧是全表扫描，并没有命中索引。</p>

<p>这是因为 MySQL 在查询分析后发现，这玩样儿还不如直接读表快呢。这就涉及到了一个问题：回表。</p>

<h2 id="回表">回表</h2>

<p>尽管我们的索引加快了查询过程，但是光一棵索引树并不能拿到我们需要的全量数据，比如在这个语句中，我们需要 title 和 content，而这两条数据在索引树中并不存在，所以需要拿着 id 再去表中查找。</p>

<p>在上面的例子中，无论怎么样都相当于读了一遍全表，因此 MySQL 分析后决定不使用这个索引。</p>

<p>在「回表」这个步骤中隐藏的另一个暗示是：如果我们只需要索引树中的值，那不就不需要回表了吗？性能会更快。</p>

<p>这是正确的，以视频网站为例，我们拿到了某个活动的参与者后，需要显示参与者头像，那么我们先在活动表里筛选出用户，再去用户信息表查询数据。</p>

<p>如果我们的索引是 activity_id, user_id 且我们只需要 user_id，那么此时我们的 SQL 如果是：</p>

<pre><code class="language-sql">SELECT user_id FROM activity WHERE activity_id = 1
</code></pre>

<p>会比下面的要高效不少：</p>

<pre><code class="language-sql">SELECT * FROM activity WHERE activity_id = 1
</code></pre>

<p>实际上很多 ORM 都会默认将所有的表字段去除，这在某些情况下会极大的拖累响应速度，包括查询速度和传输速度。</p>

<p>在我们的例子中如果我们不需要 content，那么响应耗时会大大减少。</p>

<h2 id="mongodb-呢">MongoDB 呢</h2>

<p>MongoDB 的 Explain 形如：</p>

<pre><code class="language-sql">db.blog_posts.find({ author: 'skyao' }).sort({ created: -1 }).explain();
</code></pre>

<p>且因为 MongoDB 是文档数据库，因此在列内也支持更为灵活的索引和不同的索引类型，但由于索引本身还是一棵树，所以在原理上差别并不大。（我也是顺便用的，所以目前没有做很详细的研究）。</p>

<h2 id="总结">总结</h2>

<p>在本文中我们主要介绍了索引的一些常识和设计的一些小技巧——</p>

<p><strong>如果你发现命中了索引，但是也没有很快，记得看看是不是索引有用，但不多。</strong></p>

<p>当然，并不是设计好了索引，查询优化就结束了，上面也介绍了一些「SQL 不应该这么写」的部分，导致接口慢这个结果的原因并不是一个索引没加好就完成了的。这一篇写不动了……下一篇再见。</p>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Sun, 01 Sep 2024 22:54:00 +0800</pubDate>
    </item>
    <item>
      <title>Redis 中使用键空间监听 key 过期消息</title>
      <link>https://www.codesky.me/archives/go-redis-key-notification.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;距离上一次更新已经超过一个月了，是月更博主对不起大家了！&lt;/p&gt; &lt;/blockquote&gt;  &lt;p&gt;主要是因为之前有一阵子业务比较忙，因此一直在加班，��...</description>
      <content:encoded><![CDATA[<blockquote>
<p>距离上一次更新已经超过一个月了，是月更博主对不起大家了！</p>
</blockquote>

<p>主要是因为之前有一阵子业务比较忙，因此一直在加班，没有空看其他的东西（又不愿意牺牲打游戏和看剧的时间），最近一有时间就在写 Demo，这几天刚写完，才能更新这篇文章。</p>

<h2 id="背景故事">背景故事</h2>

<p>这个需求也是在我们业务落地过程中衍生出来的，因此先来说说之前一阵子忙的东西吧。</p>

<p>在公司内做的服务因为有各种基建的加持，所以想要实现一些功能很容易，比如说标题写的东西，或者是 binlog 订阅消费；但是在 to B 私有化部署的场景下，客户机千奇百怪，就要求我们用尽可能少的依赖和简单的部署架构进行实现，肯定也不会有公司里这么多花里胡哨的依赖。</p>

<p>为此简化了不少架构和功能，牺牲了不少体验之后才给接入我们基建的用户怼上一个版本。</p>

<p>而其中一个诉求就是我们的功能需要（Nice to have）订阅过期键并广播给订阅用户。</p>

<!--more-->

<h2 id="需求分析">需求分析</h2>

<p>要满足这个需求，就需要一个支持下面两种能力的程序：</p>

<ol>
<li>能够订阅过期键事件</li>
<li>能够确保消费的可靠性</li>
</ol>

<p>理论上来说，只要能有过期时间监听，那么就能跟 MySQL binlog 订阅一样进行数据消费，而 Redis 确实有个叫 <code>keyspace notifications</code>（也就是标题中的键空间通知），可以为 Redis 变更推送事件。</p>

<p>但众所周知，我们写的不是 Demo，而是生产需要落地的一个功能。为此，我们需要一定程度上保障通知的可达性。</p>

<p>在键空间通知里明确表明：</p>

<blockquote>
<p>Note: Redis Pub/Sub is <em>fire and forget</em>; that is, if your Pub/Sub client disconnects, and reconnects later, all the events delivered during the time the client was disconnected are lost.</p>
</blockquote>

<p>换言之，如果我们直接使用，那么你服务宕机了，发版了，可能中间的数据就会永久性丢失，这一部分通知也就不到位了。</p>

<p>因此我们需要设计一个补偿措施，来保证「即使我的服务挂了，之后依旧能够补偿通知挂了期间的这一部分消息」。</p>

<h2 id="想法-设计补偿">想法：设计补偿</h2>

<p>在过去的研发中，其实我们也设计过很多补偿策略了，比如事务回滚和重试。尤其是重试，诸如放进一个重试队列、过段时间再执行之类的思路是常规解法。</p>

<p>但是在 Redis 中却和 MySQL 的 <code>binlog</code> 有着明显的区别：他并没有一个持久化可读的内容——MySQL 中的 binlog 本质上是个文件，而文件就决定了，就算我失败了，那从失败行重新读就行了。</p>

<p>而 Redis 通知直接丢了，也不会帮我放进一个池子里，那么就需要自己实现一个持久化的池子，才能进行「补偿」操作。</p>

<p>当然，因此 expire 触发的时间点是不能抢救了，那么能抢救的就只有 key 入库的时间，假设 key 入库时：<code>SET key value EX [duration]</code>，那么我们默认在 <code>[duration]</code>时间后 key 会过期，且会发送通知。</p>

<p>那么要设计补偿，理论上我只需要一个定时任务，把一定时间内的未通知 key 捞出来过一遍就可以了。</p>

<h2 id="开干-队列设计">开干：队列设计</h2>

<p>本着多快好省不引入外部依赖的思路，对于队列，我们同样使用 Redis 来存储队列（本来是想用 MQ 的，写 Demo 又费劲，又不符合私有化部署降本的策略）。在 <code>SET</code> 的同时需要存储一条记录到队列，而其结构中应该包括：key 和到期时间两个。</p>

<p>从逻辑的角度来说，key 过期监听的补偿=该到期的 key - 已经成功发送通知的 key。</p>

<p>从这个角度来看，我们每次只要把 expireTime &lt; now 的值捞出来就可以了。而 Redis 的 <code>zset</code> 正好可以匹配这一诉求，我们把 key 作为值，time 作为 score，这样通过 <code>zrange</code> 就可以很方便的捞出时间序并移除。</p>

<h2 id="实现">实现</h2>

<p>有了监听、有了补偿，我们就可以开始进行实现了。</p>

<h3 id="redis-配置">Redis 配置</h3>

<p>首先是最基本的 Redis 配置，要想让 Redis 支持消息推送，需要再 redis 的配置（redis.conf）中加入（或者找到被注释的行）<code>notify-keyspace-events</code>，文档中写明了可以配置的值：</p>

<pre><code>K     Keyspace events, published with __keyspace@&lt;db&gt;__ prefix.
E     Keyevent events, published with __keyevent@&lt;db&gt;__ prefix.
g     Generic commands (non-type specific) like DEL, EXPIRE, RENAME, ...
$     String commands
l     List commands
s     Set commands
h     Hash commands
z     Sorted set commands
t     Stream commands
d     Module key type events
x     Expired events (events generated every time a key expires)
e     Evicted events (events generated when a key is evicted for maxmemory)
m     Key miss events (events generated when a key that doesn't exist is accessed)
n     New key events (Note: not included in the 'A' class)
A     Alias for &quot;g$lshztxed&quot;, so that the &quot;AKE&quot; string means all the events except &quot;m&quot; and &quot;n&quot;.
</code></pre>

<p>对于只需要订阅过期消息的我们来说只要写：<code>notify-keyspace-events Ex</code> 就可以了。（然后记得重启）</p>

<p>当然，你也可以使用 redis-cli 来启用，类似于这样：<code>redis-cli -h &lt;node-host&gt; -p &lt;node-port&gt; config set notify-keyspace-events Ex</code></p>

<h3 id="监听实现">监听实现</h3>

<p>这里都以 Golang 为例，完整的代码可见：<a href="https://github.com/csvwolf/go-redis-watcher-demo/tree/master" target="_blank">https://github.com/csvwolf/go-redis-watcher-demo/tree/master</a></p>

<p>监听的实现主要靠 pubsub，一个简单的 Demo 例子类似于：</p>

<pre><code class="language-go">func Watch(ctx context.Context) {
	// __keyevent@0__:* 监听的空间，也可以是 *__:*
	pubsub := w.redisClient.PSubscribe(ctx, &quot;__keyevent@0__:*&quot;)

	defer pubsub.Close()

	ch := make(chan struct{}, 10)

	for {
		msg, err := pubsub.ReceiveMessage(ctx)
		if err != nil {
			fmt.Println(&quot;Error receiving message:&quot;, err)
			continue
		}
		ch &lt;- struct{}{}

		go func(msg *redis.Message) {
			// 提取事件类型
			eventType := strings.TrimPrefix(msg.Channel, &quot;__keyevent@0__:&quot;)

			// 根据事件类型做不同处理
			switch eventType {
			case &quot;expired&quot;:
				fmt.Printf(&quot;Key expired: %s\n&quot;, msg.Payload)
				// 在这里处理过期事件
			case &quot;del&quot;:
				fmt.Printf(&quot;Key deleted: %s\n&quot;, msg.Payload)
				// 在这里处理删除事件
			case &quot;set&quot;:
				fmt.Printf(&quot;Key set: %s\n&quot;, msg.Payload)
				// 在这里处理设置事件
			default:
				fmt.Printf(&quot;Unhandled event %s for key %s\n&quot;, eventType, msg.Payload)
			}
			&lt;-ch
		}(msg)
	}

}
	}
</code></pre>

<p>这里用 go routine 适当提升吞吐处理，这是个最简单的版本，只是用于验证你的 Redis 确实正常启用了键空间。</p>

<p>之后我们将持续优化并且将之前想到的各个部分补充上去。</p>

<h3 id="补偿队列">补偿队列</h3>

<p>上面只做了最基本的监听，根本没有结合「补偿」，根据我们之前的想法，构造一个 zset，因此我们封装了一个全新的 <code>Set</code>（最佳的实现来看，这里最好是事务的，也就是用 Lua 脚本，这里图省事就简单示意一下）：</p>

<pre><code class="language-go">func (r *RedisClient) Set(ctx context.Context, key string, value interface{}, expire time.Duration) error {
	var (
		err error
		now = time.Now().Add(expire)
	)

	if err = r.client.Set(ctx, key, value, expire).Err(); err != nil {
		return err
	}
	if expire == 0 {
		return nil
	}
	// 队列用于补偿
	if err = r.client.ZAdd(ctx, 'queue', redis.Z{Member: key, Score: float64(now.UnixMilli())}).Err(); err != nil {
		return err
	}
	return nil
}

</code></pre>

<p>同时，我们还需要设计获取和移除补偿队列的内容：</p>

<pre><code class="language-go">func (r *RedisClient) GetKeysByTime(ctx context.Context, endTime time.Time) ([]string, error) {
	return r.client.ZRangeByScore(ctx, r.queueKey, &amp;redis.ZRangeBy{Min: &quot;0&quot;, Max: strconv.FormatInt(endTime.UnixMilli(), 10)}).Result()
}

func (r *RedisClient) RemoveFromQueue(ctx context.Context, members ...interface{}) *redis.IntCmd {
	return r.client.ZRem(ctx, r.queueKey, members...)
}
</code></pre>

<p>这里本质上我们就不用在意执行失败了，因此假设获取失败了，那下一个 <code>tick</code> 再获取就好了，只是补偿时间延长了。而如果删除失败了，更不是问题，<strong>只要你的操作是幂等的就行</strong>。</p>

<p>最终我们得到了一个补偿任务的所有内容：</p>

<pre><code class="language-go">type EventType string

const (
	Expired EventType = &quot;expired&quot;
	Del     EventType = &quot;del&quot;
	Set     EventType = &quot;set&quot;
)

type Callback = func(action EventType, key string)

// MakeUpTask 补偿任务
func (w *Watcher) makeUpTask() {
	// 补偿在10秒前的所有值
	var (
		timer   = time.Now().Add(-10 * time.Second)
		members []interface{}
	)
	result, err := w.redisClient.GetKeysByTime(w.ctx, timer)
	if err != nil {
		fmt.Printf(&quot;Watcher makeup failed: err=%v&quot;, err)
		return
	}
	if len(result) == 0 {
		return
	}
	fmt.Println(time.Now().String(), &quot;补偿 keys:&quot;, result)
	for _, r := range result {
		// callback：需要执行的处理函数
		w.callback(Expired, r)
		members = append(members, r)
	}

	err = w.redisClient.RemoveFromQueue(w.ctx, members...).Err()
	if err != nil {
		fmt.Printf(&quot;Watcher makeup failed: err=%v&quot;, err)
	}
	return
}
</code></pre>

<p>在这里我们定义了一个 <code>10s</code> 的时间，虽然这是一个拍脑袋定的值，但是请注意：</p>

<p>Redis 发送事件的事件是 Redis 移除键的时间，但是 expire 时间到了并不是实时触发移除的，会取决于过期键设置的删除策略：</p>

<blockquote>
<p>Expired (<code>expired</code>) events are generated when the Redis server deletes the key and not when the time to live theoretically reaches the value of zero.</p>
</blockquote>

<p>而在监听的处理中，我们同样用这个 <code>callback</code> 做处理，并且在成功消费之后从补偿队列中移除对应的 key，代码就抽象为了：</p>

<pre><code class="language-go">func (w *Watcher) watchHandler(ctx context.Context, pubsub *redis.PubSub, callback Callback) {
	msg, err := pubsub.ReceiveMessage(ctx)
	if err != nil {
		fmt.Println(&quot;Error receiving message:&quot;, err)
	}
	splitPrefix := strings.Split(w.channel, &quot;:&quot;)
	if len(splitPrefix) == 0 {
		fmt.Println(&quot;msg unknown:&quot;, msg)
		return
	}
	eventType := strings.TrimPrefix(msg.Channel, fmt.Sprintf(&quot;%s:&quot;, splitPrefix[0]))
	callback(EventType(eventType), msg.Payload)
	// 执行成功，则删除补偿队列中的 key
	w.redisClient.RemoveFromQueue(ctx, msg.Payload)
}
</code></pre>

<h3 id="cluster-mode">Cluster Mode</h3>

<p>在官方的 keyspace notifications 中会告诉你：</p>

<blockquote>
<p>Every node of a Redis cluster generates events about its own subset of the keyspace as described above. However, unlike regular Pub/Sub communication in a cluster, events&rsquo; notifications <strong>are not</strong> broadcasted to all nodes. Put differently, keyspace events are node-specific. This means that to receive all keyspace events of a cluster, clients need to subscribe to each of the nodes.</p>
</blockquote>

<p>简单总结就是尽管 Pub/Sub 其实是可以跨节点的，但是 keyspace notification 却不能，你需要监听每个节点：</p>

<pre><code class="language-go">func (r *RedisClusterClient) PSubscribe(ctx context.Context, channels ...string) []*redis.PubSub {
	var (
		pubsubs []*redis.PubSub
		mut     sync.Mutex
	)
	r.client.ForEachShard(ctx, func(ctx context.Context, client *redis.Client) error {
		// Function 是并发的，需要加锁
		mut.Lock()
		pubsubs = append(pubsubs, client.PSubscribe(ctx, channels...))
		mut.Unlock()
		return nil
	})
	return pubsubs
}
</code></pre>

<p>补充：cluster mode 也要记得 keyspace notification 需要是启用的，否则订阅不到，关于如何启用请参考 「Redis 配置」部分。</p>

<h2 id="总结">总结</h2>

<p>完整代码见：<a href="https://github.com/csvwolf/go-redis-watcher-demo/tree/master" target="_blank">https://github.com/csvwolf/go-redis-watcher-demo/tree/master</a></p>

<p>在实际的代码中还包括了池化手段，定时任务的简单实现，在本文中没有详细描述。</p>

<p>这里是一些实际生产中的建议：</p>

<ul>
<li>如果你的实际处理函数是一些耗时任务，建议仍然加入可靠队列，比如 Redis 的 Stream 或者 MQ，再由队列进行分发和消费</li>
<li>请保证处理函数的设计是幂等的，避免重复执行造成的影响</li>
</ul>

<p>尽管这里确实实现了一个相对可靠的实现，但是个人认为订阅删除键仍然是一个不够可靠的方法，可以有选择的使用（或者干脆算出时间塞 MQ 得了）。</p>

<p>（OS：真不知道公司是咋搞的保证可靠性的）</p>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Mon, 26 Aug 2024 21:07:03 +0800</pubDate>
    </item>
    <item>
      <title>AI：Make 死宅 Great Again</title>
      <link>https://www.codesky.me/archives/ai-make-otaku-greate-again.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;今天这篇文章主要是因为组内有 AI 分享，因此被迫营业了一回，本人平时并不怎么研究 AI，因此本文依旧是从使用和介绍的角度的个人锐评...</description>
      <content:encoded><![CDATA[<blockquote>
<p>今天这篇文章主要是因为组内有 AI 分享，因此被迫营业了一回，本人平时并不怎么研究 AI，因此本文依旧是从使用和介绍的角度的个人锐评，并没有涉及原理知识，望周知。</p>
</blockquote>

<h2 id="开篇介绍">开篇介绍</h2>

<p>之前我们介绍过「AI 老婆」，如何让你的老婆来模拟唱歌，今天介绍的是应用面更广的「翻译」。（下次再抽到分享我可就没活了）</p>

<p>目前相信大家也一直在用 AI 翻译来读文章、看教程、看 Youtube，并且或许你会觉得它还挺好用，翻译是不是要失业了——本文就将通过一些工具的实际使用效果和预期来判断「翻译是否会失业」。</p>

<!--more-->

<p>首先，我们先来了解下身为一个（不怎么懂日语的）二次元，需要有哪些翻译：</p>

<ol>
<li>视频翻译（番剧 / Live / 生放送等等）</li>
<li>音频翻译（广播）</li>
<li>游戏翻译</li>
<li>轻小说翻译</li>
<li>漫画翻译</li>
</ol>

<p>而接下来我们一边科普翻译流程，一边看目前的一些解决方案。</p>

<h2 id="视频翻译">视频翻译</h2>

<h3 id="传统翻译流程">传统翻译流程</h3>

<p>这是一个视频翻译的传统流程（换行主要是一行放不下，没有别的意思）：</p>

<p><img src="https://img.codesky.me/codesky/ai-otako/whiteboard_exported_image.png" alt="翻译流程" title="" /></p>

<p>其中：</p>

<ul>
<li>听写：如果有原版字幕可以直接使用原版字幕</li>
<li>注释：用来解决一些背景解释，方便没有专业背景的读者理解</li>
<li>特效：SRT 字幕只有时间信息，一般内压都会选择 ASS 更精致</li>
<li>压制：内置字幕，外挂字幕可以省略</li>
</ul>

<p>参考：<a href="https://www.gcores.com/articles/183193" target="_blank">https://www.gcores.com/articles/183193</a></p>

<p>关于注释目前见到的最牛的是旋风管家的民间翻译，里面用了大量日本本土和二次元梗，竟然一个没漏，连角落都没放过的加上了。（但被 B 站荼毒，成为正版受害者）</p>

<p><img src="https://img.codesky.me/codesky/ai-otako/Pasted%20image%2020240823225358.png" alt="请输入图片描述" title="" /></p>

<p>特效可以看 Live 中，日式 Live 女团中大部分情况下会加特效+应援色。比如：
<img src="https://img.codesky.me/codesky/ai-otako/Pasted%20image%2020240823225601.png" alt="请输入图片描述" title="" />
可以说，能让一个熟肉浑然天成也是一种手艺活。</p>

<h3 id="ai-机翻">AI 机翻</h3>

<p>AI 的流程主要可以替代的部分是：</p>

<ol>
<li>听写：也就是语音识别</li>
<li>打轴：识别的同时打上时间段</li>
<li>翻译：AI 主要工作的部分</li>
</ol>

<p>这里就介绍两个工具，大家之后也可以自己玩玩。</p>

<h4 id="aatv">AATV</h4>

<p>项目地址：<a href="https://github.com/Chenyme/Chenyme-AAVT" target="_blank">https://github.com/Chenyme/Chenyme-AAVT</a></p>

<p><img src="https://img.codesky.me/codesky/ai-otako/Pasted%20image%2020240823230109.png" alt="请输入图片描述" title="" />
Features：</p>

<ul>
<li>支持识别和翻译<strong>多种语言</strong></li>
<li>支持 <strong>全流程本地化、免费化部署</strong></li>
<li>支持对视频 <strong>一键生成博客内容、营销图文</strong></li>
<li>支持 <strong>自动化翻译</strong>、<strong>二次修改字幕</strong>、<strong>预览视频</strong></li>
<li>支持开启 <strong>GPU 加速</strong>、<strong>VAD 辅助</strong>、<strong>FFmpeg 加速</strong></li>
<li>支持使用 <strong>ChatGPT</strong>、<strong>Claude</strong>、<strong>Gemini</strong>、<strong>DeepSeek</strong> 等众多大模型翻译引擎</li>
</ul>

<p>更新的非常勤快（年轻真好），因为我使用的时候的截图它还不长这样……（它还是下面这样的）</p>

<p><img src="https://img.codesky.me/codesky/ai-otako/Pasted%20image%2020240823230109.png" alt="请输入图片描述" title="" />
本地模型下载：<a href="https://huggingface.co/Systran" target="_blank">https://huggingface.co/Systran</a></p>

<p>如果你本来就不会校对、也不懂怎么做 ASS 字幕，这个处理完相当于一步到位的 AI 成品，可以直接发布。</p>

<p><strong>调参和换模型仍然是重要的，好的显卡大的内存也是重要的。——来自 Memory</strong> <strong>OOM</strong> <strong>的总结</strong></p>

<p>视频效果可见：</p>

<iframe width="560" height="315" src="https://www.youtube.com/embed/TryQnjECIOY?si=5iBFih-EV-NuCpce" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

<p>可以看到在句子连续的情况下没什么问题，但是在梗、人名、专有名词和断句上仍然存在问题，甚至会导致漏句。</p>

<blockquote>
<p>适合场景：非大型视频网站视频搬运（比如 Niconico?）（因为 y2b 字幕外挂太多，根本用不上，除非你要搬到 B 站） 动画也可以，但是这年头里番都有字幕组汉化了，不会真的有人看这么冷的吧。</p>
</blockquote>

<h4 id="linly-dubbing">Linly-Dubbing</h4>

<p>项目地址：<a href="https://github.com/Kedreamix/Linly-Dubbing" target="_blank">https://github.com/Kedreamix/Linly-Dubbing</a></p>

<p>Features：</p>

<ul>
<li><strong>多语言支持</strong>: 支持中文及多种其他语言的配音和字幕翻译，满足国际化需求。</li>
<li><strong>AI 智能语音识别</strong>: 使用先进的AI技术进行语音识别，提供精确的语音到文本转换和说话者识别。</li>
<li><strong>大型语言模型翻译</strong>: 结合领先的本地化大型语言模型（如GPT），快速且准确地进行翻译，确保专业性和自然性。</li>
<li><strong>AI 声音克隆</strong>: 利用尖端的声音克隆技术，生成与原视频配音高度相似的语音，保持情感和语调的连贯性。</li>
<li><strong>数字人对口型技术</strong>: 通过对口型技术，使配音与视频画面高度契合，提升真实性和互动性。</li>
<li><strong>灵活上传与翻译</strong>: 用户可以上传视频，自主选择翻译语言和标准，确保个性化和灵活性。</li>
<li><strong>定期更新</strong>: 持续引入最新模型，保持配音和翻译的领先地位。</li>
</ul>

<p><img src="https://img.codesky.me/codesky/ai-otako/Pasted%20image%2020240823230109.png" alt="请输入图片描述" title="" /></p>

<blockquote>
<p>这个工具特别适合流水线搬运视频，我们在上网冲浪时经常会看到搬运的配了音的科普视频，有了这个，你可以很轻松的当搬运区 UP。</p>
</blockquote>

<h2 id="二次元特攻模型">二次元特攻模型</h2>

<p>在开始其他内容的介绍前，先介绍一下基准技术：SakuraLLM。</p>

<p>项目地址：<a href="https://github.com/SakuraLLM/SakuraLLM/tree/main?tab=readme-ov-file" target="_blank">https://github.com/SakuraLLM/SakuraLLM/tree/main?tab=readme-ov-file</a></p>

<blockquote>
<ul>
<li>基于一系列开源大模型构建，在通用日文语料与轻小说/Galgame等领域的中日语料上进行继续预训练与微调，旨在提供开源可控可离线自部署的、ACGN风格的日中翻译模型。</li>
</ul>
</blockquote>

<p>接下来几种翻译形式大部分都是基于 SakuraLLM 落地的实际工具。</p>

<h2 id="游戏翻译">游戏翻译</h2>

<p>游戏翻译，尤其是 Galgame 翻译也是一个考验语言功底的内容，举两个非常简单的个人认为现阶段机翻无法替代的内容：</p>

<p><img src="https://img.codesky.me/codesky/ai-otako/image%20(1).png" alt="请输入图片描述" title="" /></p>

<blockquote>
<p><strong>秋深し、隣はなにも、しない人 晨意微寒秋渐深，闲伴无事俏佳人</strong>
<strong>秋深し、情けは人の、為ならず 梦里不觉秋已深，余情岂是为他人</strong>
——白色相簿 2</p>
</blockquote>

<p>我在尽可能的让机翻翻译的工整对账，充满文艺气息的版本是：「秋意深浓，邻家静坐，善意施人，非为他人。」</p>

<p>从意境的角度还是有一定差距。</p>

<p>此外，当我们谈论游戏的时候，就不得不意识到上下文连贯和本地化的重要意义。比如「逆转裁判」中使用了大量的谐音梗，甚至作为了案子线索的一部分，如果直译的话最坏的结果就是游戏根本打不下去，关于逆转裁判用了多少谐音梗，可见：<a href="https://www.bilibili.com/read/cv34832033/?jump_opus=1" target="_blank">https://www.bilibili.com/read/cv34832033/?jump_opus=1</a></p>

<h3 id="based-on-sakurallm">Based On SakuraLLM</h3>

<p>说了这么多，日语苦手至少多了一个选择（也不是不能玩），以下几个基于 SakuraLLM 的如果有需要都可以试试。</p>

<blockquote>
<p>本来我确实想试试，奈何找了几个 Gal 都是有汉化的……</p>
</blockquote>

<ul>
<li><a href="https://github.com/HIllya51/LunaTranslator" target="_blank">LunaTranslator</a>：Galgame在线翻译</li>
<li><a href="https://github.com/XD2333/GalTransl" target="_blank">GalTransl</a>：Galgame离线翻译，制作补丁</li>
<li><a href="https://github.com/NEKOparapa/AiNiee-chatgpt" target="_blank">AiNiee</a>：RPG游戏翻译（适合 RPG Maker）</li>
</ul>

<p>巴哈姆特的 LunaTranslator 教程细的很：<a href="https://forum.gamer.com.tw/C.php?bsn=60599&amp;snA=41884" target="_blank">https://forum.gamer.com.tw/C.php?bsn=60599&amp;snA=41884</a></p>

<h3 id="renpythief">RenpyThief</h3>

<p>支持 Unity 之类的游戏，肉眼来看相比上面几个至少多支持了一种引擎，缺点是有免费额度，多了要钱（配置 OpenAPI Key 怎么不能算一种要钱呢）。更像是一个成熟的商业产品。</p>

<p>项目地址：<a href="https://lion.craft.me/RenpyThief" target="_blank">https://lion.craft.me/RenpyThief</a></p>

<p>NGA 老哥表示：<a href="https://nga.178.com/read.php?tid=38871232&amp;rand=174" target="_blank">https://nga.178.com/read.php?tid=38871232&amp;rand=174</a></p>

<h2 id="轻小说翻译">轻小说翻译</h2>

<p>轻小说在传统的翻译中面临的主要挑战是背景解释和译者吐槽（其实一部分人也不喜欢看译者吐槽，觉得有点 OOC）。总的来说有了 SakuraLLM 基本属于专业对口，关于落地应用可以直接看下面两个：</p>

<ul>
<li><a href="https://books.fishhawk.top/" target="_blank">https://books.fishhawk.top/</a></li>
<li><a href="https://github.com/FishHawk/auto-novel" target="_blank">https://github.com/FishHawk/auto-novel</a></li>
</ul>

<h2 id="漫画翻译">漫画翻译</h2>

<h3 id="传统翻译流程-1">传统翻译流程</h3>

<p>和动画翻译一样，针对漫画翻译，我们再从传统翻译流程开始讲起：</p>

<p><img src="https://img.codesky.me/codesky/ai-otako/whiteboard_exported_image%20(1).png" alt="请输入图片描述" title="" />
可能会给大家困扰的点是：「修图」和「嵌字」有什么区别。</p>

<p>因此我在网上找了一张比较好理解的解释图：</p>

<p><img src="https://img.codesky.me/codesky/ai-otako/whiteboard_exported_image%20(1).png" alt="请输入图片描述" title="" />
简单的来说，漫画不仅仅是由气泡框组成的，一些漫画背景中也会有文本量，这种时候就更需要 PS 技巧了。</p>

<h3 id="机翻">机翻</h3>

<p>在嵌字的翻译上，目前漫画翻译项目已经做的很不错了：<a href="https://github.com/zyddnys/manga-image-translator" target="_blank">https://github.com/zyddnys/manga-image-translator</a>，效果如下：</p>

<table>
<thead>
<tr>
<th><img src="https://img.codesky.me/codesky/ai-otako/Pasted%20image%2020240824113629.png" alt="!\[\[Pasted image 20240824113605.png\]\]" title="" /></th>
<th><img src="https://img.codesky.me/codesky/ai-otako/Pasted%20image%2020240824113629.png" alt="!\[\[Pasted image 20240824113629.png\]\]" title="" /></th>
</tr>
</thead>

<tbody>
</tbody>
</table>
<p>但是在修图上差的还是有点远的。</p>

<p>不过这个工具不仅支持英语日语，还支持韩语，属实解决了大家看漫画的所有核心痛点了。</p>

<h2 id="总结">总结</h2>

<p>如果大家光看一些英文冲浪的普通翻译来说，可能真的会得出「翻译岗位已经没有人要」的结论。</p>

<p>但是从上面的分析来看，垂类领域翻译，尤其是会做本地化的翻译还是非常重要的，虽然也不能说多年之后的发展会怎么样，但现阶段，咱们还是感谢带中文的游戏和民间汉化组吧。</p>

<p>另外：跑这个真吃 GPU，还好我换了新电脑。</p>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Sat, 24 Aug 2024 13:01:49 +0800</pubDate>
    </item>
    <item>
      <title>新电脑纪念：Windows 平替 Mac 尝试</title>
      <link>https://www.codesky.me/archives/mac-to-windows.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;总算在 618 换了新电脑，写篇文章简单纪念下。 本文（基本）不涉及代码，望周知。&lt;/p&gt; &lt;/blockquote&gt;  &lt;p&gt;在写上一篇稿子的时候，甚至早在之��...</description>
      <content:encoded><![CDATA[<blockquote>
<p>总算在 618 换了新电脑，写篇文章简单纪念下。
本文（基本）不涉及代码，望周知。</p>
</blockquote>

<p>在写上一篇稿子的时候，甚至早在之前写那篇 AI 相关稿子的时候，就发现我的 2017 年的台式机和 2019 intel core 的 Mac 可以说是相当不给力，第一没法指望 GPU 加速、第二实在是卡的不行，风扇呼呼转，愣是连写个稿子都卡（主要是需要开大量的 Chrome Tab 查阅资料，这年头的网站真是一个比一个狠），三是公司的电脑是 M2 Pro，可以说是相当流畅，以至于我整天非常气氛于自己当初一万多买的 Mac 和八千买的台式机怎么就那么卡。</p>

<p>痛定思痛，公司的电脑也有限限制，还是得有一个自己的新电脑！</p>

<!--more-->

<p>买电脑的心路历程也是有些坎坷的，最开始的时候下单了零刻的迷你主机，但可能是因为品控问题，买回来的连接显示器没反应，而且巨烫无比，差点就把我烫伤，但是由于 Debug 的零配件缺失，所以只能连着内存和 SSD 和迷你主机一起退货了。</p>

<p>考虑再三后还是决定不省这个钱了，第一，还是 N 卡好，第二，Windows 的机器多少也比 Macbook 便宜。</p>

<p>最终买的组装机配置如下：</p>

<ul>
<li>CPU：Intel i7-14700KF</li>
<li>内存：DDR5 16G x 2</li>
<li>显卡：4070TI Super</li>
<li>硬盘：金士顿 1T SSD</li>
<li>主板：微星 Z790 GAMING PLUS</li>
</ul>

<h2 id="系统升级">系统升级</h2>

<p>预装了 Win11 家庭版，还顺便帮我装上了鲁大师和 360 浏览器（谁要这个啊！）</p>

<p>第一时间就是重装成专业版、系统重置和重新分区。</p>

<h3 id="专业版">专业版</h3>

<p>群晖的第三方源本身有一个套件叫「KMS 服务器」，启动后只有后台服务，没有任何前端配置。</p>

<p>然后在电脑上以管理员身份运行命令符：</p>

<pre><code class="language-shell">slmgr /upk # 卸载原来的产品密钥
slmgr /skms 192.168.x.xxx # ip 是你的群晖 IP
slmgr /ipk W269N-WFGWX-YVC9B-4J6C9-T83GX # Win11 专业版密钥
slmgr /ato # 激活
slmgr /xpr # 验证激活，查看激活时间
</code></pre>

<p>KMS 的密钥可见：<a href="https://learn.microsoft.com/zh-cn/windows-server/get-started/kms-client-activation-keys" target="_blank">https://learn.microsoft.com/zh-cn/windows-server/get-started/kms-client-activation-keys</a></p>

<h3 id="系统重置与重新分区">系统重置与重新分区</h3>

<p>在「设置」-&gt;「系统」-&gt;「恢复」中选择重置此计算机，不保留全部资料就能快速 reset。</p>

<p>分区在「系统」-&gt;「存储」-&gt;「磁盘和卷」中更改大小，可能要先把其他盘删除，把容量空出来再挪给 C 盘（总之根据经验先分了 200G，毕竟就算软件不在 C 装，还有各种软件的用户设置会往里写）。</p>

<h2 id="软件平替">软件平替</h2>

<p>在大学的时候 Windows to Mac 的时候我曾经寻找过一轮软件平替，到现在用了这么多年，已经习惯了 Mac 里的软件，拿互联网黑话来说就算「有了自己的方法论」，没想到风水轮流转，终于轮到 Mac to Windows 了（某种程度上可能可以算作消费降级？）</p>

<ul>
<li>Markdown 写作工具：Mweb -&gt; Obsidian，然后用群晖 Drive 做同步，就可以免费用到爽啦。目前本文也就是通过 Obsidian 写作而成的。</li>
<li>音视频格式转换工具：Permute -&gt; 格式工厂。格式工厂从小用到大，没想到 2024 年了还是得用格式工厂。</li>
<li>抓包：Proxyman</li>
<li>IDE：Jetbrains 全家桶</li>
<li>视频嗅探下载：Downie -&gt; vidjuice（还没有试用，只是听说的）</li>
<li>密码管理：1Password</li>
<li>API：Postman</li>
<li>Office：WPS</li>
<li>解压缩：系统自带的可以解决大部分场景，但是发现少部分解压缩不了的还得靠 winrar 之类的软件解决</li>
<li>Terminal：Windows Terminal</li>
<li>开发环境：WSL + Ubuntu，然后在结合上面的 Terminal 使用效果还可以。（默认会装在 C 盘，所以需要调整一下位置）。</li>
<li>快速启动器：Alfred -&gt; Powertoys Run。我的常见场景：软件快速启动、翻译、和剪贴板都有了，很稳。（强烈推荐 Powetoys，各种工具都很好用）</li>
<li>输入法：本来应该用搜狗没什么疑问的，但是搜狗竟然不支持自定义直角括号，一番研究之后决定使用百度输入法（所以本文是用百度输入法打的字）。</li>
</ul>

<h2 id="wsl">WSL</h2>

<pre><code>wsl --install
</code></pre>

<p>安装完 WSL 之后问题就是它在 C 盘了，要修改位置，需要再操作一下：</p>

<pre><code>wsl --shutdown # 先停止运行
wsl --export Ubuntu d:\ubuntu.tar # 导出镜像
wsl --unregister Ubuntu # 删除原来安装的环境
wsl --import Ubuntu d:\Ubuntu d:\ubuntu.tar # 导入
</code></pre>

<p>然后 WSL 就安装在 D 盘下了√。</p>

<p>接下来就按照 Ubuntu 的处理方式安装完 WSL 里需要的开发工具就可以了。</p>

<p>Jetbrains 全家桶可以直接连进 WSL 开发，之前用 Go 写了个 Demo，效果看上去挺无痕的。</p>

<p>物理磁盘的位置在 <code>/mnt/c</code> ，这样文件就可以在 Windows 的文件管理器中可见了。</p>

<h2 id="远程桌面">远程桌面</h2>

<p>远程桌面的方案我调整了半天，因为本身不是网线连接，所以有线唤醒 WOL 的方式肯定是用不了了，想要使用休眠或者睡眠然后再唤起的方案，调整了半天发现 WIFI 依旧会被断开，真的是从 BIOS 改到设备管理器，再改到注册表，然而并没有什么卵用，因为根据 <a href="https://learn.microsoft.com/zh-cn/windows-hardware/drivers/kernel/system-sleeping-states" target="_blank">Windows 的文档</a>，S3 就是会在睡眠后关闭 WIFI 的。</p>

<p>最终采取的解决方案是：老子不睡了！PowerToys 里有个「唤醒」工具保持不睡眠。（所以说 PowerToys 很好用吧）。</p>

<h2 id="系统美化">系统美化</h2>

<h3 id="diy">DIY</h3>

<p>桌面使用 Fences 归类一下，留下一个壁纸纯欣赏（虽然平时也不会用到，都是用快速启动器启动的）。</p>

<p>然后安装好自定义鼠标指针（目前鼠标指针是我的艾蕾老婆，Windows 在这方面真好啊）。</p>

<p>可惜了已经凉凉的 QQ 宠物，怀念 QQ 宠物在桌面上乱跑的感觉，找了几个桌面宠物类产品都不是很香，不好玩 -^-。</p>

<h3 id="丑陋的字体">丑陋的字体</h3>

<p>Chrome 发现访问站点中宋体的部分（部分文档的代码注释部分）很丑，在<code>chrome://settings/fonts</code> 中将等宽字体改成 <code>Cascadia Mono</code> ，感觉又好了起来，顺便还下了「思源宋体」感觉有需要的时候可以再配置抢救一下。</p>

<h2 id="总结">总结</h2>

<p>多年没有用 Windows，没想到 Windows 对于高分屏的支持度已经很不错了，而且也支持了多桌面，后续如果发现了新鲜货再来分享。</p>

<p>主要还是新电脑性能好呀（光拿来写文章是不是有点浪费了，但是打游戏的话其实 Switch + SteamDeck 已经能解决我的大部分游戏的诉求了，倒不如说现在垒着的游戏已经是打都打不完了）。</p>

<p>本文属于凑更新的同时尝试一下在 Windows 下写作，最近工作比较忙，加上还在研究习惯「Windows 的使用」（以及 FGO 又出了新剧情等等）。因此来不及收集其他技术文章的写作素材，先接着拖更了！</p>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Sat, 22 Jun 2024 21:29:46 +0800</pubDate>
    </item>
    <item>
      <title>大型迁移现场：腾讯云 to 阿里云大冒险</title>
      <link>https://www.codesky.me/archives/tecent-cloud-to-aliyun.wind</link>
      <description>&lt;blockquote&gt; &lt;p&gt;尽管欠了好多篇博文待更新，但还是需要插播一则近日消息，那就是：我把博客从腾讯云迁移到阿里云了。&lt;/p&gt; &lt;/blockquote&gt;  &lt;p&gt;关于为什么要��...</description>
      <content:encoded><![CDATA[<blockquote>
<p>尽管欠了好多篇博文待更新，但还是需要插播一则近日消息，那就是：我把博客从腾讯云迁移到阿里云了。</p>
</blockquote>

<p>关于为什么要迁移，是因为以前因为备案问题（指 .me 域名不能备案，但是旧备案流程没这么复杂的时候已经备上的不管），所以一直只能当腾讯云钉子户，但是它续费……实！在！太！贵！了！</p>

<p>三年 5 折算下来总计需要 1200 元，但是配置却是 1C1G 的盖中盖，而现在类似于阿里云之类的开发者上云套餐，只需要 99 一年，立省 1000/3y。</p>

<!--more-->

<p>但是另一个问题是，我的腾讯云里面的内容非常杂，虽然他只有 1C1G，但是里面其实跑了不少子域项目，并且由于时间久远，当时我甚至还会 PHP 和 Nginx，（然而现在我已经不会了），导致我根本不知道：</p>

<ol>
<li>跑了多少项目</li>
<li>这些项目到底是以什么姿势跑起来的</li>
</ol>

<p>上一次续费三年的时候，我就抱着供起来的心态强行续了一波，但是和 99 相比，这些困难都不值一提——直到我发现问题不止在数据迁移和服务迁移上。</p>

<h2 id="数据迁移">数据迁移</h2>

<p>数据迁移是其中最简单的，虽然曾经用过 MongoDB，但目前所有 Active 的项目都没有用 MongoDB 的，只有用 MySQL 的，所以用 MySQL 的 dump 就可以了。</p>

<p>成功的第一步：<code>mysqldump -uroot -p --all-databases &gt; export.sql</code>，轻轻松松，dump 我熟。</p>

<p>然而 <code>all-databases</code> 一时爽，很快我就遇到了：</p>

<pre><code>ERROR 3554 (HY000) at line 318: Access to system table 'mysql.innodb_index_stats' is rejected.
</code></pre>

<p>所以说，没事把业务表导出来就行了，怎么顺便把系统表也带上了呢，Stackoverflow 告诉我们，你应该进行这个操作来过滤系统表：</p>

<pre><code>mysqldump -u root -p --all-databases --ignore-table=mysql.innodb_index_stats --ignore-table=mysql.innodb_table_stats &gt; dump.sql
</code></pre>

<p>但此时我懒得重新 dump 一遍，主要是内容有点多，而 FTP 有点慢。</p>

<p>所以我光荣的选择了：<code>mysql -u root -p -f &lt; dump.sql</code>，<code>--force</code> 一时爽，一直 force 一直爽。</p>

<h2 id="项目迁移">项目迁移</h2>

<h3 id="文件迁移">文件迁移</h3>

<p>文件虽然散落在各地，但是 PHP / 纯静态项目都在指定目录下，而 Node / Golang 项目本身都是闭源/开源在 GitHub 中的，最多只要重新构建就可以了。</p>

<p>而 PHP/ 纯静态目录直接打个包就完事儿了。</p>

<h3 id="nginx-配置迁移">nginx 配置迁移</h3>

<p>nginx 配置比较麻烦主要是当年 PHP 配置的花，现在全都不会了，另一方面是配置的太过随意，所以我决定根据思路自己直接重写一下配置。</p>

<p>还好不知道是不是人变聪明了，php-fpm 的坑少踩了挺多。</p>

<p>关于 php-fpm + nginx，我直接根据：<a href="https://www.digitalocean.com/community/tutorials/php-fpm-nginx" target="_blank">https://www.digitalocean.com/community/tutorials/php-fpm-nginx</a> 一顿猛抄。</p>

<p>HTTPS 证书因为之前我迁移到了 Cloudflare，自然也是重新搞了一波，根据：<a href="https://certbot-dns-cloudflare.readthedocs.io/en/stable/" target="_blank">https://certbot-dns-cloudflare.readthedocs.io/en/stable/</a> 直接生成，理论上会注册一个 renew 任务，因此不用担心续期。</p>

<pre><code class="language-sh">certbot certonly --dns-cloudflare --dns-cloudflare-credentials ~/.secrets/cloudflare.ini -d *.codesky.me --preferred-challenges dns-01
</code></pre>

<p>然后再把证书配上就可以了：</p>

<pre><code>ssl_certificate /etc/letsencrypt/live/codesky.me-0001/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/codesky.me-0001/privkey.pem;
</code></pre>

<p>然后再配置伪静态（毕竟博客后缀是 .wind，当年装逼，现在还得负责擦屁股）：</p>

<pre><code>if (!-e $request_filename) {
    rewrite ^(.*)$ /index.php$1 last;
}
location ~ .*\.php(\/.*)*$ {
    fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
    fastcgi_index index.php;
    include fastcgi.conf;
}

location / {
    root   /var/www/html/typecho;
    index  index.php index.html index.htm;
}
</code></pre>

<p>再加上 <code>codesky.me</code> 到 <code>www.codesky.me</code> 的重定向：</p>

<pre><code>return       301 https://www.codesky.me$request_uri;
</code></pre>

<p>这样反复然后再把 xsky.me 也配了就行（相比之下反向代理真的太简单了）。</p>

<h3 id="typecho">Typecho</h3>

<p>当然在实际过程中，我发现要把 Typecho 搞上去，还是遇到了很多问题。</p>

<p>首先就是版本问题，因为默认 php 已经是 PHP 8.1 了，但我的 Typecho 版本并不支持，所以被迫升级了一波版本号，但是升级完成之后发现有函数报错了：<code>Fatal error: Call to undefined function mb_strlen()</code>。然后又参考：<a href="https://www.php.net/manual/en/mbstring.installation.php" target="_blank">https://www.php.net/manual/en/mbstring.installation.php</a> 安装了 mbstring：<code>apt install php-mbstring</code>。</p>

<p>DB 也报错了，是因为 php.ini 中需要开启 <code>pdo_mysql</code> 并且安装：<code>apt-get install php-common php-mysql php-cli</code>。</p>

<p>还好这次升级没有不兼容更新，所以总体问题不是很大。</p>

<h2 id="备案">备案</h2>

<p>好不容易历经千辛万苦总算可以在本地 <code>curl -H 'HOST: www.codesky.me' localhost</code> 跑通了，但是暴露到公网之后我发现！Fuck！备案需要重新处理。</p>

<p>按照现在的备案要求，不仅需要国内的机器+实名，还需要域名实名。</p>

<p>这下到了最难的部分了：.me 的域名，阿里云、腾讯云都不收，只有<a href="www.west.cn" target="_blank">西部数码</a>可以接受转入，但 transfer 也需要时间。</p>

<p>好不容易 transfer 完，域名实名也解决了，备案流程也真是长，唯一值得表扬的阿里云把因为备案导致机器空置的时长补给我了。</p>

<p>真的是，个人博客不仅倒贴钱，还倒贴了一堆时间，难怪个人站长药丸。</p>

<h2 id="总结">总结</h2>

<p>所以，PHP 现在没人爱是因为它部署起来太麻烦吗——</p>

<p>顺便，备案是因为又拍云的流量根本用不掉，但他只支持备案的域名，备案有时好办事，反正我还有 .moe 的域名拿来浪（每年的开销都在这了）。</p>

<p>经此一役我只能说，容器部署是好文明，但小机器真的带不动啊。</p>
]]></content:encoded>
      <author>敖天羽</author>
      <pubDate>Wed, 29 May 2024 00:14:49 +0800</pubDate>
    </item>
  </channel>
</rss>