一致性事务:从 2PC 到 Outbox pattern

前言:分布式系统里最让人头疼的事 如果你做过一点微服务开发,大概率都遇到过这种场景: 用户下单 → 扣库存 → 扣余额 → 更新积分。 这听上去很简单,按顺序写几行逻辑就好,但问题是——这些操作分散在不同的服务、不同的数据库里。 于是问题来了: 如果中间有一步失败了,整个流程该怎么办? 订单创建了,但库存没扣? 库存扣了,但支付失败? 消息没发出去,用户看不到状态? 这就是经典的分布式事务一致性问题。 今天我们就从最早的 2PC(两阶段提交)开始,一路讲到现在大家更常用的 Outbox 模式,看看这一路我们是怎么在”理想”和”现实”之间反复拉扯的。 一、理想中的完美方案:2PC(两阶段提交) 2PC,全称 Two Phase Commit。 听起来就很有那味儿:让多个数据库像一个事务一样提交或回滚。 它是怎么工作的? 顾名思义,它分两步走: Prepare 阶段(投票阶段) 协调者(Coordinator)告诉所有参与者(数据库、服务):”兄弟们,准备一下,看你们能不能提交。” 每个参与者检查本地事务是否能成功,如果没问题,就写好日志、锁住资源,回复一句”我准备好了(YES)”。如果不行,就回复”NO”。 Commit/Abort 阶段(执行阶段) 如果所有人都回复”YES”,协调者就广播”Commit!”,大家正式提交。 如果任何一个回复”NO”或超时,协调者就广播”Abort!”,大家全部回滚。 听上去很完美,对吧?所有操作都能保持一致,要么都成功,要么都失败。 举个例子 假设我们有订单服务、库存服务、支付服务。 在一次下单操作里: 协调者发送”Prepare”命令。 三个服务各自检查:订单能建、库存够、余额足。 都返回”YES”。 协调者发送”Commit”,大家一起完成事务。 理想中,这就是”强一致性”的完美世界。 可惜,现实很骨感 2PC 在实际应用中面临严重的工程问题: 1. 阻塞问题 参与者在 Prepare 阶段后会持有锁并等待协调者的最终指令。如果协调者响应慢或网络延迟,所有参与者都会阻塞,严重影响系统吞吐量。 2. 单点故障 如果协调者在发送最终决定前崩溃,参与者会因超时而回滚(相对安全) 但如果协调者在发送 Commit/Abort 的过程中崩溃(部分参与者收到消息,部分没收到),就会导致数据不一致: 收到 Commit 的节点已经提交 没收到消息的节点还在等待或已超时回滚 3. 网络问题 在网络状况的影响下,部分参与者可能收不到协调者的消息,导致长时间持锁或数据不一致。 4. 性能问题 同步阻塞的特性导致: 资源锁定时间长 并发能力差 响应时间受最慢节点影响 在高并发的互联网系统里,这种全局锁和同步等待非常致命。 所以 2PC 在实际分布式场景中,主要用于数据库内部实现,很少在应用层使用。 一句话总结: 2PC 理论完美,工程地狱。它保证了强一致性,但牺牲了可用性和性能。 二、退一步:Saga 模式(补偿事务) 既然”强一致”做不到,那我们退一步,追求”最终一致“。 这就是 Saga 模式的思路。 核心思想 不再要求”原子性地回滚”,而是让每个服务各自完成自己的本地事务。 如果中间某一步失败,就执行 “补偿操作”(Compensation),把前面的动作”撤销”掉。 换句话说: 不锁资源,出错就补。用业务逻辑补偿代替技术回滚。 Saga 的两种实现方式 1. 编排式(Orchestration) 由一个中央协调器负责调度各个步骤: 订单服务(协调器): 1. 调用库存服务扣库存 2. 调用支付服务扣款 3. 调用积分服务加积分 4. 如果任一步失败,依次调用补偿接口 2. 编舞式(Choreography) 各服务通过事件驱动自行协作: 订单服务 → 发布"订单已创建"事件 库存服务 → 监听事件,扣库存,发布"库存已扣"事件 支付服务 → 监听事件,扣款,发布"支付成功"事件 如果支付失败 → 发布"支付失败"事件 库存服务 → 监听到失败事件,执行库存补偿 举个例子:下单流程 Saga 化 假设业务流程: 创建订单(本地事务) 扣库存(本地事务) 扣余额(本地事务) 增加积分(本地事务) 如果第 3 步(扣余额)失败了,那就按相反顺序触发补偿逻辑: 恢复库存(补偿操作) 取消订单(补偿操作) 需要注意的关键问题 1. 补偿操作的设计难度 每个正向操作都要有对应的补偿操作 补偿操作必须幂等(可能被重复调用) 补偿操作可能不完美,需要业务妥协 2. 某些操作难以或无法补偿 ❌ 已发送的短信通知(无法撤回) ❌ 已发放且被使用的优惠券 ❌ 已调用的第三方支付(需要走退款流程,不是真正的”撤销”) ❌ 已发出的实物货品 3. 中间状态可见性 Saga 过程中,其他事务可能看到不一致的中间状态: 订单已创建,但支付还在处理中 库存已扣,但订单被取消(正在补偿) 这要求业务设计时考虑这些中间态。 优缺点 ✅ 优点: 性能好:不需要全局锁,各服务独立执行 可用性高:单个服务故障不会阻塞整个流程 适合长流程:支持跨天、跨周的业务流程 松耦合:各服务可以独立演进 ❌ 缺点: 补偿逻辑复杂:每个操作都要设计补偿,代码量翻倍 调试困难:分布式环境下追踪问题链路长 无法保证隔离性:中间状态对外可见 某些场景不适用:存在不可补偿的操作 一句话总结: Saga 是”可控的混乱”:牺牲了强一致性和隔离性,换来了性能和可用性。适合大部分业务场景,但需要精心设计。 三、现实中更优雅的方案:Outbox Pattern(事件表) 再往后,很多系统变成了事件驱动架构。 这时一个新问题出现了: 我更新了数据库后,要发一条消息到 MQ,但如果发 MQ 时系统挂了怎么办? 比如: 创建订单(DB 成功) 发送 “OrderCreated” 消息到 Kafka(失败或未执行) 数据库里已经有订单,但消息没发出去,下游服务不知道有新订单,系统就不一致了。 你可能会想:那我先发消息,再写数据库? 1. 发送消息到 Kafka(成功) 2. 创建订单(失败) 这样更糟:消息发出去了,但数据库里没有订单,下游收到的是”幽灵订单”。 核心问题是:这是两个不同的系统(数据库 + 消息队列),无法在一个事务中原子性地完成。 这时,Outbox Pattern 登场。 核心思路 既然我们不想搞分布式事务,那就干脆: 把”要发的消息”也当作业务数据写进数据库,和业务操作一起提交! 具体做法: 业务操作 + 消息落库(在同一个本地事务中) 插入/更新业务数据(如订单表) 同时插入一条待发送的消息到 outbox_messages 表 后台异步发送 定时任务/CDC/消息中继扫描 outbox_messages 表 找到未发送的消息 发送到 MQ 标记为”已发送” 这样,数据库保证了业务数据和消息记录的原子性,即使系统崩溃,重启后也能继续发送未完成的消息。 举个实际例子 业务代码: BEGIN TRANSACTION; -- 1. 插入订单 INSERT INTO orders (id, user_id, amount, status) VALUES (123, 42, 100, 'created'); -- 2. 插入待发送的消息(同一个事务) INSERT INTO outbox_messages ( id, aggregate_type, aggregate_id, event_type, payload, status, created_at ) VALUES ( 'msg-uuid-001', 'Order', 123, 'OrderCreated', '{"order_id":123,"user_id":42,"amount":100}', 'pending', NOW() ); COMMIT; 异步消息发送器: // 定时任务或 CDC 流程 func publishOutboxMessages() { for { // 查询待发送的消息(带锁,避免并发冲突) messages := db.Query(` SELECT id, event_type, payload FROM outbox_messages WHERE status = 'pending' ORDER BY created_at ASC LIMIT 100 FOR UPDATE SKIP LOCKED `) for msg := range messages { // 先标记为处理中 db.Exec(` UPDATE outbox_messages SET status = 'processing' WHERE id = ? `, msg.id) // 发送到消息队列 err := kafka.Send(msg.eventType, msg.payload) if err != nil { // 失败,重置状态,稍后重试 db.Exec(` UPDATE outbox_messages SET status = 'pending', retry_count = retry_count + 1 WHERE id = ? `, msg.id) } else { // 成功,标记为已发送 db.Exec(` UPDATE outbox_messages SET status = 'sent', sent_at = NOW() WHERE id = ? `, msg.id) } } time.Sleep(1 * time.Second) } } 关键技术点 1. 消息幂等性 消费者必须能处理重复消息(Outbox 可能重发): 每条消息带唯一 ID (类似幂等 Key) 消费者用消息 ID 去重 或设计其他幂等的业务操作 2. 消息顺序 如果业务需要保证顺序: 按 created_at 顺序发送 或使用分区键保证同一实体的事件有序 3. Outbox 表清理 已发送的消息要定期清理或归档: DELETE FROM outbox_messages WHERE status = 'sent' AND sent_at < NOW() - INTERVAL 7 DAY; 4. 使用 CDC (Change Data Capture) 优化 可以用 Debezium 等 CDC 工具监听数据库变更日志,自动捕获 Outbox 表的插入事件并发送到 Kafka,无需轮询。 优缺点 ✅ 优点: 不需要分布式事务:利用本地事务保证一致性 高性能:异步解耦,不阻塞主流程 可靠性高:消息不会丢失,故障恢复能力强 实现相对简单:比 Saga 的补偿逻辑简单 ❌ 缺点: 有一定延迟:消息发送是异步的(通常毫秒到秒级) 需要额外维护 Outbox 表:定期清理,避免膨胀 消费者需要幂等:可能收到重复消息 增加数据库负载:每次业务操作都要写额外的消息记录 一句话总结: Outbox Pattern 是一种”落地级方案”:用一个本地事务表巧妙地解决了”数据库 + 消息队列”的一致性问题,既保证可靠性,又能保持高性能。是现代事件驱动架构的标配。 ⚖️ 四、三种方案对比总结 |模式|一致性保证|性能|复杂度|隔离性|典型场景| |—|—|—|—|—|—| |2PC|强一致|中低 (同步阻塞)|中 (协议简单但容错复杂)|有 (持锁)|数据库内部事务 小规模强一致场景| |Saga|最终一致|高 (无全局锁)|高 (补偿逻辑、状态机、幂等)|无 (中间态可见)|长事务业务流程 订单、支付、审批| |Outbox|最终一致|高 (异步解耦)|中 (消息表管理)|不适用|事件驱动系统 可靠消息投递 数据库-MQ 一致性| 如何选择? 📌 单体应用内的操作 → 用本地数据库事务(ACID) 📌 跨服务的业务流程(如下单、支付、发货) → 用 Saga(编排式或编舞式) 📌 数据库操作 + 发送消息到 MQ → 用 Outbox Pattern 📌 关键金融交易(转账、结算) → 考虑 TCC (Try-Confirm-Cancel) 或事件溯源 ❌ 避免在应用层使用 2PC → 除非是数据库底层实现,或极小规模的强一致场景 五、写在最后:一致性没有完美解 现实世界里,没有完美的”分布式一致性”方案。 不同业务场景,不同需求下,我们都在平衡: 一致性 vs 性能 简单 vs 灵活 强一致 vs 最终一致 理论完美 vs 工程可行 真正的目标不是”完美一致”, 而是”在可接受的时间窗口内,达到业务所需的一致程度”。 工程上的务实原则 能用单体事务就别分布式 如果功能可以在一个服务内完成,就用本地事务,不要为了”微服务”而微服务。 异步优于同步 大部分业务场景都能接受”最终一致”,几秒的延迟换来的是更高的性能和可用性。 补偿优于回滚 现实业务中很多操作本身就无法”撤销”,补偿是更符合业务语义的方式。 监控和可观测性是关键 分布式系统出问题是常态,重要的是能快速发现、定位和恢复。 测试、测试、还是测试 补偿逻辑、幂等性、重试机制都要充分测试,包括网络分区、节点宕机等异常场景。 如果你在设计”订单 + 库存 + 支付”这样的系统: ✅ 用 Saga 编排业务流程(订单创建 → 扣库存 → 扣款 → 发货) ✅ 用 Outbox 保证每个服务的数据变更能可靠地发布事件 ✅ 让消费者实现幂等,处理可能的重复消息 ✅ 设计好监控和告警,及时发现补偿失败或消息积压 这,就是工程上的”务实一致性”。 参考资料: Martin Fowler - The Transactional Outbox Pattern Chris Richardson - Saga Pattern Designing Data-Intensive Applications by Martin Kleppmann https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html

2025/12/31
articleCard.readMore

鸿蒙 OS 的签名密钥机制,我终于整明白了!

在做移动应用开发时,不管是 Android 还是 iOS,我们都绕不开“签名”这件事。最近我们项目在筹备适配 HarmonyOS NEXT(鸿蒙 Next) 的 SDK,也顺势把鸿蒙的签名机制研究了一遍。这篇文章来分享下。 简单来说,签名的目的是确保应用的来源可信、内容完整、平台可识别。我们开发的应用,最终都要交给系统安装,那系统当然得确认一下:这个包是“你”发的、这包在传输过程中没被篡改、它是我允许安装的对象。这就要靠数字签名。 说到底,数字签名就是: 用私钥对应用包里的内容摘要加密,生成一个签名块,塞进包里。 系统在安装时用你当初的公钥解密它,校验签名是否匹配。如果一切对得上,才会放行。 我们先复习下 Android 和 iOS 的签名流程,再看看鸿蒙的做法是怎么“融合创新”的。 Android 签名机制回顾 搞过 Android 的同学都知道,签名这套流程绕不开 Keystore 文件(.jks 或 .keystore)。它是个密钥库,里面有你生成的一对公私钥。 生成方法嘛,Android Studio 自带的工具或者命令行 keytool 都能搞。你得设置密码、别名、组织机构之类的。 签名流程一般是: 用 apksigner 或 jarsigner 对 APK 进行签名; 安装时,系统会从 APK 中提取签名块; 用嵌入的 公钥 验签; 检查包的内容有没有被改动。 重点是——升级包必须用相同密钥签名! 否则就会被系统拦下来,提示你“签名不一致,不能覆盖安装”。 后来 Google 推出了 App Signing by Google Play 服务。你可以只上传未签名的 APK 或用上传密钥签过的 APK,真正的签名工作交给 Google,它用你存的密钥进行最终签名和分发。 iOS 签名机制回顾 iOS 的签名体系就偏“官僚”一点,全靠 Apple 的一套证书系统撑着。 要发布一个 iOS 应用,你得准备这三样东西: .cer:Apple 颁发的开发者证书,内含你的公钥和身份; .p12:你本地生成的私钥文件,用于签名; .mobileprovision:描述文件,绑定了证书、App ID、设备 ID 等。 整个签名过程和证书链校验基本都在 Apple 的 Xcode 工具和 Apple 服务端完成。只要你照着官方流程来,一般不会出啥问题。 鸿蒙 Next:签名机制融合 Android + iOS 的思路 我们项目要接入鸿蒙 Next,第一件事就是:搞清楚签名密钥这一套怎么玩。研究之后发现,鸿蒙的机制融合了 Android 的密钥库和 iOS 的证书申请体系,整体看起来像是“Android 的私钥管理 + iOS 的证书申请 + 描述文件配置”。 一共涉及 4 个关键文件: |文件名|用途|格式| |—|—|—| |Keystore|储存公私钥|.p12| |CSR(证书请求)|提交给华为申请证书|.csr| |数字证书|华为签发的开发/发布证书|.cer| |描述文件 Profile|应用权限、设备、证书绑定等信息|.p7b| 签名流程详解:一步步把包“合法化” 1. 本地生成 .p12 文件 —— 创建密钥对 你用 DevEco Studio 在本地生成 .p12,里面有一对非对称密钥对(公钥 + 私钥)。 私钥以后用来签名 HAP(HarmonyOS 应用包); 公钥则被嵌入到 .csr 文件里,发给华为去申请证书。 2. 生成 .csr 文件 —— 证书请求 .csr 文件是你发给华为的“申请信”,它里面包含了: 你的身份信息(开发者名、组织名等); 你刚刚生成的公钥。 这一点就跟 iOS 的证书申请很像。 3. 提交 .csr 到 AppGallery Connect,申请 .cer 数字证书 这个时候,轮到华为出场了。它会用自己的 CA 私钥 给你签发一张开发证书(.cer 文件)。这个证书里包含: 你的身份信息; 你的公钥; 华为 CA 的签名,确保这张证书是“正宗华为出品”。 通过解析 .csr 的公钥和 .cer 中的开发证书公钥可以发现是同一个。 这样就构建了一条完整的信任链。 4. 构建证书链,验证签名合法性 签了 .cer 后,你的 .p12(公私钥对)就算是“被认证过的开发者密钥”了。系统在安装你签名过的 HAP 包时,会验证: 你的包是不是用这个私钥签的; 这个私钥有没有经过华为签发的证书认证; 这张证书是不是华为签的。 说白了:一层层往上追溯,直到能从信任的 CA 根证书“闭环”,才算合法。 5. 最后一步,生成 .p7b 描述文件 —— 权限、包名、设备绑定 这个 .p7b 文件相当于一个“出入证”,告诉系统这个包: 来自哪个包名; 拥有哪些权限; 是调试包还是发布包; 如果是调试包,哪些设备可以安装; 对应的证书有哪些。 它是以 PKCS#7 格式 打包的,不能少,系统验证的时候会用上。 写在最后 鸿蒙的签名机制说起来复杂,但其实拆开来看就是融合了我们熟悉的 Android 和 iOS 的思路 —— 本地生成密钥,上传公钥申请证书,拿到证书签名包,加上一个描述文件来“做身份认证和权限声明”。 整个流程稍微绕一点,但掌握之后就能把签名流程自动化集成到你的打包流程里。我们现在项目中已经把签名密钥的管理、HAP 签名打包全自动串起来了,做成了 CI/CD 中的一环。 如果你也要做鸿蒙适配,早点熟悉这套流程,绝对事半功倍!

2025/12/31
articleCard.readMore

数据库分表实践:如何优雅地切换到分表架构

写在前面 在我们的后端架构中,我们采用了以游戏为租户单位的多租户数据库设计。所有游戏共享同一套数据库 schema,每张表通过 game_id 字段进行逻辑隔离。这种设计在初期带来了显著的开发与维护效率,支持我们快速迭代、快速上线多个游戏项目。 然而,随着接入的游戏数量增加、业务数据持续增长,这种“共享单表”的设计逐渐暴露出一些架构层面的瓶颈与隐患。 某些高流量的游戏影响到了其他本应“风平浪静”的项目;一些核心表的索引开始变得笨重,甚至拖慢了查询性能; 数据分析时需要频繁在庞大的表中筛选特定游戏数据,效率极低。 更不用说,MySQL 的单表上限正一步步逼近我们曾以为遥远的边界。 我们意识到,曾经高效的“共享单表”策略,已经成为了未来扩展的绊脚石。于是,在年初,我们启动了一项重要的系统演进计划:按服务维度进行数据库分表改造。 这不仅是一次技术重构,更是系统架构从“共享瓶颈”走向“弹性隔离”的转型。本篇将完整记录我们这次数据库分表的路径与实践细节——希望对你有所启发。 实施原则与总体策略 数据库分表,不仅是表结构的变化,更是一套兼容旧有系统、可渐进式切换、可回滚的工程设计。 在正式动手之前,我们就明确了三项核心原则: 不影响线上服务运行,兼容已有功能 支持灰度切换,逐步从旧表过渡到新表 设计可回滚机制,出现问题可以随时切换回旧表 基于这三条,我们制定了如下的整体方案: 我们将整个数据库分表的工作拆分为多个阶段,每次只处理一个服务的分表任务,避免全系统改动带来的风险,从影响面最小的服务开始进行实施,每个服务独立执行迁移程序,单独验证,按需上线。 为了兼容旧表与新表,我们在每个服务内部引入了 db_mode 配置项,按游戏粒度控制读写行为: write_mode: old_table:仅写旧表 dual_write:写入旧表 + 新表(事务) new_table:仅写新表 read_mode: old_table:仅从旧表读取 dark_read:主用旧表读取,同时异步比对新表 new_table:仅从新表读取 这套机制允许我们按游戏逐个切换,并且在 write_mode 修改为 new_table 前,都可以进行回滚。 自动建表:创建游戏即创建对应表 我们在后台创建游戏时,会自动触发分表建表逻辑: 这样,我们无需人工干预即可支持新游戏分表,游戏接入的流程保持不变。 数据访问层改造 我们不改动业务代码,只修改数据库访问层。 根据 db_mode 配置,进行不同表的数据库访问层操作。 内嵌迁移程序 每个服务内置一个迁移命令行程序,支持: dry-run 模式比对数据差异 支持幂等多次执行 支持按游戏、按表迁移 我们通过 Nomad Batch Job 管理迁移任务,通过 Jenkins Pipeline 提供可配置的执行入口。迁移日志也会自动上报到内部通知系统。 数据访问层的改造范式 在这次改造中,我们遵循了“只改访问逻辑,不动业务代码”的原则,把所有分表逻辑封装在 repository 层内部,使业务代码无感知地享受分表带来的性能收益。 注入 db_mode:为每个游戏配置读写模式 我们通过配置文件将每个游戏的读写策略注入到服务中。 这保证了即便是同一个服务,不同的游戏也可以处于不同的分表阶段。 读操作:Old / New / DarkRead 三分法 每个原始读方法,都会被拆成三个版本 Dark Read 的核心逻辑是: 主流程从旧表读取并返回 启动一个 goroutine,从新表读取 对比业务字段是否一致,若不一致则记录日志(包括差异字段、原始数据、新数据) 这让我们能在不影响线上服务的前提下,实时监控新表的数据同步情况,确保切换 read_mode=new_table 之前,新旧表是“业务等价”的。 写操作:Old / New / DualWrite 三分法 dual_write 模式必须通过事务包装,确保新旧表一致性 混合逻辑场景的改造 部分 repository 方法可能同时涉及读写,例如 CreateAndGet()。 这类方法无法直接套用 read_mode / write_mode 的切换逻辑,需要将其内部 SQL 操作全部拆解成单一职责方法后,再按以上的标准方式进行重构。 这样做虽然有很多的工作量,但换来了可控性、可测试性与安全性,为灰度切换打下了基础。通过这种结构化的改造范式,我们让所有数据库访问逻辑都可以: 在配置层切换行为 在不改动业务逻辑的前提下实现分表 在新旧表切换阶段保障数据一致性 迁移程序设计与执行策略 分表改造并非一次性切换,而是一场有节奏、有保障的“数据搬家”。为了确保迁移过程中数据不丢失、不重复、不污染新表,我们在每个服务中内置了一套专用的数据迁移子程序。 为什么内置在服务中呢? 是为了保证迁移程序中所用的 db schema 对象与程序一致,无需新增代码仓库,保持迁移程序与原 service image 一致即可。 这套程序采用命令行方式运行,按 game_id 和表级别控制,具备如下核心特性: :fa-hand-o-right:通过 CLI 参数控制执行行为 | 参数 | 说明 | | ———- | ———————- | | -game-id | 必选,指定要迁移的游戏 | | -table | 必选,指定要迁移的表 | | -dry-run | 可选,是否启用试运行模式,默认 true | :fa-hand-o-right: 主键游标分页查询 从旧表中按主键升序分页查询,避免 OFFSET 带来的性能问题。 :fa-hand-o-right:记录处理逻辑 仅处理指定 game_id 的数据 若新表无记录 → 插入 → Inserted++ 若新表有记录: 若数据内容不同 → 更新 → Updated++ 若业务字段等价 → 忽略 → Equivalent++ 注意: 不对比自增 ID 和时间字段(created_at, updated_at) 写操作忽略自增 ID,仅迁移业务字段 :fa-hand-o-right: 幂等执行支持 可重复运行任意多次 若数据已一致,不会重复插入 / 更新 避免执行中断后的数据污染风险 :fa-hand-o-right:错误处理与退出机制 全局 recover,优雅退出 迁移程序入口包装 panic-recover 异常发生时输出详细日志、当前进度 通过 Messenger 通知协作群 中断信号监听 支持 SIGINT / SIGTERM 信号监听 在每一批处理前检测 context 是否已取消 收到信号后中止执行,输出中间状态并发送通知 :fa-hand-o-right:结构化统计信息 + 可追溯日志 每次执行完成后,程序会输出统计信息,并自动发送至内部通知群。 上线切换流程与回滚策略 数据库分表的目标,并不是“改造完上线就完事”,而是从 old_table 模式平滑切换到 new_table 模式,并确保在每一步都有明确的验证机制和可回退能力。 这是一场有节奏的灰度迁移流程,我们为每一步都设计了验证手段、执行流程与回滚方案。 分阶段策略 我们将一次完整的分表切换流程拆解为以下 6 个阶段: 阶段一:部署改造完成的程序,此时所有的游戏都读写 old table。 以下阶段按游戏进行: 阶段二:部署 dual_write,开始写新旧双表 阶段三:多次执行数据迁移,确保新表一致 阶段四:read_mode 切换为 dark_read,开始观测新表稳定性 阶段五:read_mode 切换为 new_table 阶段六:write_mode 切换为 new_table 回滚策略 我们特别强调:在任意切换阶段,如果发现异常,都可以回退到旧表访问模式。 例如: |异常场景|回滚操作| |—|—| |dark_read 中发现大量差异|read_mode → old_table| |切换 new_table 后接口报错|read_mode → dark_read 或 old_table| |dual_write 写新表失败|write_mode → old_table| 所有行为都是配置可控、逻辑封装良好、无须变更代码。 迁移状态判断与团队共识 我们曾讨论是否要在 Redis 或数据库中记录迁移状态(例如,某个游戏是否已完成迁移)。最终我们选择: 不使用自动标记机制,而是依靠 MR review 和群通知日志来确认状态。 具体判断依据: Inserted=0 且 Updated=0 的绿色通知连续两次 数据访问层未输出 dark_read_diff 日志 多人 Review 确认可切换 结语 数据库分表是一场面向未来的系统演进,它不是一次性的技术“升级”,而是对系统架构能力的整体提升。 在这次分表实践中,我们学到: 如何在不动业务逻辑的前提下,完成结构性重构。 如何利用配置驱动的方式,设计出灵活的迁移与回滚策略。 分表应尽早规划实施。若等到数据库压力逼近瓶颈再动手,改造将变得异常谨慎且复杂,工作量与风险也将同步增加。 这一次的分表实践,为我们构建了一个更具弹性的起点。如果你对这套分表实践的任何部分感兴趣,欢迎留言讨论~ 同步发表在 GitHub 微信公众号

2025/12/30
articleCard.readMore

“该省省,该花花”

春节假期,我终于完成了两件大事:见了未来的岳父岳母,还带奶奶游览了北京。忙里偷闲,我决定总结一下这几天的经历,顺便分享一些“花钱”与“省钱”的小感悟。 作为一名农村出身的人,我的消费观念一直是:钱能不花,就不花。 例如: • 旅游?自己带矿泉水,自己带干粮,景区里一瓶水8块?不可能! • 门票?买最低门槛的,能溜进去的坚决不多花一分钱! • 遇到导游、摄影师、纪念品摊贩?避之如洪水猛兽,仿佛他们会瞬间吸干我的钱包。 但这些年,尤其是这个假期的几件事,让我逐渐意识到:有些钱,省下来是亏的,花出去才是赚的。 游泳:花钱买经验,少走弯路 大学时,我决定学游泳。(ps: 一个月1000生活费,拮据~)当时面临两个选择: 办游泳年卡(500块),自学成才! 请游泳教练(20节课500块),专业入门! 作为精打细算的学生党,我毫不犹豫地选择了年卡,心想:天天泡在泳池里,还能游不会? 结局是,我的年卡被用得相当充分,但整整一年下来,只学会了蛙泳。自由泳换气?不行。打腿节奏?不对。游过去还能游回来?不存在的。两年后,喝了无数口泳池水,我终于磕磕绊绊学会了自由泳,蝶泳只学了个“皮毛”。但即便如此,每次游完我脖子都酸,说明动作仍然有问题。 回头一想,当年如果花钱请教练,可能两个月就能达到现在的水平,少喝多少水啊! (而且,大学时候的教练费是真的便宜啊~~)但当时的我,非要靠自己摸索,结果就是走了很多弯路,还差点呛成“水鬼”。 滑雪:没教练,甚至不会穿板 这次假期,我和女朋友去了东北,决定挑战滑雪。作为一个自学成才的运动达人(至少自认为是),我信心满满地选择了单板。 然而,我连雪板都穿不上。 站在雪道旁,我捣鼓了半小时,发现自己甚至不会正确扣上固定器。看着别人轻松滑走,我怀疑人生: • 是鞋的问题? • 是板的问题? • 还是我的问题? 这时,一个大哥看不下去了:“哥们,你鞋都不会穿,找个教练吧……” 女朋友心疼我,掏钱包,请了个教练。等教练的工夫,我终于自己学会了穿鞋(咱还是有点天赋的!) 接下来,短短两个小时,教练带我从后刃滑行 → 落叶飘 → 陡坡尝试,五次练习后,我已经能上中级道了!从一脸懵逼到掌握平衡、控制速度,我终于体会到:花钱买指导,真香! 如果没有教练,我可能得花三天去摔跤,最后依旧漏洞百出。这几百块,等于帮我省下了两天的雪场门票、误伤自己可能花的医药费。 旅游:没有导游,可能只是“打个卡” 小时候家里穷,到北京来玩也是因为爸妈在北京工作,出门基本是“快进模式”: • 景区门口拍个照 = 到此一游! • 随便走走,发现没啥意思 = 直接走人! 这次去沈阳故宫,门口有个导游大姐找到我们:“快关门了,最后一个拼团,给你俩便宜点~!” 本着反正也不贵的心态,我们跟上了团。结果,这趟游览改变了我的旅游观念—— 沈阳故宫很小,随便逛一圈可能用不了20分钟。但在导游的讲解下,这座小小的宫殿变得鲜活起来: • 什么是口袋房? • 这里为什么只有一个大烟囱?为什么有一个杆子顶个碗? • 东南西北不同颜色都怎么来的? 听完导游的讲解,我发现自己不仅记住了这些历史故事,甚至对历史产生了更浓厚的兴趣。 如果没有导游,我可能只会留下几张照片,走马观花地打个卡,最后啥也没学到。 人生的投资观:该省省,该花花 这几件事让我彻底明白了一个道理:“省钱”不是不花钱,而是把钱花在真正值得的地方。 • 该花的钱,花出去能让你更省时间、更少走弯路、更享受体验。这些钱是“投资”,而不是“浪费”。 • 该省的钱,指的是那些没有意义的开销,比如买一堆用不上的纪念品,或者在低性价比的地方瞎花钱。 真正会花钱的人,不是抠门,而是懂得如何花最少的钱,跨过性价比最高的门槛。 当你愿意为专业指导付费,你就能少走几年弯路; 当你愿意为体验买单,你就能获得真正的乐趣和记忆; 当你愿意为知识买单,你就能看到别人看不到的世界。 人生,就是一个不断投资自己的过程。 So,my friend,别再和我一样傻省钱了,学会“该省省,该花花”!

2025/12/31
articleCard.readMore

Golang 中的 EOF 与 read: connection reset by peer 错误深度剖析

引言 在 Golang 网络请求中,我们经常会遇到两种常见的错误:EOF 和 read: connection reset by peer。这两个错误虽然看似相似,但实际上有着本质的区别。这篇文章将深入探讨这两种错误的原因、区别以及如何优雅的处理它们。 错误原因解析 EOF 错误 首先,让我们看看 Golang 标准库中对 EOF 的定义: // EOF is the error returned by Read when no more input is available. // (Read must return EOF itself, not an error wrapping EOF, // because callers will test for EOF using ==.) // Functions should return EOF only to signal a graceful end of input. // If the EOF occurs unexpectedly in a structured data stream, // the appropriate error is either ErrUnexpectedEOF or some other error // giving more detail. var EOF = errors.New("EOF") 从注释中我们可以看出: EOF 表示没有更多的输入可用。 函数应该只在输入优雅结束时返回 EOF。 如果在结构化数据流中意外发生 EOF,应该返回 ErrUnexpectedEOF 或其他更详细的错误。 connection reset by peer 错误 在 Golang 源码中,connection reset by peer 对应的错误码是 “ECONNRESET”。在 Linux 和类 Unix 系统上,ECONNRESET 错误码表示连接被对端重置。 模拟错误场景 为了更好地理解这两种错误,我们可以通过代码来模拟这些场景。 服务端代码 package main import ( "errors" "log" "net" "net/http" "os" "time" ) func handler(w http.ResponseWriter, r *http.Request) { conn, _, err := w.(http.Hijacker).Hijack() if err != nil { log.Printf("无法劫持连接: %v", err) return } defer conn.Close() // 通过注释或取消注释下面这行来切换错误类型 // conn.(*net.TCPConn).SetLinger(0) } func main() { server := &http.Server{ Addr: ":8788", Handler: http.HandlerFunc(handler), ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, IdleTimeout: 5 * time.Second, ErrorLog: log.New(os.Stderr, "http: ", log.LstdFlags), } listener, err := net.Listen("tcp", server.Addr) if err != nil { log.Fatalf("无法监听端口 %s: %v", server.Addr, err) } defer listener.Close() log.Printf("服务器在端口 %s 上等待连接...", server.Addr) if err = server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("服务器启动失败: %v", err) } } 客户端代码 func TestHttpRequest(t *testing.T) { reqUrl := "http://localhost:8788" req, err := http.NewRequest("GET", reqUrl, nil) if err != nil { t.Fatalf("创建请求失败: %v", err) } client := &http.Client{} resp, err := client.Do(req) if err != nil { t.Logf("请求错误: %v", err) return } defer resp.Body.Close() } 在服务端代码中,通过注释或取消注释 conn.(*net.TCPConn).SetLinger(0) 这行代码,我们可以模拟两种不同的错误场景: 当这行代码被注释时,客户端会收到 EOF 错误。 当这行代码未被注释时,客户端会收到 read: connection reset by peer 错误。 错误原因分析 EOF 错误 EOF 错误通常表示连接被对端优雅地关闭。这是一个正常的网络行为,表示数据传输已经完成。在 TCP 协议中,这对应于正常的四次挥手过程: 服务器发送 FIN 包。 客户端回应 ACK 包。 客户端发送 FIN 包。 服务器回应 ACK 包。 connection reset by peer 错误 connection reset by peer 错误表示连接被对端强制关闭。这通常是由于以下原因之一: 对端进程崩溃。 网络异常。 对端主动选择立即断开连接(如使用 SetLinger(0))。 在这种情况下,TCP 连接没有经过正常的四次挥手过程,而是直接发送了 RST(重置)包。 实际应用场景分析 EOF 错误的常见场景 对端完成数据发送后正常关闭连接。 长连接超时被服务器主动关闭。 读取固定长度的数据流结束。 connection reset by peer 错误的常见场景 服务器进程崩溃或被强制终止。 网络设备(如防火墙)主动断开连接。 服务器配置了短的 keep-alive 超时时间,客户端尝试使用已关闭的连接。 如何处理这两种错误 处理 EOF 错误 1.对于预期的 EOF(如读取到文件末尾),可以正常处理,不需要特别的错误处理逻辑。 data, err := reader.Read(buffer) if err == io.EOF { // 正常结束,处理完成的数据 return } else if err != nil { // 处理其他错误 return err } 2.对于非预期的 EOF,应该进行错误处理和日志记录。 if err == io.ErrUnexpectedEOF { log.Printf("意外的 EOF: %v", err) // 进行错误恢复或重试逻辑 } 处理 connection reset by peer 错误 1.实现重试机制。 func retryableHttpGet(url string, maxRetries int) (*http.Response, error) { var resp *http.Response var err error for i := 0; i < maxRetries; i++ { resp, err = http.Get(url) if err == nil { return resp, nil } if !strings.Contains(err.Error(), "connection reset by peer") { return nil, err } time.Sleep(time.Second * time.Duration(i+1)) } return nil, fmt.Errorf("最大重试次数已达到: %w", err) } 2.优化连接池配置。 client := &http.Client{ Transport: &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 100, IdleConnTimeout: 90 * time.Second, }, Timeout: 10 * time.Second, } 3.使用断路器模式。 import "github.com/sony/gobreaker" cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{ Name: "HTTP GET", MaxRequests: 3, Interval: 5 * time.Second, Timeout: 30 * time.Second, ReadyToTrip: func(counts gobreaker.Counts) bool { failureRatio := float64(counts.TotalFailures) / float64(counts.Requests) return counts.Requests >= 3 && failureRatio >= 0.6 }, }) resp, err := cb.Execute(func() (interface{}, error) { return http.Get("http://example.com") }) 最佳实践 日志记录:详细记录错误发生的上下文,包括时间、请求详情等。 监控告警:设置合理的阈值,当错误率超过预期时及时告警。 错误分类:区分预期和非预期的错误,采取不同的处理策略。 优雅降级:在遇到网络问题时,实现服务的优雅降级,保证核心功能可用。 代码 review:检查是否有不当的连接处理逻辑,如强制关闭连接等。 总结一下 理解 EOF 和 connection reset by peer 错误的区别和处理方法,对于构建健壮的网络应用至关重要。EOF 通常表示正常的连接关闭,而 connection reset by peer 则可能指示更严重的问题。通过合理的错误处理、重试机制和监控,我们可以大大提高应用的可靠性和用户体验。 在实际开发中,要根据具体的应用场景和需求,选择适当的错误处理策略。同时,持续监控和分析这些错误,可以帮助我们及时发现和解决潜在的问题,提高系统的整体稳定性。

2025/12/31
articleCard.readMore

为什么你应该在代码中消除 "context deadline exceeded" 错误

在 Go 语言中,context 包提供了一种跨 API 和进程边界传递请求作用域值、取消信号以及超时信号的方式。使用 context 可以帮助我们更好地控制 goroutine,避免 goroutine 泄漏等问题。 出现 “context deadline exceeded” 错误通常是因为在请求上下文中设置了超时时间,但请求在超时时间内未完成。我们应该尽量避免这种错误,原因如下: 错误处理:context deadline exceeded 是一个错误,如果忽视它可能导致程序运行异常或产生其他错误。 错误分析:当我们对数据埋点和日志进行分析时,如果出现 “context deadline exceeded” 错误,我们很难直接定位到具体的错误来源。 假设我们在一个分布式系统中处理多个请求,如果日志中充斥着 “context deadline exceeded” 错误,我们根本无法判断是哪里出现了问题。 资源泄漏:未及时取消 goroutine 可能会导致资源(如内存、文件描述符等)无法及时释放,引起资源泄漏问题。 比如数据库慢查询,数据库连接可能会被占用,导致连接池耗尽。 性能问题:长时间运行的请求未能取消,会消耗大量的系统资源,影响整体系统性能。 用户体验:对于需要等待长时间的请求,用户可能会感到迷惑和不耐烦,影响用户体验。 为了消除 “context deadline exceeded” 错误,我们可以采取以下几种办法: 合理设置超时时间:根据实际业务需求,设置合理的超时时间,避免过短或过长的设置。在对应位置返回转换后的业务错误。 比如,对于一个数据库查询操作,可以根据历史数据分析设置一个合理的超时时间,确保大多数查询都能在该时间内完成。 使用 context.WithTimeout:在请求开始时,使用 context.WithTimeout 或 context.WithDeadline 创建带超时时间的 context,并在请求完成或超时后及时取消该 context。 比如,在执行一个 HTTP 请求时,可以使用 context.WithTimeout 设置一个 5 秒的超时时间,如果这个请求超时,可以返回对应的业务枚举错误,在进行错误定位时,可以直接找到问题出现的原因。 监控长时间运行的请求:定期检查是否存在长时间运行的请求,如果有,则及时调整超时策略。 可以使用 Prometheus 监控请求的持续时间,并设置告警通知,提醒开发人员处理超时请求。 优化代码逻辑:review 代码,优化潜在的低效或阻塞代码,减少执行时间。 记录及分析错误:通过日志记录及时发现 “context deadline exceeded” 错误,并分析其根本原因。 消除 “context deadline exceeded” 错误不仅有利于系统的健康运行,也可以提高系统的可靠性和用户体验。在 Go 编程中,我们应该合理使用超时时间的设置,尽量避免这类错误的发生。

2025/12/31
articleCard.readMore

死锁日记:手写 GoLang 上报队列

我们团队负责的防沉迷上报服务突然在某一天遭遇了内存溢出(OOM)的情况。通过查看 Prometheus 监控数据,我们发现 Goroutines 的数量在中午十二点之后呈现出线性增长趋势,直至晚上十点 OOM 发生,Goroutines 数量骤降为零。如下图所示: 从这个 Goroutine 的创建趋势图中,我们可以推断出服务在中午十二点触发了某个 bug,导致 Goroutine 不断创建,最终引发了内存溢出的问题。 队列实现 在我们的服务中,有一个用于上下线上报的队列。当队列达到一定阈值或指定时间间隔时,会触发读取和上报。以下是队列的具体实现: package main import ( "errors" "sync") type QueueError error var CapacityExceededError QueueError = QueueError(errors.New("capacity exceeded")) // Queue 包含信号通知的队列 // 当队列长度大于等于 thresholdSize 时,触发信号,外部可以监听信号 // 当队列长度小于 thresholdSize 时,重置信号状态 type Queue struct { items items capacity int lock sync.Mutex // 触发信号的阈值大小 thresholdSize int // 信号通知,当队列长度大于等于 thresholdSize 时,触发信号 processSignal chan bool // 是否已经处理过信号 signalProcessed bool } type items []interface{} // NewQueue 创建新队列, 指定容量和触发信号的阈值大小 func NewQueue(thresholdSize int, capacity int) *Queue { q := &Queue{} q.processSignal = make(chan bool, 1) q.thresholdSize = thresholdSize q.capacity = capacity // 初始化与 thresholdSize 大小一致的队列 q.items = make([]interface{}, 0, thresholdSize) return q } // Put 添加元素到队列 func (q *Queue) Put(item interface{}) error { q.lock.Lock() defer q.lock.Unlock() if q.len() >= q.capacity { return CapacityExceededError } q.items = append(q.items, item) if q.len() >= q.thresholdSize { q.triggerSignal() } return nil } // Poll 从队列中取出元素, 并返回取出的元素 // 如果队列内部元素小于 num, 则设定 signalProcessed 为 falsefunc (q *Queue) Poll(num int) []interface{} { q.lock.Lock() defer q.lock.Unlock() if q.len() <= num { num = q.len() } result := q.items.get(num) if q.len() < q.thresholdSize { q.resetSignalState() } return result } // PollAll 返回队列中的所有元素,并清空队列 func (q *Queue) PollAll() []interface{} { q.lock.Lock() defer q.lock.Unlock() if q.len() == 0 { return nil } // 获取所有元素 allItems := q.items q.items = make([]interface{}, 0, q.thresholdSize) q.resetSignalState() return allItems } func (items *items) get(number int) []interface{} { returnItems := make([]interface{}, 0, number) index := 0 for i := 0; i < number; i++ { if i >= len(*items) { break } returnItems = append(returnItems, (*items)[i]) (*items)[i] = nil index++ } *items = (*items)[index:] return returnItems } func (q *Queue) triggerSignal() { if q.signalProcessed { return } q.processSignal <- true q.signalProcessed = true } func (q *Queue) resetSignalState() { q.signalProcessed = false } func (q *Queue) len() int { return len(q.items) } func (q *Queue) ProcessSignal() <-chan bool { return q.processSignal } 在这个队列中,使用了一个无缓冲的 channel 来记录队列是否达到了阈值,还有一个布尔变量来标记队列是否已经被消费。当队列中的元素数量超过阈值时,channel 和布尔变量协作,通知外部读取队列。 消费过程 在消费过程中,我们使用 timer 和队列信号作为消费信号,持续读取队列。当到达指定的时间间隔或队列长度超过阈值时,进行队列的读取和上报。 // 到达 ReportInterval 或者 batchSize 进行发送 timer := time.NewTimer(w.config.ReportInterval) defer timer.Stop() for { // 缓存消息,定时发送或者足量发送 select { case <-ctx.Done(): slog.Info("report stop") return case <-timer.C: ReportMessages(ctx, que) timer.Reset(ReportInterval) case <-que.ProcessSignal(): ReportMessages(ctx, que) timer.Reset(ReportInterval) } } 在这段代码中: 当到达配置的 ReportInterval 时间时,计时器 timer 触发,执行 ReportMessages 函数消费队列,并重置计时器以等待下一个时间间隔。 当队列长度超过阈值时,队列的 ProcessSignal 信号触发,同样执行 ReportMessages 函数消费队列,并重置计时器。 如果上下文 ctx 被取消,则记录日志并停止报告。 通过这种方式,我们确保了队列可以在定时或达到一定长度时进行消费,保持系统的高效运行。 死锁分析 在队列的实现中,我们使用了锁 (lock) 和无缓冲的信号通道 (channel) 来管理队列状态。这两者都有可能会阻塞,如果它们同时阻塞并互相等待,就会导致死锁。通过观察 Goroutine 的创建趋势图,可以推测出请求在放入队列时发生阻塞,导致 Goroutines 数量不断增加,最终引发内存溢出。 那么,在什么情况下会导致死锁呢? 经过分析,只有一种情况会导致死锁: 队列大量写入瞬间到达阈值:当队列中的元素数量瞬间达到阈值时,信号通道 (channel) 被写入。 进入定时器 (timer) 进行消费:此时,timer 触发,消费队列并将 flag 设置为 false,但是信号通道 (channel) 没有被消费。 信号通道阻塞:在这种情况下,写入队列操作 (queue.Put) 会因信号通道阻塞而无法完成。 再一次进入定时器:此时不会进入 <-que.ProcessSignal() 的 case,而是再次进入 timer case,尝试读取队列但获取锁 (lock) 阻塞。 互相阻塞:队列的 Put 和 PollAll 操作互相阻塞,最终导致死锁。 总结来说,死锁发生在队列瞬间达到阈值时,信号通道被写入但未被消费,随后定时器再次触发消费操作时,导致队列的 Put 和 PollAll 操作互相阻塞。 死锁原因深入分析 根据代码逻辑,每次执行完 select 语句后都会调用 timer.Reset(ReportInterval) 来重置定时器。因此,理论上不应该两次进入 timer case,因为存在 que.ProcessSignal() 信号量,会优先进入这个 case。然而,经过多次反复检查后,其他情况不可能导致死锁,最终怀疑的焦点集中在 timer.Reset 上。 通过查看 timer.Reset 函数的源码及其注释,发现了几个关键点: 只对已停止或已到期的定时器使用:对于通过 NewTimer 创建的定时器,应当只在定时器已停止或已到期并且相关通道已清空的情况下调用 Reset。这样可以避免定时器重置操作和通道接收操作之间的竞争条件。 确保通道已被清空: 如果程序已经从 t.C 接收到了值,表明定时器已经到期,并且通道已被清空,可以直接调用 t.Reset。 如果程序尚未从 t.C 接收到值,应首先停止定时器: if !t.Stop() { <-t.C // 清空通道 } t.Reset(d) 此代码片段首先尝试停止定时器,如果定时器已经到期(`t.Stop()` 返回 `false`),则必须从通道中读取以清空通道,避免潜在的死锁或重复接收旧的过期值。 因此,如果定时器已经到达执行时间,需要先读取 timer.C 再执行 Reset 操作,否则 Reset 可能不会生效,导致定时器相继执行两次,从而发生死锁。 根据日志和错误分析,最近版署防沉迷系统“升级”后经常出现超时(time out),导致上报执行时间变长,队列堆积,从而暴露出这个 bug。 总结 在上面的描述中,我们发现了队列和上报逻辑中的两个主要 bug: 队列内部的死锁:put 和 poll 操作可能会导致死锁。 错误使用 timer.Reset:timer.Reset 的使用方法不正确,可能导致定时器误触发。 此外,队列的设计也存在一些问题,例如多余的 flag 值和对 channel 信号的阻塞处理不当。我们对队列进行了优化,去掉了多余的 flag 值,并改进了 channel 信号处理。以下是优化后的队列代码: package main import ( "errors" "sync") type QueueError error var CapacityExceededError QueueError = QueueError(errors.New("capacity exceeded")) // Queue 包含信号通知的队列 // 当队列长度大于等于 thresholdSize 时,触发信号,外部可以监听信号 // 当队列长度小于 thresholdSize 时,重置信号状态 type Queue struct { items items capacity int lock sync.Mutex // 触发信号的阈值大小 thresholdSize int // 信号通知,当队列长度大于等于 thresholdSize 时,触发信号 processSignal chan bool } type items []interface{} // NewQueue 创建新队列, 指定容量和触发信号的阈值大小 func NewQueue(thresholdSize int, capacity int) *Queue { q := &Queue{} q.processSignal = make(chan bool, 1) q.thresholdSize = thresholdSize q.capacity = capacity // 初始化与 thresholdSize 大小一致的队列 q.items = make([]interface{}, 0, thresholdSize) return q } // Put 添加元素到队列 func (q *Queue) Put(item interface{}) error { q.lock.Lock() defer q.lock.Unlock() if q.len() >= q.capacity { return CapacityExceededError } q.items = append(q.items, item) if q.len() >= q.thresholdSize { q.triggerSignal() } return nil } // Poll 从队列中取出元素, 并返回取出的元素 // 如果队列内部元素小于 num, 则设定 signalProcessed 为 falsefunc (q *Queue) Poll(num int) []interface{} { q.lock.Lock() defer q.lock.Unlock() if q.len() <= num { num = q.len() } result := q.items.get(num) if q.len() < q.thresholdSize { q.resetSignal() } return result } // PollAll 返回队列中的所有元素,并清空队列 func (q *Queue) PollAll() []interface{} { q.lock.Lock() defer q.lock.Unlock() if q.len() == 0 { return nil } // 获取所有元素 allItems := q.items q.items = make([]interface{}, 0, q.thresholdSize) if len(allItems) >= q.thresholdSize { q.resetSignal() } return allItems } func (q *Queue) triggerSignal() { select { case q.processSignal <- true: default: } } func (q *Queue) resetSignal() { select { case <-q.processSignal: default: } } func (items *items) get(number int) []interface{} { returnItems := make([]interface{}, 0, number) index := 0 for i := 0; i < number; i++ { if i >= len(*items) { break } returnItems = append(returnItems, (*items)[i]) (*items)[i] = nil index++ } *items = (*items)[index:] return returnItems } func (q *Queue) len() int { return len(q.items) } func (q *Queue) ThresholdChan() <-chan bool { return q.processSignal } 同时,我们对 timer.Reset 的使用进行了修改,以确保符合预期: case <-que.ThresholdChan(): ReportMessages(ctx, que) if !timer.Stop() { <-timer.C } timer.Reset(ReportInterval) 这是我第一次在 Go 语言中手写死锁代码,这次经历让我学习到了很多。虽然防沉迷上报这个业务价值并不高,影响很小,但通过这次优化,我对使用 channel 的方式有了更深刻的理解,也避免了以后可能会遇到的的坑。 2024-08-15 更新: 在 golang 1.23 中,对 Timer 和 Ticker 的行为做了修改: Second, the timer channel associated with a `Timer` or `Ticker` is now unbuffered, with capacity 0. The main effect of this change is that Go now guarantees that for any call to a `Reset` or `Stop` method, no stale values prepared before that call will be sent or received after the call. 与定时器或计时器关联的定时器通道现在没有缓冲区,容量为 0。此更改的主要影响是 Go 现在可以保证对任何调用 Reset 或 Stop 方法的调用,在调用之前准备的过时值在调用之后不会被发送或接收。 如果你也有同样的问题,可以升级到 golang 1.23~

2025/12/31
articleCard.readMore

gorm 中 MySQL 错误码映射与主键冲突错误处理

处理 gorm 错误返回时,有一些错误是没有办法直接使用 errors.Is 来进行判断的,比如主键冲突的错误,直接使用 errors.Is(err, gorm.ErrDuplicatedKey) 是无法判断出主键冲突的错误返回的。 如果没有办法进行判断,为什么 gorm 要给这样一个 error ,但又不能使用呢? gorm.io/driver/mysql 包中有一个 error_translator 的 go 文件 package mysql import ( "github.com/go-sql-driver/mysql" "gorm.io/gorm") // The error codes to map mysql errors to gorm errors, here is the mysql error codes reference https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html. var errCodes = map[uint16]error{ 1062: gorm.ErrDuplicatedKey, 1451: gorm.ErrForeignKeyViolated, 1452: gorm.ErrForeignKeyViolated, } func (dialector Dialector) Translate(err error) error { if mysqlErr, ok := err.(*mysql.MySQLError); ok { if translatedErr, found := errCodes[mysqlErr.Number]; found { return translatedErr } return mysqlErr } return err } 我们可以看到这个文件将 mysql 的几种错误码进行了枚举,使用 Translate 函数会将对应的 mysql error 转化为 gorm error 那这里的 Translate 函数,是谁进行使用了呢?在什么时候进行使用了呢? 主键冲突的错误一定是出现在插入的时候,我们顺着 gorm 的 Create 方法向下找,可以发现它调用了一个 AddError 的函数,如下 // AddError add error to dbfunc (db *DB) AddError(err error) error { if err != nil { if db.Config.TranslateError { if errTranslator, ok := db.Dialector.(ErrorTranslator); ok { err = errTranslator.Translate(err) } } if db.Error == nil { db.Error = err } else { db.Error = fmt.Errorf("%v; %w", db.Error, err) } } return db.Error } 这里有一行很关键,db.Dialector.(ErrorTranslator) 对 Dialector 接口进行了断言,断言成功,就调用对应 Dialector 的 Translate 函数,而当这里的 Dialector 是上面 gorm.io/driver/mysql 中的 Dialector 时,就可以运行上面的翻译逻辑,将 mysql 的 error 转换为 gorm 的 error。 那么,我们下一步就是找到方法,把这里使用的 Dialector 替换成 gorm.io/driver/mysql 包下的这个 Dialector,这样我们就可以使用 errors.Is(err, gorm.ErrDuplicatedKey) 对插入冲突进行判断了。 gorm 中 DB 对象的结构是这样的 // DB GORM DB definition type DB struct { *Config Error error RowsAffected int64 Statement *Statement clone int } 这里的 Config 中就包含了 Dialector 接口,我们只需要在创建 gorm.DB 的时候,将接口的实例(gorm.io/driver/mysql 包下的这个)传入进去,就可以让 gorm 在之后的 error 判断时,对 mysql 的 error 进行翻译。 到这儿,原理部分就明明白白了,接下来简单改写一下 gorm.DB 的 init 过程即可!⬇️ import "gorm.io/driver/mysql" import "gorm.io/gorm" connUrl := "数据库连接地址" db, err := gorm.Open( mysql.Open(connUrl).(*mysql.Dialector), TranslateError: true, // 开启 mysql 方言翻译 (开启后 duplicatedKey err 判断才能生效) ) 以上! 参考链接:https://gorm.io/docs/error_handling.html#Dialect-Translated-Errors

2025/12/31
articleCard.readMore

CloudFlare Tunnel 免费内网穿透的简明教程

Tunnel 可以做什么 将本地网络的服务暴露到公网,可以理解为内网穿透。 例如我们在本地服务器 localhost:8091 搭建了一个 博客网站,我们只能在内网环境才能访问这个服务,但通过内网穿透技术,我们可以在任何广域网环境下访问该服务。相比 NPS 之类传统穿透服务,Tunnel 不需要公网云服务器,同时自带域名解析,无需 DDNS 和公网 IP。 将非常规端口服务转发到 80⁄443 常规端口。 无论是使用公网 IP + DDNS 还是传统内网穿透服务,都免不了使用非常规端口进行访问,如果某些服务使用了复杂的重定向可能会导致 URL 中端口号丢失而引起不可控的问题,同时也不够优雅。 自动为你的域名提供 HTTPS 认证。 为你的服务提供额外保护认证。 最重要的是——免费。 Tunnel 工作原理 Tunnel 通过在本地网络运行的一个 Cloudflare 守护程序,与 Cloudflare 云端通信,将云端请求数据转发到本地网络的 IP + 端口。 前置条件 持有一个域名 将域名 DNS 解析托管到 CloudFlare (我目前都直接转到了 CloudFlare ) 有一台服务器(本地非本地都可以,有没有公网 IP 都 OK),用于运行本地与 cloudflare 通信的 cloudflared 程序 一张境内双币信用卡(仅用于添加付款方式,服务是免费的) 开始 1. 打开 Cloudflare Zero Trust 工作台面板 2. 创建 Cloudflare Zero Trust ,选择免费计划。需要提供付款方式,使用境内的双币卡即可 填写 team name,随意填写 选择免费计划 添加付款方式 填写信用卡信息(仅验证,不会扣款),完成配置 3. 完成后,在 Access Tunnels 中,创建一个 Tunnel。 创建 Tunnel 4. 选择 Cloudflared 部署方式。 Tunnel 需要通过 Cloudflared 来建立云端与本地网络的通道,这里推荐选择 Docker 部署 Cloudflared 守护进程以使用 Tunnel 功能。 获取 Cloudflared 启动命令及 Token 在本地网络主机上运行命令。我们还可以加上--name cloudflared -d --restart unless-stop为 Docker 容器增加名称和后台运行。你可以使用下方我修改好命令来创建 Docker,注意替换你为自己的 Token(就是网页中—-token 之后的长串字符) docker run --name cloudflared -d --restart unless-stop cloudflare/cloudflared:latest tunnel --no-autoupdate run --token <YourToken> 5. 配置域名和转发 URL 为你的域名配置一个子域名(Subdomain),Path 留空,URL 处填写内网服务的 IP 加端口号。注意 Type 处建议使用 HTTP,因为 Cloudflare 会自动为你提供 HTTPS,因此此处的转发目标可以是 HTTP 服务端口。 配置内网目标 IP+端口 这里要注意,配置的 ip 如果是 127.0.0.1 或者是 localhost,是不行的 对于 linux 可以创建一个桥接网络 下面的 localNet 是网络名字,可自行修改;关于 192.168.0.0 这个子网,也可以自行定义. 默认按照下面的命令,执行后将可以通过 192.168.1.100 访问宿主机. # 使用192.168.1.100替换127.0.0.1,如mongodb://192.168.1.100:27017 docker network create -d bridge --subnet 192.168.0.0/24 --gateway 192.168.1.100 localNet 完成 接着访问刚刚配置的三级域名,例如 https://app.yourdomain.com(是的,你没看错,是 https,cloudflare 已经自动为域名提供了 https 证书)就可以访问到内网的非公端口号服务了。一个 Tunnel 中可以添加多条三级域名来跳转到不同的内网服务,在 Tunnel 页面的 Public Hostname 中新增即可。 为你的服务添加额外验证 如果你觉得这种直接暴露内网服务的方式有较高的安全风险,我们还可以使用 Application 功能为服务添加额外的安全验证。 1. 点击 Application - Get started。 创建 Application 2. 选择 Self-hosted。 选择类型 3. 填写配置,注意 Subdomain 和 Domain 需要使用刚刚创建的 Tunnel 服务相同的 Domain 配置。 配置三级域名 4. 选择验证方式。填写 Policy name(任意)。在 Include 区域选择验证方式,示例图片中使用的是 Email 域名的方式,用户在访问该网络时需要使用指定的邮箱域名(如@gmail.com)验证,这种方式比较适合自定义域名的企业邮箱用户。另外你还可以指定特定完整邮箱地址、IP 地址范围等方式。 选择验证方式 5. 完成添加 此时,访问 https://app.yourdomain.com 可以看到网站多了一个验证页面,使用刚刚设置的域名邮箱,接收验证码来访问。 评价 除了上述直接转发 http 服务之外,Tunnel 还支持 RDP、SSH 等协议的转发,玩法丰富。 作为一款免费的服务,简单的配置,低门槛使用条件,适合简单部署尝试。 通过 ssh 隧道部署,还可以跳过国内服务器域名必须备案的条件。 不过要注意的是 Tunnel 在国内访问速度不快,并且有断流的情况,请酌情使用。

2025/12/31
articleCard.readMore

2023 年购物回忆录:值不值得买

2023年,对我来说是充满购物冒险的一年,我购买了很多东西,今天,我想回顾一下,分享哪些购买对我来说物超所值,哪些则是我冲动消费的产物,让我感到后悔。让我们开始这段回忆旅程吧~??️ 超值购物体验 3D 打印机 拓竹 A1 2199RMB ????? 长久以来,我一直对3D打印充满好奇。23年年尾,拓竹推出了尺寸价格各方面都还蛮契合我期望的A1,首发抢到!也可能是因为到目前时间很短,新鲜感未消,所以,他是我综合来看,2023年购买的最值、体验最好的产品,当然我还有很多的坑的知识要去补,慢慢把它玩好,说不定以后再升级更贵的P系列呢哈哈~ spotify 会员 120RMB/年 爱合租 ??? 使用的是爱合租这个平台(非广),已经忘了大概什么时候发现了爱合租这个平台,应该是之前推上有人说过还是谁的博客提了一嘴。 很多人说Spotify的推荐算法是最NB的,想从apple music 切过去看看,尝试尝试。所以我很早就下载体验了一下,但是单买太贵,而现在有了合租的渠道。 但是比较难受的是,苹果不支持语音控制播放,生态各方面不太行,快捷指令我也没有遇到什么合适的。总体来说,听歌的频率更低了,是不是也一方面说明我更忙了,没时间emo了? youtube 会员 60RMB/年 ??? 之前一直是 YouTube的免费用户,努力贡献广告收入,但是非会员无法后台播放,就很气,得一直开着前台,受不了简直是受不了了,从推上发现了一个卖的,他在tg上有频道专门卖,60一年,很便宜,确实解决了我的问题。 switch 双人成行 170RMB ????? 这应该是我在switch上通关的第一个大作,和女朋友一起玩的,确实很不错,是我玩过最好的双人游戏,也一定是目前switch上最好的双人游戏,值得每一对情侣去尝试。我比较晕3d(无缘塞尔达),但这个是可以玩的,游戏做的确实好,超值! 卤素台灯 165RMB ????? 由于家里没啥光照,窗户为了保暖我也给封起来了,23年尾,我想给家里养几盆花,由于之前我养的无一例外的挂掉了,这次我直接就想到了我需要准备好的光照,充足的光照。我就从网上找什么生物灯、补光灯,最后发现这款卤素台灯不错,外观也好看,既可以当台灯,也可以给植物补光,一整个契合我的需求,完美! 实际效果也非常非常给力,绿箩 发财树 常春藤都在夸夸长~ 我后来买了一个小米智能插座3,配合快捷指令,每天语音、定时开关,体验翻倍!这里夸一下小米插座,其实小米插座也是一个五星产品哦~ 后悔的购物决定 风行折叠车 5300RMB+无数配件 ? 今年换工作的时候上头买了一辆折叠车,花了我今年最贵的单品价格,我之前还专门写了一篇去记录整个购买过程,唉,大几千买了回来吃灰,哈哈哈哈 最亏的一个,希望24年夏天可以骑起来! 个人感慨 寻找生活的乐趣 2023年,我在购物上的探索,不仅仅是物质的积累,更是我对生活质量追求的体现。每一次点击“购买”,背后都有我对生活品质的向往和对新鲜事物的好奇。尤其是3D打印机拓竹A1,它不仅仅是一件商品,更是我梦寐以求的创造工具。每当我看到它打印出的第一个作品时,那种成就感和快乐是无法用言语表达的。 反思与成长 然而,并不是每一次的购物都带来了预期中的满足。比如风行折叠车,它让我意识到,有时候我们对事物的渴望,更多是一时的冲动,而非真正的需要。这种反思让我学会了在冲动购物和理智选择之间找到平衡。 未来展望 理性消费的决心 面对2024年,我计划更加理性地对待我的购物欲望。在购买前,我会花时间思考这件物品是否真正适合我的生活方式,它是否能长期为我的生活带来价值。同时,我也想尝试减少不必要的开支,专注于提升现有物品的使用体验,比如学习更多关于3D打印的技巧,或是开发更多与Spotify相结合的智能生活场景。 拥抱新技术,追求高质量生活 我对新技术和高质量生活的追求不会停止。在未来,我期待能遇到更多像卤素台灯这样的产品,它们不仅美观实用,还能提升我的生活品质。同时,我也希望能够更加注重生态与健康,例如选择更环保的产品,或是投资于提升居家环境的物品。

2025/12/31
articleCard.readMore

探索服务端通信技术:短轮询、WebSocket、SSE 与长轮询的深度比较

在现代 Web 应用中,服务端与客户端之间的高效通信至关重要。本文探讨了四种主流的服务端通信方法:短轮询、WebSocket、SSE(Server-Sent Events)和长轮询,分析它们的工作原理、适用场景及优缺点。 一、短轮询:高兼容性的传统选择 短轮询是服务端通信的一种基本方法,客户端通过定期发送 HTTP 请求来检查服务器上的更新。 实际应用案例: 适用于新闻网站或博客的评论更新,用户可以在较短的时间内看到新的评论。 优点: 高兼容性,适用于所有支持 HTTP 的客户端。 缺点: 高资源消耗,频繁建立和关闭 TCP 连接。 使用场景: 最适合不频繁更新且对实时性要求不高的应用。 最普通的一个场景:客户端定期向服务器请求最新消息 package main import ( "fmt" "net/http" "time" ) func main() { http.HandleFunc("/poll", func(w http.ResponseWriter, r *http.Request) { // 假设这里是从数据库或某个存储检索最新消息的逻辑 message := "Hello, world! - " + time.Now().Format(time.RFC1123) fmt.Fprint(w, message) }) http.ListenAndServe(":8080", nil) } 二、WebSocket:实时双向通信的理想选择 WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议,适用于需要实时双向通信的应用。 实际应用案例: 在线游戏、股票交易平台,这些应用需要实时的数据交互。 优点: 低延迟,适用于实时通信。 缺点: 在某些网络环境下可能受限。 使用场景: 高效的实时通信,特别是需要频繁数据交换的场景。 最常用的一个场景:实时聊天应用 服务端和客户端通过 WebSocket 保持连接,实现实时通讯。 package main import ( "net/http" "github.com/gorilla/websocket" ) var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, } func main() { http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { conn, _ := upgrader.Upgrade(w, r, nil) // 错误处理省略 for { messageType, p, _ := conn.ReadMessage() // 错误处理省略 conn.WriteMessage(messageType, p) // 错误处理省略 } }) http.ListenAndServe(":8080", nil) } 三、 SSE:简单的单向服务器推送技术 SSE 允许服务器向客户端单向发送更新,是一种基于 HTTP 的技术。 实际应用案例: 实时新闻更新、股票市场的价格更新。 优点: 实现简单,支持自动重连。 缺点: 浏览器兼容性问题,无法实现双向通信。 使用场景: 适用于单向数据流的应用,如实时新闻或股价更新。 一个典型的应用场景:股票价格更新 package main import ( "fmt" "net/http" "time" ) func main() { http.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") for i := 0; ; i++ { fmt.Fprintf(w, "data: Price update %d\n\n", i) w.(http.Flusher).Flush() time.Sleep(time.Second * 2) // 模拟数据更新 } }) http.ListenAndServe(":8080", nil) } 四、长轮询:短轮询的高效替代 长轮询在服务器有数据更新时才响应客户端请求,是短轮询的改进版。 实际应用案例: 社交媒体通知更新,如 Facebook 或 Twitter 的新消息提示。 优点: 减少不必要的轮询请求,相对于短轮询更高效。 缺点: 仍需频繁建立和关闭连接。 使用场景: 适用于对实时性要求不是非常高,但更新频率较高的场景。 我最近使用到的一个应用场景:AI 生图以后,通知给客户端 package main import ( "fmt" "net/http" "time" ) func main() { messages := make(chan string) // 消息通道 go func() { for { time.Sleep(time.Second * 5) // 每5秒生成一条消息 messages <- fmt.Sprintf("New message at %v", time.Now()) } }() http.HandleFunc("/long-poll", func(w http.ResponseWriter, r *http.Request) { message := <-messages // 等待新消息 fmt.Fprint(w, message) }) http.ListenAndServe(":8080", nil) } 结论 在选择服务端通信技术时,应考虑应用的具体需求和场景。WebSocket 适合需要高实时性和双向通信的应用;SSE 适用于简单的单向数据推送;而短轮询和长轮询则适用于更新频率不高的场景。 选择合适的技术可以显著提高用户体验和应用性能。 我也曾遇到 WebSocket 一把梭的,在一些性能不敏感的地方,确实没有什么区别,但是,这不应该是一个技术人员的追求。

2025/12/31
articleCard.readMore

API 设计中的多类型属性选择:OpenAPI 与 gRPC 的 oneof 与强类型对比

在谈论 API 设计和开发时,有时,一个属性可以是多种类型中的一个,但不能同时是多种类型。比如支付接口的回调处理,常常为了兼容不同平台的参数,会使用以下方式中的一种来进行接收: 范型 key-value 形式的 map 所有的 Object 都去接收,枚举哪个取哪个 但这种模式,往往会造成参数内容的不规范 、接口维护困难 或者是浪费网络传输带宽。 在程序开发中,我们往往会采用主流的 HTTP 协议和 gRPC 协议进行通信,两种技术都为开发者提供了强大的工具来描述、验证和生成 API,但它们的方法和原则有所不同。 OpenAPI 和 oneof OpenAPI,早前被称为 Swagger,是一个用于描述 RESTful API 的规范。在其 3.0.1 版本中,引入了oneof关键字。 原因: RESTful API 设计经常遇到一个属性可以是多种类型中的一个的情况。oneof提供了一种简单、明确的方式来描述这种复杂性。 好处: 它使得模式更具表现力和灵活性,允许属性值匹配其中一个定义的模式。 gRPC 和 oneof gRPC 使用 Protocol Buffers (ProtoBuf) 作为其接口定义语言。ProtoBuf 中也有一个oneof关键字,但其用途与 OpenAPI 中的略有不同。 原因: 在 RPC 通信中,特别是在跨语言的场景下,有时需要表示一个值可以是多种数据类型中的一个。oneof为此提供了一个优雅的解决方案。 好处: 它保证了在任何给定时间,oneof内的字段只能设置一个,这有助于节省存储空间和序列化/反序列化时间。 强类型支持 强类型是 OpenAPI 和 gRPC 都强烈支持的一个核心概念。 强类型保证了数据的一致性:开发者在设计时定义了期望的数据类型,这有助于防止意外的类型错误。 提高性能:知道数据的确切类型可以优化存储和访问。 我们可以达到的是 错误检测:可以在编译时(或验证时,对于 OpenAPI)捕获类型错误,而不是在运行时。 代码清晰:类型声明或注释为其他开发者提供了有关数据的清晰、明确的信息。 尽管 OpenAPI 和 gRPC 在处理oneof和强类型时有所不同,但它们的目标是相同的:提供明确、一致和可靠的 API 描述。选择哪种技术取决于具体的应用场景,但了解这些技术如何处理这些关键概念可以帮助开发者做出明智的决策。

2025/12/30
articleCard.readMore

团队开发中对于代码仓库和镜像管理的标准统一

随着现代软件开发的持续迭代,团队间的协作变得越来越重要。为了确保整个团队在编码、构建和部署过程中的效率和质量,引入一些特定的配置文件和标准工作流程显得尤为关键。以下就是一些在团队开发中常见而又重要的文件,我总结了它们的一些作用和优点。 1. .editorconfig 作用:.editorconfig 是一种定义代码格式约定的文件,它用于统一不同编辑器和 IDE 的代码格式设置。 好处: 保证团队成员间代码风格的一致性。 避免因为不同的代码风格导致的无意义的代码差异。 提高代码的可读性和维护性。 2. .dockerignore 作用:定义哪些文件或目录应该被 Docker 忽略,不被包括在镜像构建中。 好处: 减少 Docker 镜像的大小。 加快构建速度。 避免将敏感或不必要的文件包含在 Docker 镜像中。 3. .gitignore 作用:指定哪些文件或目录应该被 Git 忽略,不被包括在版本控制中。 好处: 保持仓库的整洁。 避免将编译产物、日志、临时文件等不必要的文件添加到 Git 中。 防止敏感数据泄露。 4. .http 文件 作用:这是一个用于 API 测试的文件,可以在一些 IDE 如 Visual Studio Code、IDEA 中直接运行。 好处: 简化 API 测试流程。 提供一个可共享和可版本化的 API 测试集合。 5. Dockerfile 作用:描述如何自动构建 Docker 镜像的指南。 好处: 保证镜像构建的一致性。 简化和标准化部署流程。 提供一个清晰的、版本化的构建描述。 6. Makefile 作用:定义一组任务,如编译、测试和部署等,以自动化工作流程。 好处: 提高工作效率,简化复杂任务。 为新成员提供了一个明确的指南。 保证任务的一致性和可重复性。 7. Jenkinsfile 作用:描述如何在 Jenkins 上自动化构建、测试和部署流程。 好处: 实现持续集成和持续部署。 提高代码质量,通过自动化测试减少错误。 加速软件交付速度。 以上所列出的各种文件在整个 CI/CD 中都很重要。它们不仅可以帮助团队保持代码的一致性、高质量和安全性,还可以大大提高开发和部署的效率。如果你的团队中有哪个工具没有使用到,很开心可以分享给你,如果你觉得有什么同样重要的文件我没有提到,欢迎留言。

2025/12/30
articleCard.readMore

我的自行车

在今年7月份左右,我开始关注自行车,出于便携性而言,我更倾向于购买一辆折叠车,之前在大街上也看到了很多骑得超快的折叠车,所以我对于折叠车的竞速性能也是满意的,于是我开始看各种折叠车的测评和论坛。 折叠车和山地、公里车相比,会更加复杂,零件会更多一些。同样的,有整车 有组装。但在折叠车的市场里,不想公路和山地,大多数购买整车,在折叠车这里,组装是主市场。 组装,主要的零件就是车架,折叠车里最火的就是风行的车架。除了风行,另外就是大行、飞鱼 和 小布,大行是卖的最多的折叠车整车品牌,小布是一个很“高端豪华”设计很时尚的很贵的折叠车品牌。 风行蚂蚁腿: 大行P8: 小布折叠车: 因为风行折叠车的可选配、自定义方案足够多,所以我选择了从风行中寻找方案。风行的折叠车主要有四种车架,蚂蚁腿、Y架、K架、海豚架。 而我的出行并没有特别的需求,通勤短程使用,没有特别的需求,好看即可,而蚂蚁腿就是颜值这块儿的顶梁柱。 然后我看了很多的方案,看到最后,给我审美整疲劳了,最后把之前觉得好看的发给了女朋友,让她来给我选,最后,她一眼就看中了非碳的最贵的一个。。。 这个车架的颜色非常漂亮,有风行的标,但不是风行的颜色。问过老板才知道,是他们家自己喷的,我再翻,就没有过第二家这么做的了。 整车配置如下: 车架大概是 1200 + 700 涂装 两个轱辘 加一起 1k多 碳前叉 1k 加上其他的零零散散碎碎 ¥5300 看到这个价格,我当时确实是跪了。 买车最开始嚷嚷着预算800, 后来看着看着 1600, 再看风行折叠车9速最低配也要2500,好家伙一层一层,最后终于超越了5k。。。 可能是价格的烘托,这车是越看越好看。 女朋友觉得我喜欢的东西,买就完了。想到自己今年要换工作,工资也涨了一点点,买! 随后我也是痛痛快快下单了。 经过半个多月的漫长等待,商家终于想起我了,然后用了一天给我喷了漆,连夜顺丰,给我发过来了。 经过比较漫长的挑选和等待,我终于收到了心爱的车~ 开箱,店家送了一副鲜红色的脚踏板,我透,然后我和店家说颜色不合适呀,店家又给我发了一个。 就是上面两幅,黑的发来的是个坏的,左脚螺丝口对的有问题,装上骑也有异响。我忍不了等待了,自己花一百多买了一副。 然后就是把套,也是在上图,是一个海绵把套,骑了几公里后我发现手腕疼,赶紧换! 海绵把套还贼难拆,直接刀子剌掉。 考虑过上弯把和牛角把,像这样的: 但是发现,会影响折叠(特别是我的车头管是内折的)。所以最后选了一个大行的把套,感觉还是不错的。 关于折叠车,业界是标配不上脚撑子和挡泥板的,我装一下,我也不上! 为了安全,买了车前灯和尾灯还有铃铛,买了自行车挎包 买了骑行护目镜,头盔目前还没有买。 至此,我的自行车相关配件也购买完毕。 开始遇到一些问题: 骑行异响 骑的时候,不是牙盘响,认真听 是车前面的部分。后来联系了商家,确认不会是别的问题,我就往前把的各种关节加了WD-40,解决! 换挡不流畅 5挡以下,换挡来回跳,后来问了下店家,说后面有一个档位的微调螺丝,调整下就好了,我就在车库调调骑骑,弄得差不多算流畅吧。 另外还有一个温馨提示,我这个车是碟刹嘛,刹车动作如果比较多,骑完车一定不要手贱去摸那个刹车片。 可以开始畅快地骑行了,然后,我发现,公司不让把自行车折叠带上楼!!! 放楼下又怕刮蹭,毕竟这车架就小两千啦,还是心疼的。 再往后两天,我又换了工作,离家17公里多,啊~ 骑不动了,不骑了 吃灰开始。

2025/12/31
articleCard.readMore

使用 rclone 命令行管理 Cloudflare R2 对象存储

Cloudflare R2 是一款高性能、低成本的对象存储服务。之前在使用 OSS 的时候,用惯了 OSS brewer, 在用 R2 时候发现,开始没有找到合适的 brewer 工具,而网页端上传会限制文件数量和大小,就尝试用命令行的 client 了哈哈, 使用之后发现,确实好用,相比使用网页管理对象存储,利用命令行工具可以大大提高管理效率,特别适合需要批量操作或脚本化的场景。 评论老哥分享了一个 client 工具: https://cyberduck.io/ 我尝试用下试试~ 安装 rclone sudo -v ; curl https://rclone.org/install.sh | sudo bash 查看 config 文件识别的地址 rclone config file # Configuration file doesn't exist, but rclone will use this path: # /Users/xxx/.config/rclone/rclone.conf # 编辑文件配置 vi /Users/xxx/.config/rclone/rclone.conf [testConfig] type = s3 provider = Cloudflare access_key_id = abc123 secret_access_key = xyz456 endpoint = https://<accountid>.r2.cloudflarestorage.com acl = private 这里我创建了一个名为 bucket 的桶 所需的 access_key_id 和 secret_access_key 需要另外申请: 设置完成后,就可以对 cloudflare 的桶资源进行管理了。 列出存储桶和对象 # 列出所有存储桶: rclone lsd testConfig: # 列出所有桶及内部目录文件 ~ rclone tree testConfig: ... # 列出指定桶及内部目录文件 ~ rclone tree testConfig:bucket / └── 2023 ├── 08 │ ├── 1690960360295.png │ └── 1692713312723.png ├── 09 │ ├── 1693822893.jpg │ └── 1694588667.png └── 10 └── 1696911351.png # 创建新桶: rclone mkdir testConfig:bucket # 删除空桶: rclone rmdir testConfig:bucket # 列出对象列表: rclone ls testConfig:path # 计算对象存储总量: rclone size testConfig:path 上传和检索对象 # 上传本地文件或目录: # rclone copy [目录或者文件] test:桶名+路径 rclone copy helloworld testConfig:bucket # 查看 rclone tree testConfig:bucket # 下载对象到本地: # rclone copy test:桶名+路径 本地目标路径 rclone copy testConfig:bucket/README.md . # 删除 # rclone delete test:桶名+路径 rclone delete testConfig:bucket/README.md # 更多 rclone --help

2025/12/31
articleCard.readMore

GORM 中 SQL、慢 SQL 打印日志传递 trace ID

实现 gorm.io/gorm/logger 下的函数⬇️ // gorm 源码 type Interface interface { LogMode(LogLevel) Interface Info(context.Context, string, ...interface{}) Warn(context.Context, string, ...interface{}) Error(context.Context, string, ...interface{}) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) } 以下为自定义的重写实现,主要函数是Trace package log import ( "context" "time" "github.com/go-kratos/kratos/v2/log" "gorm.io/gorm/logger" ) type GormLogger struct { SlowThreshold time.Duration } func NewGormLogger() *GormLogger { return &GormLogger{ SlowThreshold: 200 * time.Millisecond, // 一般超过200毫秒就算慢查所以不使用配置进行更改 } } var _ logger.Interface = (*GormLogger)(nil) func (l *GormLogger) LogMode(lev logger.LogLevel) logger.Interface { return &GormLogger{} } func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) { log.Context(ctx).Infof(msg, data) } func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) { log.Context(ctx).Errorf(msg, data) } func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) { log.Context(ctx).Errorf(msg, data) } func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { // 获取运行时间 elapsed := time.Since(begin) // 获取 SQL 语句和返回条数 sql, rows := fc() // Gorm 错误时打印 if err != nil { log.Context(ctx).Errorf("SQL ERROR, | sql=%v, rows=%v, elapsed=%v", sql, rows, elapsed) } // 慢查询日志 if l.SlowThreshold != 0 && elapsed > l.SlowThreshold { log.Context(ctx).Warn("Database Slow Log, | sql=%v, rows=%v, elapsed=%v", sql, rows, elapsed) } } 这里我使用的log组件是kratos框架的log组件,设置好zap后注入为全局log 在GORM创建连接的地方注入我们重写后的自定义Logger db, err := gorm.Open(mysql.Open(cfg.Dsn()), &gorm.Config{ QueryFields: true, Logger: logdef.NewGormLogger(), // 注入 }) 最后,在查询的地方,带上withContext即可 func (ud *userDao) AddOne(c context.Context, user *model.User) (userId int64, err error) { err = db.GetConn().WithContext(c).Create(user).Error if err != nil { log.Context(c).Errorf("AddOne fail|err=%+v", err) return } return user.Id, nil }

2025/12/31
articleCard.readMore

更换博客图床,从 OSS 到 GitHub 再到 R2

写博客一个很重要的东西就是图床, 如果没有一个合适的图床来安全稳定地存储数据,指不定哪天,你的文章就到处都是 而对于一个曾经被ban过号,崩过盘(硬盘)的人来说,分外敏感。如果你的图片存储在任何一个带监管的平台,你的账号很可能由于一些不可抗力资源牵连,内容全部404。 于是我对自己的博客相关的资源都进行了“安全”的部署,图片资源这块儿一年多来,我一直使用的是github做图床的,但是github有几个问题: 一个仓库的存储大小限制1G,如果超过限制,很可能会被ban库的。 GitHub 阻止大小超过 100 MB 的文件。 GitHub 在国内网络下无法访问。 于是,几个月前把云资源全部迁到cloudflare的我,又有了白嫖的念头。 能不能使用R2来做图床呢?之前用过阿里OSS做图床的我,本能地想到,一定是可以的! 简单搜搜,R2图床~ 不出所料~哈哈哈哈简简单单 当然前提是你需要有一个cloud flare 账号哦 一. cloud flare操作 1. 到cloud flare创建一个bucket 2. 对bucket进行配置 3. 然后到r2的列表菜单,申请一个secret 二. 本地操作 使用pic-go进行上传,如果没有,请安装一个 1. 在设置中的默认图床不包含cloud flare ,需要安装一下插件,这里的插件需要安装的是aws的S3插件,该插件兼容国外同S3类型的这些所有的对象存储。 需要安装这两个插件,一个是功能,一个是展示。 然后在设置处就会出来新的选项 2. 填写下刚才获取到的那些参数 最后效果如下: ![image.png](https://pic.whrss.com/2023/08/1690961579898.png) 完美~

2025/12/31
articleCard.readMore

如何在 Go 中实现程序的优雅退出,go-kratos 源码解析

使用kratos这个框架有近一年了,最近了解了一下kratos关于程序优雅退出的具体实现。 这部分逻辑在app.go文件中,在main中,找到app.Run方法,点进入就可以了 它包含以下几个部分: App结构体:包含应用程序的配置选项和运行时状态。 New函数:创建一个App实例。 Run方法:启动应用程序。主要步骤包括: 构建ServiceInstance注册实例 启动Server 注册实例到服务发现 监听停止信号 Stop方法:优雅停止应用程序。主要步骤包括: 从服务发现中注销实例 取消应用程序上下文 停止Server buildInstance方法:构建用于服务发现注册的实例。 NewContext和FromContext函数:给Context添加AppInfo,便于后续从Context获取。 核心的逻辑流程是: 创建App实例 在App.Run()里面启动Server,注册实例,监听信号 接收到停止信号后会调用App.Stop()停止应用 我们先对Run方法进行一个源码进行查看 // Run executes all OnStart hooks registered with the application's Lifecycle. func (a *App) Run() error { // 构建服务发现注册实例 instance, err := a.buildInstance() if err != nil { return err } // 保存实例 a.mu.Lock() a.instance = instance a.mu.Unlock() // 创建错误组 eg, ctx := errgroup.WithContext(NewContext(a.ctx, a)) // 等待组,用于等待Server启动完成 wg := sync.WaitGroup{} // 启动每个Server for _, srv := range a.opts.servers { srv := srv eg.Go(func() error { // 等待停止信号 <-ctx.Done() // 停止Server stopCtx, cancel := context.WithTimeout(a.opts.ctx, a.opts.stopTimeout) defer cancel() return srv.Stop(stopCtx) }) wg.Add(1) eg.Go(func() error { // Server启动完成 wg.Done() // 启动Server return srv.Start(NewContext(a.opts.ctx, a)) }) } // 等待所有Server启动完成 wg.Wait() // 注册服务实例 if a.opts.registrar != nil { rctx, rcancel := context.WithTimeout(ctx, a.opts.registrarTimeout) defer rcancel() if err := a.opts.registrar.Register(rctx, instance); err != nil { return err } } // 监听停止信号 c := make(chan os.Signal, 1) signal.Notify(c, a.opts.sigs...) eg.Go(func() error { select { case <-ctx.Done(): return nil case <-c: // 收到停止信号,停止应用------------- ⬅️注意此时 return a.Stop() } }) // 等待错误组执行完成 if err := eg.Wait(); err != nil && !errors.Is(err, context.Canceled) { return err } return nil } 核心逻辑就是这里⬇️,使用signal.Notify去监听操作系统给出的停止信号。 // 监听停止信号 c := make(chan os.Signal, 1) signal.Notify(c, a.opts.sigs...) eg.Go(func() error { select { case <-ctx.Done(): return nil case <-c: // 收到停止信号,停止应用 return a.Stop() } }) 然后调用了Stop方法,我们再看下Stop的源码 // Stop gracefully stops the application. func (a *App) Stop() error { // 获取服务实例 a.mu.Lock() instance := a.instance a.mu.Unlock() // 从服务发现注销实例 if a.opts.registrar != nil && instance != nil { ctx, cancel := context.WithTimeout(NewContext(a.ctx, a), a.opts.registrarTimeout) defer cancel() if err := a.opts.registrar.Deregister(ctx, instance); err != nil { return err } } // 取消应用上下文 if a.cancel != nil { a.cancel() } return nil } 主要步骤是: 获取已经保存的服务实例 如果配置了服务发现,则从服务发现中注销该实例 取消应用上下文来通知应用停止 在Run方法中,我们通过context.WithCancel创建的可取消的上下文Context,在这里通过调用cancel函数来取消该上下文,以通知应用停止。 取消上下文会导致在Run方法中启动的协程全部退出,从而优雅停止应用。 所以Stop方法比较简单,关键是利用了Context来控制应用生命周期。 我们可以注意到,在Run方法中,我们使用到了一个signal包下的Notify方法来对操作系统的关闭事件进行监听,这个是我们动作的核心,我把这部分单独整理在了另一篇文章中。 通过对操作系统事件的监听,我们就可以对一些必须完成的任务进行优雅地停止,如果有一些任务必须完成,我们可以在任务开始使用 wg := sync.WaitGroup{} 来对任务进行一个Add操作,当所有任务完成,监听到操作系统的关闭动作,我们需要使用wg.wait() 等待任务完成再进行退出。以实现一个优雅地启停。

2025/12/30
articleCard.readMore

os.signal golang 中的信号处理

在程序进行重启等操作时,我们需要让程序完成一些重要的任务之后,优雅地退出,Golang为我们提供了signal包,实现信号处理机制,允许Go 程序与传入的信号进行交互。 Go语言标准库中signal包的核心功能主要包含以下几个方面: 1. signal处理的全局状态管理 通过handlers结构体跟踪每个signal的处理状态,包含信号与channel的映射关系,以及每个信号的引用计数。 2. 信号处理的注册与注销 Notify函数用于向指定的channel注册信号处理,会更新handlers的状态。 Stop函数用于注销指定channel的信号处理,将其从handlers中移除。 Reset函数用于重置指定信号的处理为默认行为。 3. 信号的抓取与分发 process函数在收到signal时,会把它分发给所有注册了该信号的channel。 4. signal处理的恢复 通过cancel函数,可以恢复signal的默认行为或忽略。 5. Context信号通知支持 NotifyContext函数会创建一个Context,在Context结束时自动注销signal处理。 6. 处理signal并发访问的同步 通过handlers的锁保证对全局状态的线程安全访问。 7. 一些工具函数 如handler的mask操作,判断signal是否在ignore列表中等。 总的来说,该实现通过handlers跟踪signal与channel的关系,在收到signal时分发给感兴趣的channel,提供了flexible和高效的signal处理机制。 在实际地使用中,我们需要创建一个接收信号量的channel,使用Notify将这个channel注册进去,当信号发生时,channel就可以接收到信号,后续的业务就可以针对性地进行处理。如下: package main import ( "fmt" "os" "os/signal" "syscall" ) func main() { // 创建一个channel来接收SIGINT信号 c := make(chan os.Signal) // 监听SIGINT信号并发送到c signal.Notify(c, syscall.SIGINT) // 使用一个无限循环来响应SIGINT信号 for { fmt.Println("Waiting for SIGINT") <-c fmt.Println("Got SIGINT. Breaking...") break } } 共有32个信号量,相对应的枚举在syscall包下 常用的信号值包括: SIGHUP 1 终端控制进程结束(终端连接断开) SIGINT 2 用户发送INTR字符(Ctrl+C)触发 SIGQUIT 3 用户发送OUIT字符(Ctrl+/触发 SIGKILL 9 无条件结束程序(不能被捕获、阻塞或忽略) SIGUSR1 10 用户保留 SIGUSR2 12 用户保留 SIGPIPE 13 消息管道损坏(FIFO/Socket通信时,管道未打开而进行写操作) SIGALRM 14 时钟定时信号 SIGTERM 15 结束程序(可以被捕获、阻塞或忽略) 在go框架中,项目中实际使用到signal进行优雅退出见:如何在go中实现程序的优雅退出,go-kratos源码解析

2025/12/31
articleCard.readMore

探索 PlanetScale:划分分支的 MySQL Serverless 平台

最近我发现了一个非常有趣的国外MySQL Serverless平台,它叫做PlanetScale。这个平台不仅仅是一个数据库,它能像代码一样轻松地创建开发和测试环境。你可以从主库中拉出一个与之完全相同结构的development或staging数据库,并在这个环境中进行开发和测试。所有的数据都是隔离的,不会相互干扰。 当你完成开发后,你可以创建一个deploy request,PlanetScale会自动比对并生成Schema diff,然后你可以仔细审查需要部署的内容。确认没问题,你就可以将这些变更部署到线上库中。整个部署过程不会导致停机时间,非常方便。 PlanetScale的入门使用是免费的,他们提供了以下免费套餐: 5GB存储空间 每月10亿行读取操作 每月1000万行写入操作 1个生产分支 1个开发分支 社区支持 如果超出了免费套餐的限制,他们会按照以下价格收费:每GB存储空间每月2.5美元,每10亿行读取操作每月1美元,每100万行写入操作每月1.5美元。对于我这样的个人使用者,真的太不错了。 这个平台运行在云上,提供了一个Web管理界面和一个CLI工具。我试了一下他们的Web管理界面,但发现它并不是很好用,无法进行批量的SQL执行。于是我研究了一下CLI工具的使用,并做了一份小记录,现在和大家分享一下。 以下是在 Mac 中使用PlanetScale CLI工具的步骤: 其他系统安装可见:官方文档 1. 安装pscale工具 brew install planetscale/tap/pscale 2. 更新brew和pscale,确保使用的是最新版本 brew update && brew upgrade pscale 3. 进行认证 pscale auth login 这个命令会在浏览器中打开一个页面 如果你已经登录了PlanetScale账号,它会直接让你确认验证。验证成功后,你就可以开始使用CLI工具了。 如果你走到这步的时候提示: Error: error decoding error response: invalid character '<' looking for beginning of value 你需要调整一下网络~ 目前是不给大陆用户IP使用的。 4. 连接到相应的数据库分支 pscale connect [数据库名] [分支名] # 例如: pscale connect blog main 连接成功后,你就可以通过本地的3306端口代理访问远程数据库了。 Secure connection to database whrss and branch main is established!. Local address to connect your application: 127.0.0.1:3306 (press ctrl-c to quit) 5. 本地连接 点击Get connection strings,你就可以得到连接数据库所需的账号名和密码,然后可以在本地的数据库连接软件中直接连接数据库了。 ![](https://raw.githubusercontent.com/whrsss/pic-sync/master/img/202307121100351.png) 选择适合你的编程语言的连接串,这样你就可以在不同的程序中直接使用了。 通过这些简单的步骤,你就可以轻松地使用PlanetScale来管理和部署你的MySQL应用了。快来体验一下吧!

2025/12/30
articleCard.readMore

优化你的RSS订阅:一次全面改进的实践

先前,我的 RSS 订阅功能过于简化,只提供了几个基本字段,而且不展示全文。简介后面,我添加了一个链接指向原文,如下所示: 过于简陋,无法直接在 RSS 阅读器软件上进行查看,另外,我临时用 Kotlin 编写的这个功能需要每次调用时重新生成,这点就对这个功能的简单和独立性产生了影响,这件事放了太久,最近进行一次全面的改进。 原始的文章是用 Markdown 编写的。为了方便通过手机或电脑上的 RSS 阅读器查看,我需要将文章内容转换为 HTML 格式。在 Go 语言中,有一个成熟的组件:blackfriday。它能把每段文字用 p 标签标记,用 code 和 pre 标签标记代码块,以及用 h1234 标签标记不同层级的标题。 github.com/PuerkitoBio/goquery github.com/russross/blackfriday/v2 经过一次生成并查看效果后,我发现有些地方需要改进。例如,图片和文字都是左对齐的,看起来并不美观。另外,非代码块的单引号也会被转化为代码块,导致原本可以在一行显示的内容被拆分为三行。于是我添加了一些附加功能: func Md2Html(markdown []byte) string { // 1. Convert markdown to HTML html := blackfriday.Run(markdown) // 2. Create a new document from the HTML string doc, err := goquery.NewDocumentFromReader(bytes.NewReader(html)) if err != nil { log.Fatal(err) } // 3. Process all elements to have a max-width of 1300px and text alignment to left doc.Find("p, h1, h2, h3, h4, h5, h6, ul, ol, li, table, pre").Each(func(i int, s *goquery.Selection) { s.SetAttr("style", "max-width: 1300px; display: block; margin-left: auto; margin-right: auto; text-align: left;") }) // 4. Process the images to be centered and have a max size of 500x500 doc.Find("img").Each(func(i int, s *goquery.Selection) { s.SetAttr("style", "max-width: 500px; max-height: 500px; display: block; margin-left: auto; margin-right: auto;") }) // 5. Process code blocks to be styled like in markdown, and inline code to be bold doc.Find("code").Each(func(i int, s *goquery.Selection) { if goquery.NodeName(s.Parent()) == "pre" { // this is a code block, keep the markdown style s.SetAttr("style", "display: block; white-space: pre; border: 1px solid #ccc; padding: 6px 10px; color: #333; background-color: #f9f9f9; border-radius: 3px;") } else { // this is inline code, replace it with bold text s.ReplaceWithHtml("<b>" + s.Text() + "</b>") } }) // 6. Get the modified HTML modifiedHtml, err := doc.Html() if err != nil { log.Fatal(err) } // Replace self-closing tags modifiedHtml = strings.Replace(modifiedHtml, "/>", ">", -1) return modifiedHtml } 修改之后,我把生成的 HTML 内容放入 RSS 的 XML 中。XML 内容的生成就是简单的字符串拼接,我写了一个 GenerateFeed 方法来完成: type Article struct { Title string Link string Id string Published time.Time Updated time.Time Content string Summary string } func GenerateFeed(articles []Article) string { // 对文章按发布日期排序 sort.Slice(articles, func(i, j int) bool { return articles[i].Published.After(articles[j].Published) }) // 生成Feed feed := `<feed xmlns="http://www.w3.org/2005/Atom"> <title>了迹奇有没</title> <link href="/feed.xml" rel="self"/> <link href="https://whrss.com/"/> <updated>` + articles[0].Updated.Format(time.RFC3339Nano) + `</updated> <id>https://whrss.com/</id> <author> <name>whrss</name> </author>` for _, article := range articles { feed += ` <entry> <title>` + article.Title + `</title> <link href="` + article.Link + `"/> <id>` + article.Id + `</id> <published>` + article.Published.Format(time.RFC3339) + `</published> <updated>` + article.Updated.Format(time.RFC3339Nano) + `</updated> <content type="html"><![CDATA[` + article.Content + `]]></content> <summary type="html">` + article.Summary + `</summary> </entry>` } feed += "\n</feed>" return feed } 最后效果确实十分理想,手机上的观看效果不输我的原始站点,很不错~

2025/12/30
articleCard.readMore

最近在做的事:GitHub Action | GPT Plus | whisper | V2EX | GPT API | PMP

运动数据使用GitHub Action自动更新 看着每天的运动数据,满满的,还感觉有点充实:tw-1f606: 积少成多嘛 开通体验了一下GPT Plus 太想体验下GPT4了,之前苦于充值门槛过高,上个月出了app充值后,我使用支付宝礼品卡充值体验了下,感觉还是不错的,但是个人使用确实还是按量更划算一点,但是充值门槛过高:tw-1f632: 买了一支录音笔,全天候录音,使用 whisper 录音转文字 之前全程吃瓜了刘能离婚案,后来受他种草,也买了索尼的tx660,全天候录音,之后再把录音源文件和转的文本保存起来,这样想检索也会很方便的。 终于注册了 V2EX 这个社区知道很久了,一直是游客登录,看到别人发的别人社区啊推荐啊很不错,我说我也得养养号:tw-1f61c: GPT API续费问题曲线救国 续上面充值问题,最近充值门槛更高了,对网络要求也更高了,还动不动就给封号。那我怎么整,Api确实便宜又方便,而且不用管那么复杂的网络。这不刚注册了V2EX嘛,上去翻翻。 然后我使用了这个-> aiproxy ,咱这V站注册就开始实际生产力了哈哈哈。 感觉还可以,然后使用自建的客户端输入地址和key就可以用啦。 报名了PMP的培训班和考试 6月中下旬决定报名8月份的PMP考试,一方面是我现在处于软考之后的一个学习的空窗期,一个原因是因为朋友刘某(一个年轻的项目经理)拉我,最重要的一个原因是,我确实很需要管理协调方面的工作技能,而且我刚好到了PMP的报名门槛:tw-1f604:。 报名费是真滴贵呀~

2025/12/30
articleCard.readMore

一个游戏开发者的自我介绍

我写这篇博客的初衷并不是出于单纯的自我表达,而是为了那些可能会对我未来职业发展产生重要影响的人们。或许你是一个正在考虑我的简历的HR,或者是将来可能与我一起共事的团队成员。无论你是谁,我都希望通过这篇博客让你更深入地了解我。同时,写作的过程也让我有机会重新审视和整理我的技能和经历。:tw-1f61d: 我目前就职于一家游戏公司,我们的团队虽然小但十分精悍:tw-270a:。我负责的任务非常多,目前核心是一个女性向AVG游戏的后端开发,这款游戏在TapTap上的评价和排名都非 常 高,另外我也参与公共服务和核心业务模块的开发。 除此之外,我还负责管理公司所有的服务器资源,并负责运维工作。这主要得益于我平时对很多技术的尝试和钻研,让我能够顺利应对上云带来的各种挑战。 我在2021年3月加入这家公司,两个月后便开始接手核心项目。主要的技术栈包括Kratos + Go和Spring Boot + Kotlin。目前,我们正在将新项目统一使用Go这套技术栈,因为Go不仅满足了我们的需求,还非常易于维护。在数据库方面,我个人对SQL和分布式事务更熟悉,也能够在不同的业务场景中使用不同的数据库和工具。 我加入公司之初,我们的服务和服务器都是单体和独立的,各种技术栈和老项目混乱无章,也恰逢人员交接之际。然而,随着新业务的展开,我们从零开始,大量地构建和改进,同时采用了K8S。如今,无论是服务文档、服务框架、单元测试,还是CI/CD,我们都已经运行得非常成熟,这也非常适合我们目前和未来一段时间的项目架构。 我始终认为技术人应该持续学习和进步。我觉得我已经做到了我能做到的事情,至于更多的东西,我知道我现在的知识储备可能有限,但我仍希望能更进一步。年轻的我没有太多负担,希望能够抓住机会,走得更远。这就是我正在寻找新机会的原因。我期待挑战,也期待更有趣的工作。我相信,无论结果如何,这个过程都会带给我宝贵的经验和成长。

2025/12/30
articleCard.readMore

使用GitHub Action 同步运动数据并生成热力图

如果你想把你的运动数据和热力图同步到 GitHub,那么你来对地方了。在这篇文章中,我将详细解释如何使用 GitHub Actions 和 Python 自动同步和更新你的数据。 这个项目是在伊洪@yihong0618的GitHubPoster的基础上进行的所以在此表示感谢。他的另一个项目是IBeat (之前我对GitHub Action做了一次尝试),从这里我开始接触了快捷指令的触发。 最终效果如下: 一. 开始前的准备 首先,你需要 fork @yihong0618的项目,并将其克隆到本地: https://github.com/whrsss/GitHubPoster 然后,安装项目的依赖项: pip3 install -r requirements.txt 二. 全量生成历史数据 研究了一下,步骤是先全量生成历史数据(用全量模式backfill),再进行追加(incremental)。 下面是如何导出每日运动量(卡路里)出来的例子。首先,将apple 运动中的数据导出,解压在IN_FOLDER文件夹中,然后在项目根目录运行: python3 -m github_poster apple_health --apple_health_mode backfill --year 2020-2023 --apple_health_record_type move --me "your name" 这样在OUT_FOLDER目录下就会生成一份全量的数据,IN_FOLDER下也会生成对应的全量json文件,之后的增量,会加入到IN_FOLDER的json文件中。 逻辑微调 由于我的目标是统计每日消耗的卡路里,在apple health导出的数据中,分成了两块儿, 分别是 type=“HKQuantityTypeIdentifierBasalEnergyBurned”和 type=“HKQuantityTypeIdentifierActiveEnergyBurned” ,每日基础消耗数据和每日运动活动数据。 (ps. Apple 运动记录中不同的运动类型在这里,想要自定义其他可以自取) 而在GitHubPoster中支持的3种类型中,move、exercise、stand,对应的apple health数据都是单种类型,所以,需要将type修改为数组类型才能实现。 在 github_poster/loader/apple_health_loader.py 中修改其中的 RecordMetadata 的支持和使用。 首先需要将type字段修改为一个列表或者集合。在定义RecordMetadata时,将type字段类型改为List或Set: from typing import List RecordMetadata = namedtuple("RecordMetadata", ["types", "unit", "track_color", "func"]) 然后在定义HEALTH_RECORD_TYPES时,你就可以为每个键提供多个类型了: HEALTH_RECORD_TYPES = { "stand": RecordMetadata( ["HKCategoryTypeIdentifierAppleStandHour", "AnotherType", ...], "hours", "#62F90B", lambda x: 1 if "HKCategoryValueAppleStandHourStood" else 0, ), ... } 接着,你需要在AppleHealthLoader类的backfill方法中,修改判断记录类型的逻辑。将原来的等于比较改为检查类型是否在types列表中: def backfill(self): from_export = defaultdict(int) in_target_section = False for _, elem in ET.iterparse(self.apple_health_export_file, events=["end"]): if elem.tag != "Record": continue if elem.attrib["type"] in self.record_metadata.types: ... 这样,每个RecordMetadata就可以包含多个type了,可以在HEALTH_RECORD_TYPES中为每个键定义任意数量的类型。 再次执行,即可生成想要的数据。 到这里,如果你不需要每日更新,只发个朋友圈啥的,就够了。 三. 设置 GitHub Actions 进行数据的增量更新 要实现数据的每日更新,我们需要利用快捷指令和 GitHub Actions。具体的步骤如下: 生成一个AccessToken 我们首先需要生成一个AccessToken,用来作为快捷指令post请求的凭证: 配置GitHub仓库的权限 接下来,我们需要打开GitHub action 的仓库读写权限,用来作为图片生成完成以及数据增量的仓库保存: 编写 GitHub Actions workflow 编写 GitHub Actions 工作流程 我们在.github/workflow目录下创建一个yml后缀的文件,比如叫sync_exercise.yml,下面是我的文件的内容: name: Run Poster Generate on: workflow_dispatch: inputs: time: description: 'time list' required: false value: description: 'value list' required: false pull_request: env: TYPE: "apple_health" ME: whrsss # change env here GITHUB_NAME: whrsss GITHUB_EMAIL: xxx@qq.com jobs: sync: name: Sync runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: 3.8 - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt if: steps.pip-cache.outputs.cache-hit != 'true' - name: Run sync apple_health script if: contains(env.TYPE, 'apple_health') run: | python3 -m github_poster apple_health --apple_health_mode incremental --apple_health_date ${{ github.event.inputs.time }} --apple_health_value ${{ github.event.inputs.value }} --year 2020-2023 --apple_health_record_type move --me "whrsss" - name: Push new poster run: | git config --local user.email "${{ env.GITHUB_EMAIL }}" git config --local user.name "${{ env.GITHUB_NAME }}" git add . git commit -m 'update new poster' || echo "nothing to commit" git push || echo "nothing to push" 如果想要了解github action语法的同学可以google一下 上面的yaml的步骤分别是: 定义了一个名为 “Run Poster Generate” 的 Github Actions 指定了两种触发方式,一种是手动触发的 workflow_dispatch,另一种是当有新的 pull_request 时触发 指定了一些环境变量,包括 poster 生成所需的参数 定义了一个名为 “sync” 的 job,指定在 ubuntu-latest 操作系统上运行 包括了三个步骤: checkout:将代码仓库从 Github 上下载到本地 set up python:安装 python 3.8 版本 install dependencies:安装项目所需的所有依赖包 如果缓存中没有依赖,才会继续执行后续步骤 如果环境变量中的 TYPE 是 apple_health,则执行同步 Apple Health 数据的脚本 完成同步后,将生成的新 poster 推送到 Github 仓库中去。 获取workflow id 获取workflow id 我们可以通过以下命令获取workflow id: curl https://api.github.com/repos/{用户名}/{仓库名}/actions/workflows -H "Authorization: token d8xxxxxxxxxx" # change to your config 上面的token后的token就是第一步获取的token。 创建快捷指令 你可以下载大佬已经创建好的快捷指令,然后修改以下部分即可: 设置自动化 最后,我们需要设置快捷指令的自动触发条件。我选择的是每天结束时进行同步: 结束语 希望这篇文章对你有所帮助。如果你试过这个项目,我很乐意听到你的反馈和经验。如果你有任何问题或建议,也欢迎在下面的评论中提出。

2025/12/30
articleCard.readMore

【Gorm】Save 方法更新踩坑记录

在我最近使用Gorm进行字段更新的过程中,我遇到了一个问题。当我尝试更新status字段时,即使该字段的值没有发生变化,Gorm还是提示我“Duplicate entry ‘xxxx’ for key ‘PRIMARY’”。 首先,让我们看看Gorm的官方文档对Save方法的描述: Save方法会保存所有的字段,即使字段是零值。 db.First(&user) user.Name = "jinzhu 2" user.Age = 100 db.Save(&user) // UPDATE users SET name='jinzhu 2', age=100, birthday='2016-01-01', updated_at = '2013-11-17 21:34:10' WHERE id=111; ``` `Save`方法是一个复合函数。如果保存的数据不包含主键,它将执行`Create`。反之,如果保存的数据包含主键,它将执行`Update`(带有所有字段)。 ```go db.Save(&User{Name: "jinzhu", Age: 100}) // INSERT INTO `users` (`name`,`age`,`birthday`,`update_at`) VALUES ("jinzhu",100,"0000-00-00 00:00:00","0000-00-00 00:00:00") db.Save(&User{ID: 1, Name: "jinzhu", Age: 100}) // UPDATE `users` SET `name`="jinzhu",`age`=100,`birthday`="0000-00-00 00:00:00",`update_at`="0000-00-00 00:00:00" WHERE `id` = 1 根据这个描述,我预期的行为应该是更新操作,因为我提供了ID字段。然而,实际发生的却是插入操作。这让我感到困惑。 为了理解这个问题,我深入阅读了Gorm的源码: // Save updates value in database. If value doesn't contain a matching primary key, value is inserted.func (db *DB) Save(value interface{}) (tx *DB) { tx = db.getInstance() tx.Statement.Dest = value reflectValue := reflect.Indirect(reflect.ValueOf(value)) for reflectValue.Kind() == reflect.Ptr || reflectValue.Kind() == reflect.Interface { reflectValue = reflect.Indirect(reflectValue) } switch reflectValue.Kind() { case reflect.Slice, reflect.Array: if _, ok := tx.Statement.Clauses["ON CONFLICT"]; !ok { tx = tx.Clauses(clause.OnConflict{UpdateAll: true}) } tx = tx.callbacks.Create().Execute(tx.Set("gorm:update_track_time", true)) case reflect.Struct: if err := tx.Statement.Parse(value); err == nil && tx.Statement.Schema != nil { for _, pf := range tx.Statement.Schema.PrimaryFields { if _, isZero := pf.ValueOf(tx.Statement.Context, reflectValue); isZero { return tx.callbacks.Create().Execute(tx) } } } fallthrough default: selectedUpdate := len(tx.Statement.Selects) != 0 // when updating, use all fields including those zero-value fields if !selectedUpdate { tx.Statement.Selects = append(tx.Statement.Selects, "*") } updateTx := tx.callbacks.Update().Execute(tx.Session(&Session{Initialized: true})) if updateTx.Error == nil && updateTx.RowsAffected == 0 && !updateTx.DryRun && !selectedUpdate { return tx.Create(value) } return updateTx } return } 源码的主要逻辑如下: 获取数据库实例并准备执行SQL语句。value是要操作的数据。 利用反射机制确定value的类型。 如果value是Slice或Array,并且没有定义冲突解决策略(”ON CONFLICT”),那么设置更新所有冲突字段的冲突解决策略,并执行插入操作。 如果value是一个Struct,那么会尝试解析这个结构体,然后遍历它的主键字段。如果主键字段是零值,则执行插入操作。 对于除Slice、Array、Struct以外的类型,将尝试执行更新操作。如果在更新操作后,没有任何行受到影响,并且没有选择特定的字段进行更新,则执行插入操作。 从这个函数我们可以看出,当传入的value对应的数据库记录不存在时(根据主键判断),Gorm会尝试创建一个新的记录。如果更新操作不影响任何行,Gorm同样会尝试创建一个新的记录。 这个行为与我们通常理解的“upsert”(update + insert)逻辑略有不同。在这种情况下,即使更新的数据与数据库中的数据完全相同,Gorm还是会尝试进行插入操作。这就是为什么我会看到Duplicate entry 'xxxx' for key 'PRIMARY'的错误,因为这就是主键冲突的错误提示。 对于Gorm的这种行为我感到困惑,同时我也对官方文档的描述感到失望,因为它并没有提供这部分的信息。 如何解决这个问题呢? 我们可以自己实现一个Save方法,利用GORM的Create方法和冲突解决策略: // Update all columns to new value on conflict except primary keys and those columns having default values from sql func db.Clauses(clause.OnConflict{ UpdateAll: true, }).Create(&users) // INSERT INTO "users" *** ON CONFLICT ("id") DO UPDATE SET "name"="excluded"."name", "age"="excluded"."age", ...; // INSERT INTO `users` *** ON DUPLICATE KEY UPDATE `name`=VALUES(name),`age`=VALUES(age), ...; MySQL 在Gorm的Create方法的文档中,我们可以看到这种用法。如果提供了ID,它会更新其他所有的字段。如果没有提供ID,它会插入新的记录。 经热心老哥提醒,当使用 INSERT ... ON DUPLICATE KEY UPDATE 语句时,如果插入的行因为唯一索引或主键冲突而失败,MySQL 会执行更新操作。这种情况下,会涉及到锁的问题,如果多个事务同时试图对同一行进行 INSERT ... ON DUPLICATE KEY UPDATE 操作,可能会引起死锁,尤其是在复杂的查询和多表操作中。死锁发生时,MySQL 会自动检测并回滚其中一个事务来解决死锁。频繁使用这种语句在高并发场景下可能会对性能造成影响,因为每次冲突都需要进行额外的更新操作,并且涉及到锁的管理。 所以,如果你的业务涉及复杂和并发的业务场景,可以尝试手动在应用层进行检测冲突,避免数据库的冲突和锁的竞争。

2025/12/31
articleCard.readMore

一次线上异常的追踪与处理

一次线上异常的追踪与处理 5月31日晚,我们接到游戏玩家反馈,经常出现请求超时的提示。在我亲自登录游戏验证后,也出现了相同的错误,但游戏仍然可以正常运行,数据也没有任何问题。 经过客户端的错误检查,我们发现请求出现了408 Request Timeout的错误。该响应状态码意味着服务器打算关闭没有在使用的连接,即使客户端没有发送任何请求,一些服务器仍会在空闲连接上发送此信息。服务器决定关闭连接,而不是继续等待。 1. 日志检查 接下来,我查看了服务器的日志,发现后台的两个服务的日志都在正常运行,没有异常提示。当我进行pod查看时,发现有两个pod显示容器没有日志,这两个pod已经挂掉。 为什么这两个pod会宕机呢?我开始回溯近1小时的日志,发现在晚上10点左右,出现了JDBC连接异常。 ### Error querying database. Cause: org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection; nested exception is java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 31363ms. 通过Google查询,我了解到这种错误是由于Spring Boot的默认连接池HikariPool在连接排队阻塞,无法获取连接,最后导致超时。在数据库错误之后的一段时间内,出现了Java内存异常。 {"@timestamp":"2023-05-31 22:18:24.382","level":"ERROR","source":{"className":"org.apache.juli.logging.DirectJDKLog","methodName":"log","line":175},"message":"Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed; nested exception is java.lang.OutOfMemoryError: Java heap space] with root cause","error.type":"java.lang.OutOfMemoryError","error.message":"Java heap space","error.stack_trace":"java.lang.OutOfMemoryError: Java heap space\n"} 由于我们没有设置连接池上限(默认最大为10),当获取连接阻塞后,请求排队,最终导致内存溢出。最后,由于内存溢出,pod触发java.io.IOException: Broken pipe错误,即管道断开,服务宕机。 {"@timestamp":"2023-05-31 22:18:24.393","level":"WARN","source":{"className":"org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver","methodName":"logException","line":199},"message":"Resolved [org.springframework.web.util.NestedServletException: Handler dispatch failed; nested exception is java.lang.OutOfMemoryError: Java heap space]"} 2. 增加连接池大小 Hikari是Spring Boot自带的连接池,默认最大只有10个。因此,我的第一步解决方案是增加这个服务的连接池大小。在服务的yaml数据库连接配置中增加了一些参数。  datasource: url: 'jdbc:mysql://rm-2xxxxxx' username: 'xx' password: 'xxx' # 下面这些???? type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver hikari: #连接池名 pool-name: DateHikariCP #最小空闲连接数 minimum-idle: 10 # 空闲连接存活最大时间,默认600000(10分钟) idle-timeout: 180000 # 连接池最大连接数,默认是10 maximum-pool-size: 100 # 此属性控制从池返回的连接的默认自动提交行为,默认值:true auto-commit: true # 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认1800000即30分钟 max-lifetime: 1800000 # 数据库连接超时时间,默认30秒,即30000 connection-timeout: 30000 connection-test-query: SELECT 1 3. 性能优化 尽管从表面上看,问题是由于连接池数量太少,导致连接请求阻塞。但深层的原因是服务对数据库的请求处理过慢,最后导致阻塞。如果请求数量继续增加,即使扩大了连接池,同样会阻塞连接。这就像滴滴打车,碰到下雨天儿,队一旦开始排,后面就不知道要排多久了。 这个数据库存储了大量的数据,其中聊天记录的存储主要占用了性能。我们在处理聊天记录时做了分表处理。但由于数据量过大,单表依然有近两千万的数据。这张大表有一个联合索引,索引数据量较大。每次更新都需要维护索引空间,每次单个玩家数据量到达限值,就会进行局部清理。 这里的数据插入动作可能消耗时间较长。由于对消息的可靠性要求不高,我们可以使用异步进行,这样在等待插入的过程中可以省去大量的请求连接占用资源。 我们优化了消息保存数量。以前,每个玩家保存900条消息,但一般只查询最近的300条。现在,每500条进行一次清理,清理至300条,以节省空间。 即使数据量节省了很多,但由于业务价值相对成本比例因素,与业务部门进行沟通,将业务的容忍度定为定期3个月。 4. 空间优化 查询表空间占用: -- 查询库中每个表的空间占用,分项列出 select table_schema as '数据库', table_name as '表名', table_rows as '记录数', truncate(data_length/1024/1024, 2) as '数据容量(MB)', truncate(index_length/1024/1024, 2) as '索引容量(MB)' from information_schema.tables where table_schema='表名' order by data_length desc, index_length desc; 对经常有删除操作的数据表进行碎片清理: alter table 表名 engine=innodb; 经过清理,可以看到表空间占用缩小了40%左右。加上之前的业务修改,数据量又有了明显的缩减,使得数据库到了MySQL的舒适区,单表在500万左右。 5. 相关经验 以前我们有一个Go服务有非常大的IO,偶尔会出现崩溃,日志也是提示:“write tcp IP: xxx-> IP:xxx write: broken pipe”。开始以为是服务器在上传到OSS的过程中出现的连接异常,后来和阿里确认了并非OSS的断开错误。经过多次排查,最后发现在上传文件前,对内容进行了json序列化,这个过程非常费性能。当请求过多时,就发生了阻塞,阻塞过多,内存占用过大,溢出,服务就会拒绝服务。此时,连接的管道就会强行断开。 在很多业务场景中,都会出现这种情况:当计算资源不足时,请求就会阻塞堆积,最后最先崩溃的总是内存。

2025/12/30
articleCard.readMore

Go 单元测试高效实践

敏捷开发中有一个广为人知的开发方法就是 XP(极限编程),XP 提倡测试先行,为了将以后出现 bug 的几率降到最低,这一点与近些年流行的 TDD(测试驱动开发)有异曲同工之处。 在最开始做编程时,我总是忽略单元测试在代码中的作用,觉得编写单元测试的功夫都赶上甚至超越业务程序了。到后来,业务量越来越复杂,慢慢地,浮现一个问题,就是系统对于测试人员是一个黑盒,简单的测试无法保证系统所设计的东西都可以测试到⬇️ 举两个最简单的例子: 系统设计的数据打点,是无法从功能业务上测试出来的,而对于测试人员,可能由于版本差异,用例未覆盖。 如果一个表中有两个字段,新用户过来更新一个字段之后,测另一个字段的功能时就不再以一个新用户的身份操作了。 在这样的情况下,如果开发人员没有对系统做完全的检查,就很可能出现问题。 就以上情况看,需要从开发人员的维度,对功能做一个“预期”测试,一个功能走过,应该输入什么,输出什么,哪些数据变动了,变动是否符合预期等等。 最近,公司业务基本都转入了 Go 做开发,在 Go 的整个业务处理上也日渐完善,而 Go 的单元测试用起来也十分顺手,所以做个小的总结。 一. Mock DB 在单元测试中,很重要的一项就是数据库的 Mock,数据库要在每次单元测试时作为一个干净的初始状态,并且每次运行速度不能太慢。 1. Mysql 的 Mock 这里使用到的是 github.com/dolthub/go-mysql-server 借鉴了这位大哥的方法 如何针对 MySQL 进行 Fake 测试 ###### DB 的初始化 在 db 目录下 type Config struct { DSN string // write data source name. MaxOpenConn int // open pool MaxIdleConn int // idle pool ConnMaxLifeTime int } var DB *gorm.DB // InitDbConfig 初始化Db func InitDbConfig(c *conf.Data) { log.Info("Initializing Mysql") var err error dsn := c.Database.Dsn maxIdleConns := c.Database.MaxIdleConn maxOpenConns := c.Database.MaxOpenConn connMaxLifetime := c.Database.ConnMaxLifeTime if DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ QueryFields: true, NamingStrategy: schema.NamingStrategy{ //TablePrefix: "", // 表名前缀 SingularTable: true, // 使用单数表名 }, }); err != nil { panic(fmt.Errorf("初始化数据库失败: %s \n", err)) } sqlDB, err := DB.DB() if sqlDB != nil { sqlDB.SetMaxIdleConns(int(maxIdleConns)) // 空闲连接数 sqlDB.SetMaxOpenConns(int(maxOpenConns)) // 最大连接数 sqlDB.SetConnMaxLifetime(time.Second * time.Duration(connMaxLifetime)) // 单位:秒 } log.Info("Mysql: initialization completed") } ###### fake-mysql 的初始化和注入 在 fake_mysql 目录下 var ( dbName = "mydb" tableName = "mytable" address = "localhost" port = 3380 ) func InitFakeDb() { go func() { Start() }() db.InitDbConfig(&conf.Data{ Database: &conf.Data_Database{ Dsn: "no_user:@tcp(localhost:3380)/mydb?timeout=2s&readTimeout=5s&writeTimeout=5s&parseTime=true&loc=Local&charset=utf8,utf8mb4", ShowLog: true, MaxIdleConn: 10, MaxOpenConn: 60, ConnMaxLifeTime: 4000, }, }) migrateTable() } func Start() { engine := sqle.NewDefault( memory.NewMemoryDBProvider( createTestDatabase(), information_schema.NewInformationSchemaDatabase(), )) config := server.Config{ Protocol: "tcp", Address: fmt.Sprintf("%s:%d", address, port), } s, err := server.NewDefaultServer(config, engine) if err != nil { panic(err) } if err = s.Start(); err != nil { panic(err) } } func createTestDatabase() *memory.Database { db := memory.NewDatabase(dbName) db.EnablePrimaryKeyIndexes() return db } func migrateTable() { // 生成一个user表到fake mysql中 err := db.DB.AutoMigrate(&model.User{}) if err != nil { panic(err) } } 在单元测试开始,调用 InitFakeDb() 即可 func setup() { fake_mysql.InitFakeDb() } 2. Redis 的 Mock 这里用到的是 miniredis , 与之配套的Redis Client 是 go-redis/redis/v8 ,在这里调用 InitTestRedis() 注入即可 // RedisClient redis 客户端 var RedisClient *redis.Client // ErrRedisNotFound not exist in redisconst ErrRedisNotFound = redis.Nil // Config redis config type Config struct { Addr string Password string DB int MinIdleConn int DialTimeout time.Duration ReadTimeout time.Duration WriteTimeout time.Duration PoolSize int PoolTimeout time.Duration // tracing switch EnableTrace bool } // Init 实例化一个redis client func Init(c *conf.Data) *redis.Client { RedisClient = redis.NewClient(&redis.Options{ Addr: c.Redis.Addr, Password: c.Redis.Password, DB: int(c.Redis.DB), MinIdleConns: int(c.Redis.MinIdleConn), DialTimeout: c.Redis.DialTimeout.AsDuration(), ReadTimeout: c.Redis.ReadTimeout.AsDuration(), WriteTimeout: c.Redis.WriteTimeout.AsDuration(), PoolSize: int(c.Redis.PoolSize), PoolTimeout: c.Redis.PoolTimeout.AsDuration(), }) _, err := RedisClient.Ping(context.Background()).Result() if err != nil { panic(err) } // hook tracing (using open telemetry) if c.Redis.IsTrace { RedisClient.AddHook(redisotel.NewTracingHook()) } return RedisClient } // InitTestRedis 实例化一个可以用于单元测试的redis func InitTestRedis() { mr, err := miniredis.Run() if err != nil { panic(err) } // 打开下面命令可以测试链接关闭的情况 // defer mr.Close() RedisClient = redis.NewClient(&redis.Options{ Addr: mr.Addr(), }) fmt.Println("mini redis addr:", mr.Addr()) } 二. 单元测试 经过对比,我选择了 goconvey 这个单元测试框架 它比原生的go testing 好用很多。goconvey还提供了很多好用的功能: 多层级嵌套单测 丰富的断言 清晰的单测结果 支持原生go test 使用 go get github.com/smartystreets/goconvey func TestLoverUsecase_DailyVisit(t *testing.T) { Convey("Test TestLoverUsecase_DailyVisit", t, func() { // clean uc := NewLoverUsecase(log.DefaultLogger, &UsecaseManager{}) Convey("ok", func() { // execute res1, err1 := uc.DailyVisit("user1", 3) So(err1, ShouldBeNil) So(res1, ShouldNotBeNil) // 第 n (>=2)次拜访,不应该有奖励,也不应该报错 res2, err2 := uc.DailyVisit("user1", 3) So(err2, ShouldBeNil) So(res2, ShouldBeNil) }) }) } 可以看到,函数签名和 go 原生的 test 是一致的 测试中嵌套了两层 Convey,外层new了内层Convey所需的参数 内层调用了函数,对返回值进行了断言 这里的断言也可以像这样对返回值进行比较 So(x, ShouldEqual, 2) 或者判断长度等等 So(len(resMap),ShouldEqual, 2) Convey的嵌套也可以灵活多层,可以像一棵多叉树一样扩展,足够满足业务模拟。 三. TestMain 为所有的 case 加上一个 TestMain 作为统一入口 import ( "os" "testing" . "github.com/smartystreets/goconvey/convey" ) func TestMain(m *testing.M) { setup() code := m.Run() teardown() os.Exit(code) } // 初始化fake db func setup() { fake_mysql.InitFakeDb() redis.InitTestRedis() }

2025/12/30
articleCard.readMore

一个有趣且实用的开源项目,将网页打包成应用

最近在GitHub上面发现了一个很有趣的开源应用,可以将网页打包成应用: 这里是它的使用文档 底层使用了rust进行开发,支持 Mac、Windows 和 Linux。使用命令将网站直接打包成应用。 这里我以最近部署的一个私人 ChatGPT Next为例, 我的地址为 https://gpt.whrss.com 需要本地先有一个node环境,能使用npm命令。 然后npm全局安装这个工具 npm install -g pake-cli 安装完之后,就可以使用 pake url [OPTIONS]... 来生成桌面应用了,参数有很多,我先用了这些: 这是我的命令: pake https://gpt.whrss.com --name GPT --icon /Users/wu/Downloads/gpt.icns --height 700 --width 900 这里我指定了网站的url : https://gpt.whrss.com 指定了生成的应用包的名字:GPT 指定了应用的 icon 指定了应用打开初始化的高度和宽度 mac icon生成和获取的地方 : https://macosicons.com/#/ 然后我进行了运行,因为我的icon指定的目录是Downloads下面,所以它直接生成在了下面,默认是用户目录。 如上图,生成的包体非常的小和轻量,只有不到3M。 = 更新 = # 查看npm安装的插件的版本 npm -g list /usr/local/lib ├── xxx ├── npm@9.3.1 ├── pake-cli@2.0.0-alpha7 ├── xxxxxxx └── xxxxxxx # 更新指定插件 npm -g update pake-cli # 再次查看 npm -g list 关于这个软件的应用,我想到的最多就是那些常用的,但没有pc客户端的应用,比如 Twitter、WeRead、Spotify 等等,我也是接触这个项目后第一时间就使用上了,体验很不错,比之前方便了不少。

2025/12/30
articleCard.readMore

GitHub Action 自动化部署简单尝试

之前看到很多大佬的 Blog 是部署在 Github 上面的,但因为自己目前的博客是带后端的,所以就没有考虑。很久之前看到 @yihong 的心跳和跑步,感觉挺不错的,但因为自己没有跑步的习惯,就感觉不是很感冒 ???? 直到最近在听零机一动的时候,又听到了 yihong 的跑步, 我突然想到,我应该也可以把我的游泳 骑车 有氧也像 @yihong 的跑步数据一样上传过来。那么第一件事,我需要了解一下 GitHub Action 的机制,小小地尝试一下。 首先,先看下 github action 的基本功能,我是通过阮一峰老师的文章了解的。 GitHub Actions 是一种自动化工具,2019 年开始测试,同年 11 月上线。可以在 GitHub 上构建、测试和部署软件项目。它允许 Github 用户通过预定义或自定义的操作序列来自动化整个软件开发生命周期中的流程(过程很像 Dockerfile 或 Jenkinsfile 的构建),并且完全与 Github 集成。 使用 GitHub Actions,你可以创建一个由事件触发或定期运行的工作流(workflow),其中包含一个或多个步骤(steps)。每个步骤都是一个特定任务的命令,例如编译代码、运行测试、打包发布版本等。你可以选择将这些步骤放在同一个工作流内,也可以将它们拆分为不同的工作流文件。 此外,Github Action 还提供了各种操作 (actions),可以让您轻松地执行常见的任务,如 Shell 脚本、推送 Docker 映像、调用 API 等等。 也有一些和捷径市场一样的分享社区,把自己编写的 action yml 分享给他人使用。 GitHub Actions 使得整个开发过程变得更加高效方便,能够提升团队的交付速度。同时,近年来越来越多的开源社区也开始尝试在其上面构建 CI/CD 流水线。 这里可能没有接触过的朋友可能会想,我是否可以在上面部署应用呢? 这里的 Action 的作用类似于 Jenkins,可以帮你打包编译,如果你是一个静态项目,那么作为静态仓库的 Github 仓库是可以直接部署的; 但是如果是动态的后端服务,需要 Cpu 的,就无法进行了。 第一步,创建 GitHub Token 头像 -> settings -> 左侧 Developer settings -> 左侧 Personal access tokens -> Tokens(classic) -> Generate new token (classic) 赋予 workflow 的权限 第二步: 在目标仓库中创建  .github/workflows  目录 目录中创建一个 yml 文件 workflow-name.yml 按照 GitHub 文档进行 yml 编写,我这里先了解机制,所以直接 fork @yihong 的项目, 第三步 Action 设置 在项目的 settings 中找到 actions 下的 general,向下划动 找到工作流的读写权限,进行勾选。这样我们在触发 action 后,项目的 push 就有权限进行了。 第四步,触发这个 Action 在手机上部署这个捷径 Token 填写第一步获取到的Token GitHub_Name 填写Github用户名 Action_ID 填写yml名字(workflow-name.yml就全部填上去) 修改仓库名字和分支(图中的whrsss是我的仓库名字,master是我的分支名) 修改完成后,点击快捷指令的运行。然后打开Github 仓库,点开 Action 栏,项目正在构建。点开可以查看构建日志。

2025/12/31
articleCard.readMore

从零开始搭建家庭软路由系统(安装OpenWrt,并做为旁路由接入家庭网络)

之前刷推看到了不少人发软路由,最近又看到a姐发了一句:全推入手软路由了 开始我还觉得软路由对我的作用应该不大吧,随着从众心理的影响,我觉得我应该试试。 刚好我还有从笔记本上拆下的内存和固态,岂不是严丝合缝? 然后我就下单了一个 外观差不多是这个样子: 内部结构大概是这个样子: 拥有5个网口、两个 USB 2.0 和两个 USB 3.0 口、HDMI、DP、存储卡位以及两个笔记本内存条槽位、一根 m.2 插槽和一个 SATA 接口,可以说五脏俱全。 我买的是只带电源的,内存条和磁盘需要自己加。 一、第一步 组装机器 打开机器背部的固定螺丝以及后盖,将内存条插入相应槽位 接入 SATA 固态硬盘或 M.2 硬盘,不要装上后盖,以防有问题还需要再拆开。 连接电源,此时路由器将自动开机 开机后如果听到一声响,表示内存识别功能正常。如果听到三声响连续作响就表示出现了问题。我使用的两个内存条,分别在两个槽位上尝试,只有一根(金士顿的骇客神条2400)能在其中一个槽位上被识别。如果两个槽位都无法识别,就只能买同店的内存条了。 接下来就是准备安装系统。 二、 制作启动盘 我选择的系统是 OpenWRT/LEDE 在国内的家庭软路由中有着非常高的占有率,拥有海量的软件,和非常强大的生态。同时,OpenWRT 的教程也很丰富详实。 这里我使用的是 KoolShare 固件,内置了非常强大的插件市场。 1. 下载efi 找到最新版本进行下载。 2. 写盘 下载完成后,使用balenaetcher写盘工具进行写盘, 如果没有安装balenaEtcher,首先需要下载并安装。 插入你的U盘,并打开balenaEtcher。 点击“选择镜像”按钮,选择刚才下载的.gz文件。 点击“选择驱动器”按钮,选择你插入的U盘。 点击“写入”按钮,balenaEtcher将开始写入镜像文件到你的U盘。 等待写入过程完成,并在完成后安全地弹出。 三、 安装和设置 1. 安装 将写好的盘插入软路由,接一个显示器和一个键盘给软路由,接上电源,开机。按f11(不同机器不同快捷键)进入快速启动。一切自动执行到终端提示完成,回车,提示出OpenWrt图标。 输入quickstart,1 设置Lan口IP,2 安装OpenWrt,3 重置 选择2安装操作系统。这时候会提示安装位置,如果识别磁盘没有问题,就会让你选择你内部的固态或者sata硬盘。如果提示没有找到硬盘,就是硬盘有问题或者接口接错了(我在使用sata协议的m.2接口固态硬盘时,提示了这个问题,固态硬盘需要选择nvme接口协议的) 几秒后会提示你拔掉U盘,这时候就会自动重启进入系统。 同样的 输入quickstart 选择1 设置lan口IP。可以设置为 192.168.2.6, 子网掩码设置为255.255.255.0(等下会说明用途) 接一根网线从软路由 lan 口到 PC 上,pc打开浏览器,访问 192.168.2.6 ,这时候会进入软路由后台,用户名root 默认密码 password 到这里,安装基本完成,openWrt可以进入后台访问。 2. 设置旁路由 旁路由就是将软路由接入在普通硬路由下,做数据处理。 在硬路由器控制台上禁用 DHCP 功能,然后记录下其 LAN 口 IP 地址(例如,我的是 192.168.2.5) 在软路由面板的“网络”下,选择“接口”并设置 LAN 口,将 LAN 口 IP 设置为硬路由器 IP 同一网段(也就是192.168.2.6),将网关地址和 DNS 解析地址都设置为硬路由器 LAN 口地址 192.168.2.5 点击保存并应用。 拔下PC网线,将软路由Lan口与硬路由Lan口相连。 电脑连接原先的硬路由wifi网络, 浏览器输入192.168.2.6,正确访问到软路由后台。再登录软路由后台系统,检查网络状况,已连通。

2025/12/31
articleCard.readMore

Clash 设置国内国外自动分流访问

Clash 是一款开源的网络代理工具,可以帮助用户实现对网络流量的控制和管理。我使用了很久,但苦于每次访问国内外网络需要手动开关代理,于是我就问了下GPT, 还真就解决了。 如果你也需要设置 Clash 区分国内和国外流量,可以按照以下步骤进行操作: 1. 打开配置文件(windows在右下角): 使用 Visual Studio Code 打开 config.yaml 内容是这样的: #---------------------------------------------------# ## 配置文件需要放置在 $HOME/.config/clash/*.yaml ## 这份文件是clashX的基础配置文件,请尽量新建配置文件进行修改。 ## !!!只有这份文件的端口设置会随ClashX启动生效 ## 如果您不知道如何操作,请参阅 官方Github文档 https://github.com/Dreamacro/clash/blob/dev/README.md #---------------------------------------------------# # (HTTP and SOCKS5 in one port) mixed-port: 7890 # RESTful API for clash external-controller: 127.0.0.1:9090 allow-lan: false mode: rule log-level: warning proxies: proxy-groups: rules: - DOMAIN-SUFFIX,google.com,DIRECT - DOMAIN-KEYWORD,google,DIRECT - DOMAIN,google.com,DIRECT - DOMAIN-SUFFIX,ad.com,REJECT - GEOIP,CN,DIRECT - MATCH,DIRECT 2. 在 Clash 配置文件中添加以下规则: # 添加下面的, 跟在后面 payload: - DOMAIN-SUFFIX,cn,Direct - DOMAIN-KEYWORD,geosite,Proxy - IP-CIDR,10.0.0.0/8,Direct - IP-CIDR,172.16.0.0/12,Direct - IP-CIDR,192.168.0.0/16,Direct - IP-CIDR,127.0.0.0/8,Direct - IP-CIDR,224.0.0.0/4,Direct - IP-CIDR,240.0.0.0/4,Direct - MATCH,Final 3. 每个配置段的作用 然后我问了一下 gpt 这些配置的作用: DOMAIN-SUFFIX,cn,Direct 表示所有以 “.cn” 结尾的域名都直接连接(不通过代理)。 DOMAIN-KEYWORD,geosite,Proxy 表示包含关键词 “geosite” 的域名(比如 “www.geosite.com”)都通过代理连接。 IP-CIDR,10.0.0.0/8,Direct 表示 IP 地址在 10.0.0.0 - 10.255.255.255 范围内的都直接连接。 IP-CIDR,172.16.0.0/12,Direct 表示 IP 地址在 172.16.0.0 - 172.31.255.255 范围内的都直接连接。 IP-CIDR,192.168.0.0/16,Direct 表示 IP 地址在 192.168.0.0 - 192.168.255.255 范围内的都直接连接。 IP-CIDR,127.0.0.0/8,Direct 表示本地回环地址都直接连接(通常是 127.0.0.1)。 IP-CIDR,224.0.0.0/4,Direct 和 IP-CIDR,240.0.0.0/4,Direct 表示多播地址都直接连接。 MATCH,Final 表示匹配到这条规则后,后面的规则不会再被匹配,可以理解为中断匹配流程,即该规则为终结规则(Final Rule)。

2025/12/31
articleCard.readMore

将本地服务通过 SSH 代理给外部访问

如何使用 ssh 将本地服务代理给外部访问并保持 SSH 会话的连接性 1. 外部服务器 nginx 配置 server { listen localhost:80; server_name _; root /usr/share/nginx/html; # 重要:将请求转发到本地服务 location / { root /usr/share/nginx/html; index index.html index.htm; proxy_pass http://127.0.0.1:10412; proxy_set_header Host $host:80; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Via "nginx"; } } 2. 权限认证 在外网服务器上运行以下命令以生成公钥:ssh-keygen -o 将公钥复制到内网服务器上,并添加到 ~/.ssh/authorized_keys 3. 目标内网服务器 ssh 连接 在本地启动服务并将其监听在端口 8088 将外网访问的端口 10412 转发到本地端口 8088 nohup ssh -N -v -R 10412:127.0.0.1:8088 root@{外部服务器的外网IP} 2>&1 & 4. 保持会话 在保持 SSH 会话中,加入以下命令来保持连接 ServerAliveInterval 是指定服务器发送保持连接的数据包的时间(单位:秒) ServerAliveCountMax 是指定尝试与服务器保持连接的最大次数 nohup ssh -N -v -o ServerAliveInterval=10 -o ServerAliveCountMax=1000 -R 10412:127.0.0.1:8088 root@{外部服务器的外网IP} 2>&1 &

2025/12/30
articleCard.readMore

惊喜又焦虑,AI 技术的发展

最近,我接触了很多关于AI技术的东西。例如ChatGPT、NewBing、ChatGPT更快的API、stable diffusion、AI语音识别等等。这些技术让我惊喜,也让我感到焦虑。 AI应用日新月异,我的想象力甚至赶不上技术的发展。它们不仅代表了技术的进步,也牵动着就业市场的变革。 我又陷入了意义的怪圈。作为一个软件开发者,我的学习和工作是否还有意义和价值?我的未来会是怎样?如果AI技术可以替代我的工作 ,那么我还能做什么? 如果AI技术只是用于娱乐和教育等领域 ,我或许会感到比较放心,因为我可以成为一个普通的使用者,享受这些技术带来的便利和乐趣,而不必担心它们对我的生活产生过大的影响。 但事实是,AI技术已经渗透到包括软件开发在内各个行业和领域 。AI可以提高软件开发人员的效率和质量 ,也可以拓展软件开发人员的知识和技能 。但同时 ,它们也给软件开发人员带来了更大的竞争压力和更高的要求 。 当然 ,我并不否定AI技术 。它们也有很多优点和潜力 。但是 ,我不能忽视它们带来 的改变和挑战。不知道哪一天,我真的会被AI取代。 后来,我在medium上面看到一篇文章:coding-wont-exist-in-5-years-this-is-why 让我对未来的看法变得理性客观了很多。 AI 驱动的工具将取代人类“编码员”。这些工具将能够比人类更快、更高效地编写和调试代码,而且 成本更低。 如果你所能做的就是写代码,那么你就不是“Engineer”,而是“coder”,你一定会被AI取代。 “幸存下来的不是最强壮的物种,也不是最聪明的物种——它**是最能适应变化的物种。” *- 查尔斯·达尔文 有了chatGPT这样的工具写代码,只会写代码的人是没有用的。正如工匠能够适应和学习新技能以保持竞争力一样,编码人员将能够通过更多地了解如何使用这些工具来发挥自己的优势来做到这一点。 只有渴望成为房间里最聪明的人,才会担心周围的一切都变得比他聪明。 适应新的方式是痛苦的,但只有活着的人才能感受到这种痛苦——死者甚至感受不到火葬的火光。能利用它的人——将前进,而那些不适应的人将不复存在——it is simple as that. They Are Coming for You !

2025/12/31
articleCard.readMore

体验 New Bing:一个比 ChatGPT 更强大、更幽默、更有用的搜索引擎聊天机器人

最近在推特上看到很多人在谈论New Bing申请的事情,我也收到了试用通过的邮件。于是我马上打开Edge浏览器体验了一下。 注意:如果你也收到了邀请,但是无法进入聊天页面,请参考这篇文章:https://zhuanlan.zhihu.com/p/605970396 New Bing是一个基于人工智能的搜索引擎聊天机器人。它可以回答各种问题,并且提供相关的链接和信息。它还可以跟你聊天,并且很有幽默感。 我在搜索框输入了一些问题,并且看到了右侧New Bing的回复。我还向上滑动鼠标进入了聊天窗口,在那里我跟New Bing进行了一些对话。其中包括一些荒谬的问题: 为了对比New Bing的能力,我还用同样的问题向ChatGPT问了一下。 从这个问题中可以看出,两个机器人都给出了相对正确的答案。但是New Bing却显得更像一个懂得幽默的人,并且每个专业名词都有引用来源。 以前我曾经问过ChatGPT关于滕王阁序的问题。它告诉我滕王阁序是李白、苏轼、白居易写的。每次问都是不同的答案,但都是错的。今天我又问了同样的问题,它给出了这样的回答: 现在,它的作者是正确的,但是它的地址、内容描述等等都还是错误的。 同样的问题,bing是怎么回复的呢? bing的回复没有任何问题,并且对滕王阁 王勃 骈文 对仗的出处都有标出链接引用。它是从这些答案中汇总得出的。 然后我继续问了几个问题: 在问到一些想法的问题时,bing的表现明显没有最初好,它在没有引用时,杜撰的内容也是不可信的,比如它说这是滕王阁序的最后一句。 二者 相比,chatGPT的聊天确实是一个Demo级别的产品,而NewBing是一个测试级别的产品。 并且二者在训练的层次和模型版本,应该也有先后之别。 对于普通用户来说,new bing未来应该是更加受欢迎的,加上与Edge的融合,产品体验上应该可以完胜单独的浏览器或者AI Chat。 今天我还听到一个消息,就是GPT的API从3.0升级到了3.5,回复快了数倍,而且还更加便宜。看到hongyi直接拿来接了小爱同学,这两天有时间我也去搞一下子。

2025/12/30
articleCard.readMore

Interesting & Useful 的开源项目

| 地址 | 描述 | | ——————————————————- | ——————————————————————————————————————————————————- | | https://github.com/lmarzen/esp32-weather-epd | 一个天气显示的墨水瓶项目 | | https://github.com/AUTOMATIC1111/stable-diffusion-webui | Stable Diffusion 模型的 WebUI 界面。这是一个实现在浏览器上使用的 Stable Diffusion 模型的项目,支持通过文本/图片生成图片、嵌入文本、调整图片大小等功能。 | | https://github.com/gildas-lormeau/SingleFile | 一键下载网页,能够将网页上的文字、图片等内容,完整地整合到单个 HTML 文件里,支持 Chrome、Firefox、Safari、Microsoft Edge 等主流浏览器。 | | https://github.com/espanso/espanso | 快捷输入工具 | | https://github.com/tw93/Pake | 将网页打包成桌面应用,支持Mac Windows Linux |

2025/12/30
articleCard.readMore

聊天记录存储实践

公司的某款游戏在1月初接入微软小冰AI聊天功能。为了保存聊天记录并为后续的统计功能做好准备,决定将聊天记录存放在服务端。最初并不清楚聊天数据量的大小以及玩家对聊天功能的使用情况,所以采用了价格和性能相对宽容的MySQL作为存储介质。 经过大约一个月的运营后,聊天记录表中的数据量已经达到了两千万条。反馈给策划部门后,为控制数据量,决定对聊天记录数量进行限制。每个玩家的每个角色最多只保存200条记录,每个玩家最多可保存3个角色的记录,即每个玩家最多只保存900条聊天记录。服务端的处理逻辑随即修改为每存储300条记录后就清理一次,以确保数据量控制在一定范围内。 根据游戏的新增和玩家留存数据,假设每个玩家平均游戏时间为10天,新增的玩家数在2000至3000之间。因此,每10天会有20000至30000个新的玩家数据,按最高值计算,每10天可能会产生最高2700万条数据,每月最多可能达到5000万条数据。这样计算下来,在一个月后,MySQL可能会无法支持数据的频繁读写操作,需要对聊天记录的存储进行调整。 针对游戏的AVG属性,玩家的生命周期相对较短,因此可以根据玩家的注册时间对其进行划分,从而对玩家数据进行分表存储。具体来说,可以按照玩家注册时间的月份对数据进行划分,并使用相应的表进行数据的存储和查询。这样,每个月份可以相对均匀地承载玩家数据,原本可能达到千万级别的数据量,现在可以控制在百万级别,完全可以解决目前的问题(聊天数据量与新增数据正相关,新增相对稳定)。 接下来需要考虑的是,当一个玩家的生命周期结束后,其数据仍然会一直存储在数据库中。而当新的一年开始,新注册的玩家会继续存储,这样对玩家数据的切片就只是一个临时的措施。解决这个问题的方法是分析玩家在主生命周期结束后的行为,发现这些玩家长时间不会再次登录并进行聊天。因此,可以将这些不活跃的玩家数据迁移到便宜的分布式文件存储中,并且记录迁移标志,删除掉原数据库表中的记录。采用定时任务的方式,这样可以减少对业务的影响。 以上的删除操作,都要注意一点,mysql delete删除操作不会释放表空间。这里需要对表空间进行手动释放。手动释放大表空间是一个比较耗费性能的操作,还会对表数据进行锁定。所以释放操作是需要对实际情况进行设计的。在上面的情况中,我们对数据进行按月操作,可以在一个月后,对上月的数据进行清理。比如 3月初清理1月份的表,因为3月份活跃的玩家大多可能是2月份注册的。可以定在凌晨进行定时删除,对业务影响降到最低。 在2月份,新增了对聊天记录进行标记的操作需求,以进行定期反馈和AI调教。这里我们将标记操作的聊天记录独立出来存储,这样可以方便后续的统计和分析。这样也可以避免将标记操作分散在不同的表中,增加数据处理的复杂性。同时,由于标记操作的数据量不会很大,也可以考虑采用NoSQL数据库或者其他内存型数据库来存储这些数据,以提高查询速度和减少存储成本。

2025/12/30
articleCard.readMore

在 Google 设置静态页面 CDN 加速

在google设置静态页面 CDN加速 一、 创建bucket,设置bucket https://console.cloud.google.com/storage/browser ##### 创建bucket ##### 设置bucket公开访问 在bucket列表中,进入刚创建的bucket。 选择页面顶部附近的权限标签。 在权限部分中,点击 person_add 授予访问权限按钮。 此时将显示“授予访问权限”对话框。 在新的主帐号字段中,输入 allUsers。 在选择角色下拉列表的过滤条件框中输入 Storage Object Viewer,然后从过滤后的结果中选择 Storage Object Viewer。 点击保存。 点击允许公开访问。 二、设置CDN https://console.cloud.google.com/net-services/cdn/list 添加来源,创建时可能会附带创建好负载均衡,用来做DNS解析使用 指向刚才新创建的bucket 在host and path rules设置地址规则。 三、DNS解析 进入负载均衡控制台 https://console.cloud.google.com/net-services/loadbalancing/list/loadBalancers 选择创建好的负载均衡,点进去,拿到公网IP,到DNS控制台进行解析即可。

2025/12/30
articleCard.readMore

发现优质 Newsletter 和 Blog

我平时一直都在发现很多优质的资源,但都没有整理。一直增加,单独一个页面可能开始会有些乱,就先开一篇放在这里。 | 名称 | 简介 | | ——————————————————————————————— | ——————————-| | 新趣集 | 发现有趣的新产品 | | AlleyRead | 发现国内外优质内容 | | 小众软件 - 发现 | 寻找应用、软件 | | v2ex - 发现 | 分享好玩的,并获取灵感 (须自强) | | v1tx | 分享实用工具与解决问题 | | 炒饭 | 发现有趣的内容| | | 创造者日报 | 每天发现有趣的产品| | | producthunt | 每天发现有趣的新产品 (国外产品) | | 找到 AI | 找到你喜爱的作品 | | 52 破解 | 找到你想要的软件 | | 发现优质 Newsletter)| 授人予鱼 不如授人予渔, 这是一个newsletter的推荐网站 | | Copy By yihong Blog , 都是非常好的博客⬇️ @yihong0618 收藏的博客 | 博客名称 | 添加日期 | type | 备注 | | ——- | ——- | —- | —- | | niyue | 2022.01.20 | 第一个 | 写 20 年博客的前辈 | | baotiao | 2022.01.31 | 数据库 | 做有积累的事情 | | javabin | 2022.02.03 | hacker | 好 cool 的人 | | soulteary | 2022.02.07 | DIY 神 | 向往 | | 7dot9 | 2022.02.26 | 前辈 | 梦在这里可以飞翔 | | chrisdown | 2022.03.05 | SRE | Linux |

2025/12/30
articleCard.readMore

Nginx 代理静态网站 CSS 解析异常

今天在使用ecs进行部署网页时,出现了一个问题。使用nginx代理到页面index.html路径下,同路径的资源都可以加载到,但是却无法正确加载到页面样式。打开f12,网络和控制台都没有资源异常,但页面乱成了一锅粥。 本地打开是正常的,上到服务器却不行? 之前使用nginx时,并没有这个问题,于是我猜测是不是nginx新的版本对配置参数进行了修改? 但我翻看了nginx的文档,却没有找到。于是我跟着症状开始在网上翻文,终于: �解决方法 �若不对于css文件解析进行配置,nginx默认文件都是text/plain类型进行解析,为此我们需要对此进行简单配置。将下面这段代码放入到location模块下面,然后重启nginx。记住一定要清理浏览器的缓存。 include mime.types; default_type application/octet-stream; 完整如下: location / { include mime.types; default_type application/octet-stream; alias /opt/website/; autoindex on; }

2025/12/30
articleCard.readMore

macOS 12 Monterey + Windows11 我的生产力答案

大学毕业后,我买了一台乞丐版的 13 寸 MacBook Pro,这台电脑配置比现在的安卓手机还低。今年年中,我开始把用于工作的 MacBook Pro(18 款 15 寸 16+256)带回家使用,虽然勉强能用,但是我渐渐地发现这两台电脑都不能满足我的需求。 从 2020 年开始,我就计划安装一台黑苹果,我把很多零件加入购物车,但是当时显卡实在太贵,所以一直没有开始。后来以太坊价格直线上涨,矿卡价格也水涨船高,苹果公司也开始全面转向 ARM 平台,这使我更加渴望拥有一台强大的“生产力”工具,赶上黑苹果的尾巴。 在我看来,相比苹果设备,黑苹果有以下几个优点: 价格便宜。 可以自定义硬件配置,不需要精准的安装技能。 可以安装 x86 平台的 Windows 系统,可以用于娱乐和生产力。 探索的过程也是一种快乐。 价格方面,只需要 1000 元左右就可以购买 32GB DDR5 内存,500多块可以买到2TB 固态硬盘等等。 自定义硬件方面,我可以先买一个 32GB 的内存条,然后等到有钱了再买一个(众所都周知,苹果的内存条是金子做的,真买不起)。 同样的道理,我也可以先买一个 5600XT 显卡(甚至是矿卡),然后再换成 6600XT 等更强的显卡。 安装 Windows 是很有必要的,因为无论是娱乐还是工作,Windows 都是必不可少的。 如果你觉得双系统切换麻烦,这个工具了解一下:fa-hand-o-down: 几个劣势: 体积大 兼容性,稳定性差。 可能无法升级。 功耗大。 需要花费一些时间。 在体积上,再怎么取舍,也会远远超过mac stdio的完美集成。我使用的机箱是联立A4 H2O,是比较经典的A4机箱,也要11L的空间。但我这里可以装下一个240水冷,更大的体积也会带来更好的散热。 兼容性的话,在装机时候,需要很多的设置,扒帖,去定制更适配的EFI。 无法升级这个可能存在的问题,我尽量选择新的系统中的稳定的主流的系统,这样,在一次好好准备之后,接近完美的一个系统可以用很久。 功耗,对一个普通台式机玩家,不是问题。 花费时间是可以取舍的,如果想要尽量快,就去尽量找别人准备好的,可能会花费几块钱;如果喜欢折腾,就去扒帖,学习设置。白果只有物流时间。 然后,我进行了一些性能测试。总体来说,我的黑苹果系统表现不错,性能比我之前的MacBook Pro要好得多。我可以运行Adobe Creative Suite和Final Cut Pro等专业软件,没有出现任何问题。 此外,我也可以在我的黑苹果上安装Windows系统,这样我就可以玩一些Windows平台上的游戏了。这是Mac OS系统所不能提供的,这也是我决定装黑苹果的原因之一。 当然,黑苹果也有一些劣势,比如兼容性和稳定性相对较差,可能需要花费一些时间来解决问题。但是,对于我这种喜欢折腾的人来说,这些问题并不是什么大问题。我可以通过网上的资源和社区来解决这些问题。 总的来说,装黑苹果是一项有趣的尝试。虽然过程可能会有些繁琐,但是如果你是一个喜欢尝试新事物和折腾的人,那么我认为你可以尝试一下。:tw-1f603: 然后就是我的整个装机历程了: 零件: 1. 机箱 联立A4 H2O 900RMB 2. 电源 长城tf750 729RMB 3. 主板 ROG B660itx 1250RMB 4. 内存 海盗船DDR5 5200MHZ 1000RMB 5. CPU i512490F盒装 1050RMB 6. 显卡 蓝宝石5600XT 白金版 6G 628RMB 7. 固态 七彩虹2TB 569RMB 8. 水冷 利民冰封幻境240 RGB 300RMB 共: 6426RMB win11固态出厂预装 在网上找到B660I 主板设置好的EFI文件 下载和这个EFI文件适配的带引导的dmg系统镜像,直接写盘,更换EFI,重启安装。 之后我会把相对应的教程和文件贴一下,如果有同学看到了,感兴趣,着急的可以直接留言,我会尽快整理的。 :tw-1f618:

2025/12/31
articleCard.readMore

最新版 Let’s Encrypt 免费证书申请步骤,保姆级教程

最近将域名迁到了google domain,就研究了一下Let’s Encrypt的域名证书配置。发现网上找到的教程在官方说明中已经废弃,所以自己写一个流程记录一下。 步骤方法官方文档见:https://eff-certbot.readthedocs.io/en/stable/install.html#installation snapd官方文档见:https://certbot.eff.org/instructions 1. 安装snapd(这里我使用的是centos系统) sudo yum install snapd sudo systemctl enable --now snapd.socket sudo ln -s /var/lib/snapd/snap /snap 2. 使用snapd安装certbot sudo snap install --classic certbot sudo ln -s /snap/bin/certbot /usr/bin/certbot ####3. 生成证书(需要指定nginx) :fa-chevron-circle-right: 手动安装nginx certbot certonly --nginx --nginx-ctl /usr/local/nginx/sbin/nginx --nginx-server-root /usr/local/nginx/conf 这里的certonly就是只下载对应文件,不进行配置nginx,适用于自己配置或者更新使用。去掉则会帮你进行配置nginx(我没有试用)。 可能出现的问题: The error was: PluginError(‘Nginx build is missing SSL module (–with-http_ssl_module).’) 提示这个错误是因为目前nginx缺少–with-http_ssl_module这个模块,我们要添加这个模块。重新编译nginx 进入nginx下载的目录 ./configure –prefix=/usr/local/nginx –with-http_ssl_module 编译完成后 make make install /usr/local/nginx/sbin/nginx -s stop /usr/local/nginx/sbin/nginx -c /usr/local/nginx/conf/nginx.conf 使用 /usr/local/nginx/sbin/nginx -V 查看是否生效 [root]# /usr/local/nginx/sbin/nginx -V nginx version: nginx/1.23.2 built by gcc 10.2.1 20200825 (Alibaba 10.2.1-3 2.32) (GCC) built with OpenSSL 1.1.1k FIPS 25 Mar 2021 TLS SNI support enabled configure arguments: --prefix=/usr/local/nginx --with-http_ssl_module 生效,重新执行上面的命令即可。 4. 生成证书 执行上面的命令后,程序会让你确认你的邮箱和你的域名,确认完成后会将证书文件生成在指定目录中。 certbot certonly --nginx --nginx-ctl /usr/local/nginx/sbin/nginx --nginx-server-root /usr/local/nginx/conf Saving debug log to /var/log/letsencrypt/letsencrypt.log Enter email address (used for urgent renewal and security notices) (Enter 'c' to cancel): [这里输入你的邮箱] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Please read the Terms of Service at https://letsencrypt.org/documents/LE-SA-v1.3-September-21-2022.pdf. You must agree in order to register with the ACME server. Do you agree? - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - (Y)es/(N)o: Y - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Would you be willing, once your first certificate is successfully issued, to share your email address with the Electronic Frontier Foundation, a founding partner of the Let's Encrypt project and the non-profit organization that develops Certbot? We'd like to send you email about our work encrypting the web, EFF news, campaigns, and ways to support digital freedom. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - (Y)es/(N)o: Y [选Y 继续] Account registered. Which names would you like to activate HTTPS for? We recommend selecting either all domains, or all domains in a VirtualHost/server block. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1: whrss.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Select the appropriate numbers separated by commas and/or spaces, or leave input blank to select all options shown (Enter 'c' to cancel): [这里不需要输入,回车选所有] Requesting a certificate for whrss.com Successfully received certificate. Certificate is saved at: # [这里告诉我们生成的文件路径和有效期] /etc/letsencrypt/live/whrss.com/fullchain.pem Key is saved at: /etc/letsencrypt/live/whrss.com/privkey.pem This certificate expires on 2023-03-02. These files will be updated when the certificate renews. Certbot has set up a scheduled task to automatically renew this certificate in the background. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - If you like Certbot, please consider supporting our work by: * Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate * Donating to EFF: https://eff.org/donate-le - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5. Nginx.conf的配置 server{ #监听443端口 listen 443 ssl; #对应的域名,空格分隔域名就可以了 server_name whrss.com; #第一个域名的文件 ssl_certificate /etc/letsencrypt/live/whrss.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/whrss.com/privkey.pem; # 其他配置 ssl_session_timeout 5m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE; ssl_prefer_server_ciphers on; #这是我的主页访问地址,因为使用的是静态的html网页,所以直接使用location就可以完成了。 location / { root /; index /; proxy_pass http://127.0.0.1:9091; proxy_set_header Host $host:443; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Via "nginx"; } } 以上!!

2025/12/30
articleCard.readMore

五人墓碑记

作者:张溥 明 五人者,盖当蓼洲周公之被逮,激于义而死焉者也。至于今,郡之贤士大夫请于当道,即除逆阉废祠之址以葬之,且立石于其墓之门,以旌其所为。呜呼,亦盛矣哉!夫五人之死,去今之墓而葬焉,其为时止十有一月尔。夫十有一月之中,凡富贵之子,慷慨得志之徒,其疾病而死,死而堙没不足道者,亦已众矣,况草野之无闻者欤?独五人之皦皦,何也? 予犹记周公之被逮,在丁卯三月之望。吾社之行为士先者,为之声义,敛赀财以送其行,哭声震动天地。缇骑按剑而前,问:“谁为哀者?”众不能堪,抶而仆之。是时以大中丞抚吴者,为魏之私人,周公之逮所由使也。吴之民方痛心焉,于是乘其厉声以呵,则噪而相逐,中丞匿于溷藩以免。既而以吴民之乱请于朝,按诛五人,曰颜佩韦、杨念如、马杰、沈扬、周文元,即今之傫然在墓者也。然五人之当刑也,意气阳阳,呼中丞之名而詈之,谈笑以死。断头置城上,颜色不少变。有贤士大夫发五十金,买五人之脰而函之,卒与尸合。故今之墓中,全乎为五人也。 嗟夫!大阉之乱,缙绅而能不易其志者,四海之大,有几人欤?而五人生于编伍之间,素不闻《诗》《书》之训,激昂大义,蹈死不顾,亦曷故哉?且矫诏纷出,钩党之捕遍于天下,卒以吾郡之发愤一击,不敢复有株治。大阉亦逡巡畏义,非常之谋,难于猝发。待圣人之出而投环道路,不可谓非五人之力也。 繇是观之,则今之高爵显位,一旦抵罪,或脱身以逃,不能容于远近,而又有翦发杜门,佯狂不知所之者,其辱人贱行,视五人之死,轻重固何如哉?是以蓼洲 周公,忠义暴于朝廷,赠谥美显,荣于身后。而五人亦得以加其土封,列其姓名于大堤之上,凡四方之士,无有不过而拜且泣者,斯固百世之遇也。不然,令五人者保其首领,以老于户牖之下,则尽其天年,人皆得以隶使之,安能屈豪杰之流,扼腕墓道,发其志士之悲哉?故予与同社诸君子,哀斯墓之徒有其石也,而为之记,亦以明死生之大,匹夫之有重于社稷也。 贤士大夫者,冏卿因之吴公、太史文起文公、孟长姚公也。

2025/12/30
articleCard.readMore

随想—生活效能

之前我有写过一篇文章,是关于边际效益的思考的,但很不幸,我没有备份,一并被阿里云ban掉了。 那篇文章是基于李永乐老师的一个视频——996的那个,来讨论的。最近,我发现在生活中,我有不少关于效益的习惯和思考,记录一下。 最近,我意识到自己对于效益的思考和行为有不少习惯,其中一个例子是洗衣服。由于我租住的房子没有洗衣机,而我个人穿着简单,衣服也很少,所以我通常都是手洗。刚开始,我只是随便洗一下,因为感觉这样能省事,但是随着时间的推移,我开始思考这个过程中的效益和成本,并且逐渐调整了我的洗衣方式。 首先,我从心理层面分析了这个问题。我从洗衣液问世时就开始使用它,因为它能轻松地溶解脏污,尤其是一些不易清洗的面料。这种想法让我认为只要用洗衣液泡一下,就能够解决70%左右的污渍。对于日常穿着,这种简单的清洗方式足以满足需求。但是对于有些衣服,比如容易掉色的棉制外裤,洗得太狠会影响寿命,这也是我使用简单方法洗衣服的原因之一。此外,冬天水太冷,而且洗衣机所需的衣服数量太多,所以我不得不简化洗衣的流程。 接着,我开始考虑“边际效益”。我想到了洗衣过程中从“干净”到“脏”的过渡,这个过程需要进行比较,比如,当衣服开始散发出汗味时,就需要清洗。我需要洗衣服来保证我的整体干净程度。在控制洗衣液和水温等变量条件下,衣服的干净程度和所需要付出的劳动基本上可以看作一个效益曲线。这个曲线上有一个最佳点,也就是衣服的干净程度和所付出的劳动量之间的比例最佳。在这个点上,我们付出的劳动和晾晒的时间等等,可以最大程度地轻松达到一个干净的程度。如果要让衣服更加干净,所需付出的劳动就会越来越大。 因此,我选择了一种简单而有效的洗衣方式:浸泡、揉搓、涮净、拧干和晾晒。这个过程几乎不需要花费多少时间和经历,在让衣服变得干净的同时,也会大大延长衣物的寿命。 效能,效就是结果,能就是取效所做的功。在实现目标的过程中,所需付出的劳动与产生的效果之间存在一种平衡点,我们需要找到这个点,以达到最大化效益的目的。

2025/12/30
articleCard.readMore

测试一下功能吧

阿里服务器被ban了,数据全没了,测试一下重新组织博客能否正常工作吧 全部组件都使用了国外免费的平台。

2025/12/30
articleCard.readMore