普通视图

发现新文章,点击刷新页面。
昨天以前了迹奇有没

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

作者 whrss
2025年11月28日 15:08

前言:分布式系统里最让人头疼的事

如果你做过一点微服务开发,大概率都遇到过这种场景:

用户下单 → 扣库存 → 扣余额 → 更新积分。

这听上去很简单,按顺序写几行逻辑就好,但问题是——这些操作分散在不同的服务、不同的数据库里。

于是问题来了:

如果中间有一步失败了,整个流程该怎么办?

  • 订单创建了,但库存没扣?

  • 库存扣了,但支付失败?

  • 消息没发出去,用户看不到状态?

这就是经典的分布式事务一致性问题

今天我们就从最早的 2PC(两阶段提交)开始,一路讲到现在大家更常用的 Outbox 模式,看看这一路我们是怎么在”理想”和”现实”之间反复拉扯的。


一、理想中的完美方案:2PC(两阶段提交)

2PC,全称 Two Phase Commit。

听起来就很有那味儿:让多个数据库像一个事务一样提交或回滚。

它是怎么工作的?

顾名思义,它分两步走:

  1. Prepare 阶段(投票阶段)

    协调者(Coordinator)告诉所有参与者(数据库、服务):”兄弟们,准备一下,看你们能不能提交。”

    每个参与者检查本地事务是否能成功,如果没问题,就写好日志、锁住资源,回复一句”我准备好了(YES)”。如果不行,就回复”NO”。

  2. Commit/Abort 阶段(执行阶段)

    • 如果所有人都回复”YES”,协调者就广播”Commit!”,大家正式提交。

    • 如果任何一个回复”NO”或超时,协调者就广播”Abort!”,大家全部回滚。

听上去很完美,对吧?所有操作都能保持一致,要么都成功,要么都失败。

举个例子

假设我们有订单服务、库存服务、支付服务。

在一次下单操作里:

  1. 协调者发送”Prepare”命令。

  2. 三个服务各自检查:订单能建、库存够、余额足。

  3. 都返回”YES”。

  4. 协调者发送”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 化

假设业务流程:

  1. 创建订单(本地事务)

  2. 扣库存(本地事务)

  3. 扣余额(本地事务)

  4. 增加积分(本地事务)

如果第 3 步(扣余额)失败了,那就按相反顺序触发补偿逻辑:

  • 恢复库存(补偿操作)

  • 取消订单(补偿操作)

需要注意的关键问题

1. 补偿操作的设计难度

  • 每个正向操作都要有对应的补偿操作

  • 补偿操作必须幂等(可能被重复调用)

  • 补偿操作可能不完美,需要业务妥协

2. 某些操作难以或无法补偿

  • ❌ 已发送的短信通知(无法撤回)

  • ❌ 已发放且被使用的优惠券

  • ❌ 已调用的第三方支付(需要走退款流程,不是真正的”撤销”)

  • ❌ 已发出的实物货品

3. 中间状态可见性

Saga 过程中,其他事务可能看到不一致的中间状态

  • 订单已创建,但支付还在处理中

  • 库存已扣,但订单被取消(正在补偿)

这要求业务设计时考虑这些中间态。

优缺点

✅ 优点:

  • 性能好:不需要全局锁,各服务独立执行

  • 可用性高:单个服务故障不会阻塞整个流程

  • 适合长流程:支持跨天、跨周的业务流程

  • 松耦合:各服务可以独立演进

❌ 缺点:

  • 补偿逻辑复杂:每个操作都要设计补偿,代码量翻倍

  • 调试困难:分布式环境下追踪问题链路长

  • 无法保证隔离性:中间状态对外可见

  • 某些场景不适用:存在不可补偿的操作

一句话总结:

Saga 是”可控的混乱”:牺牲了强一致性和隔离性,换来了性能和可用性。适合大部分业务场景,但需要精心设计。


三、现实中更优雅的方案:Outbox Pattern(事件表)

再往后,很多系统变成了事件驱动架构

这时一个新问题出现了:

我更新了数据库后,要发一条消息到 MQ,但如果发 MQ 时系统挂了怎么办?

比如:

  1. 创建订单(DB 成功)

  2. 发送 “OrderCreated” 消息到 Kafka(失败或未执行

数据库里已经有订单,但消息没发出去,下游服务不知道有新订单,系统就不一致了。

你可能会想:那我先发消息,再写数据库?


1. 发送消息到 Kafka(成功)

2. 创建订单(失败)

这样更糟:消息发出去了,但数据库里没有订单,下游收到的是”幽灵订单”。

核心问题是:这是两个不同的系统(数据库 + 消息队列),无法在一个事务中原子性地完成。

这时,Outbox Pattern 登场。


核心思路

既然我们不想搞分布式事务,那就干脆:

把”要发的消息”也当作业务数据写进数据库,和业务操作一起提交!

具体做法:

  1. 业务操作 + 消息落库(在同一个本地事务中)

    • 插入/更新业务数据(如订单表)

    • 同时插入一条待发送的消息到 outbox_messages

  2. 后台异步发送

    • 定时任务/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 工程可行

真正的目标不是”完美一致”,

而是”在可接受的时间窗口内,达到业务所需的一致程度”。

工程上的务实原则

  1. 能用单体事务就别分布式

    如果功能可以在一个服务内完成,就用本地事务,不要为了”微服务”而微服务。

  2. 异步优于同步

    大部分业务场景都能接受”最终一致”,几秒的延迟换来的是更高的性能和可用性。

  3. 补偿优于回滚

    现实业务中很多操作本身就无法”撤销”,补偿是更符合业务语义的方式。

  4. 监控和可观测性是关键

    分布式系统出问题是常态,重要的是能快速发现、定位和恢复。

  5. 测试、测试、还是测试

    补偿逻辑、幂等性、重试机制都要充分测试,包括网络分区、节点宕机等异常场景。


如果你在设计”订单 + 库存 + 支付”这样的系统:

✅ 用 Saga 编排业务流程(订单创建 → 扣库存 → 扣款 → 发货)

✅ 用 Outbox 保证每个服务的数据变更能可靠地发布事件

✅ 让消费者实现幂等,处理可能的重复消息

✅ 设计好监控和告警,及时发现补偿失败或消息积压

这,就是工程上的”务实一致性”。


参考资料:

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

作者 whrss
2025年11月28日 11:16

在做移动应用开发时,不管是 Android 还是 iOS,我们都绕不开“签名”这件事。最近我们项目在筹备适配 HarmonyOS NEXT(鸿蒙 Next) 的 SDK,也顺势把鸿蒙的签名机制研究了一遍。这篇文章来分享下。

简单来说,签名的目的是确保应用的来源可信、内容完整、平台可识别。我们开发的应用,最终都要交给系统安装,那系统当然得确认一下:这个包是“你”发的、这包在传输过程中没被篡改、它是我允许安装的对象。这就要靠数字签名。

说到底,数字签名就是:

用私钥对应用包里的内容摘要加密,生成一个签名块,塞进包里。

系统在安装时用你当初的公钥解密它,校验签名是否匹配。如果一切对得上,才会放行。

我们先复习下 Android 和 iOS 的签名流程,再看看鸿蒙的做法是怎么“融合创新”的。


Android 签名机制回顾

搞过 Android 的同学都知道,签名这套流程绕不开 Keystore 文件(.jks 或 .keystore)。它是个密钥库,里面有你生成的一对公私钥。

生成方法嘛,Android Studio 自带的工具或者命令行 keytool 都能搞。你得设置密码、别名、组织机构之类的。

签名流程一般是:

  1. apksignerjarsigner 对 APK 进行签名;

  2. 安装时,系统会从 APK 中提取签名块;

  3. 用嵌入的 公钥 验签;

  4. 检查包的内容有没有被改动。

重点是——升级包必须用相同密钥签名!

否则就会被系统拦下来,提示你“签名不一致,不能覆盖安装”。

后来 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 中的一环。

如果你也要做鸿蒙适配,早点熟悉这套流程,绝对事半功倍!

“该省省,该花花”

作者 whrss
2025年11月28日 11:16

春节假期,我终于完成了两件大事:见了未来的岳父岳母,还带奶奶游览了北京。忙里偷闲,我决定总结一下这几天的经历,顺便分享一些“花钱”与“省钱”的小感悟。

作为一名农村出身的人,我的消费观念一直是:钱能不花,就不花。 例如:

• 旅游?自己带矿泉水,自己带干粮,景区里一瓶水8块?不可能!

• 门票?买最低门槛的,能溜进去的坚决不多花一分钱!

• 遇到导游、摄影师、纪念品摊贩?避之如洪水猛兽,仿佛他们会瞬间吸干我的钱包。

但这些年,尤其是这个假期的几件事,让我逐渐意识到:有些钱,省下来是亏的,花出去才是赚的。

游泳:花钱买经验,少走弯路

大学时,我决定学游泳。(ps: 一个月1000生活费,拮据~)当时面临两个选择:

  1. 办游泳年卡(500块),自学成才!

  2. 请游泳教练(20节课500块),专业入门!

作为精打细算的学生党,我毫不犹豫地选择了年卡,心想:天天泡在泳池里,还能游不会?

结局是,我的年卡被用得相当充分,但整整一年下来,只学会了蛙泳。自由泳换气?不行。打腿节奏?不对。游过去还能游回来?不存在的。两年后,喝了无数口泳池水,我终于磕磕绊绊学会了自由泳,蝶泳只学了个“皮毛”。但即便如此,每次游完我脖子都酸,说明动作仍然有问题。

回头一想,当年如果花钱请教练,可能两个月就能达到现在的水平,少喝多少水啊! (而且,大学时候的教练费是真的便宜啊~~)但当时的我,非要靠自己摸索,结果就是走了很多弯路,还差点呛成“水鬼”。

滑雪:没教练,甚至不会穿板

这次假期,我和女朋友去了东北,决定挑战滑雪。作为一个自学成才的运动达人(至少自认为是),我信心满满地选择了单板

然而,我连雪板都穿不上。

站在雪道旁,我捣鼓了半小时,发现自己甚至不会正确扣上固定器。看着别人轻松滑走,我怀疑人生:

是鞋的问题?

是板的问题?

还是我的问题?

这时,一个大哥看不下去了:“哥们,你鞋都不会穿,找个教练吧……”

女朋友心疼我,掏钱包,请了个教练。等教练的工夫,我终于自己学会了穿鞋(咱还是有点天赋的!)

接下来,短短两个小时,教练带我从后刃滑行 → 落叶飘 → 陡坡尝试,五次练习后,我已经能上中级道了!从一脸懵逼到掌握平衡、控制速度,我终于体会到:花钱买指导,真香!

如果没有教练,我可能得花三天去摔跤,最后依旧漏洞百出。这几百块,等于帮我省下了两天的雪场门票、误伤自己可能花的医药费。

旅游:没有导游,可能只是“打个卡”

小时候家里穷,到北京来玩也是因为爸妈在北京工作,出门基本是“快进模式”:

景区门口拍个照 = 到此一游!

随便走走,发现没啥意思 = 直接走人!

这次去沈阳故宫,门口有个导游大姐找到我们:“快关门了,最后一个拼团,给你俩便宜点~!

本着反正也不贵的心态,我们跟上了团。结果,这趟游览改变了我的旅游观念——

沈阳故宫很小,随便逛一圈可能用不了20分钟。但在导游的讲解下,这座小小的宫殿变得鲜活起来:

什么是口袋房?

这里为什么只有一个大烟囱?为什么有一个杆子顶个碗?

东南西北不同颜色都怎么来的?

听完导游的讲解,我发现自己不仅记住了这些历史故事,甚至对历史产生了更浓厚的兴趣。

如果没有导游,我可能只会留下几张照片,走马观花地打个卡,最后啥也没学到。

人生的投资观:该省省,该花花

这几件事让我彻底明白了一个道理:“省钱”不是不花钱,而是把钱花在真正值得的地方。

• 该花的钱,花出去能让你更省时间、更少走弯路、更享受体验。这些钱是“投资”,而不是“浪费”。

• 该省的钱,指的是那些没有意义的开销,比如买一堆用不上的纪念品,或者在低性价比的地方瞎花钱。

真正会花钱的人,不是抠门,而是懂得如何花最少的钱,跨过性价比最高的门槛。

当你愿意为专业指导付费,你就能少走几年弯路;

当你愿意为体验买单,你就能获得真正的乐趣和记忆;

当你愿意为知识买单,你就能看到别人看不到的世界。

人生,就是一个不断投资自己的过程。

So,my friend,别再和我一样傻省钱了,学会“该省省,该花花”!

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

作者 whrss
2025年11月28日 11:16

在 Go 语言中,context 包提供了一种跨 API 和进程边界传递请求作用域值、取消信号以及超时信号的方式。使用 context 可以帮助我们更好地控制 goroutine,避免 goroutine 泄漏等问题。

出现 “context deadline exceeded” 错误通常是因为在请求上下文中设置了超时时间,但请求在超时时间内未完成。我们应该尽量避免这种错误,原因如下:

  1. 错误处理context deadline exceeded 是一个错误,如果忽视它可能导致程序运行异常或产生其他错误。

  2. 错误分析:当我们对数据埋点和日志进行分析时,如果出现 “context deadline exceeded” 错误,我们很难直接定位到具体的错误来源。

假设我们在一个分布式系统中处理多个请求,如果日志中充斥着 “context deadline exceeded” 错误,我们根本无法判断是哪里出现了问题。

  1. 资源泄漏:未及时取消 goroutine 可能会导致资源(如内存、文件描述符等)无法及时释放,引起资源泄漏问题。

比如数据库慢查询,数据库连接可能会被占用,导致连接池耗尽。

  1. 性能问题:长时间运行的请求未能取消,会消耗大量的系统资源,影响整体系统性能。

  2. 用户体验:对于需要等待长时间的请求,用户可能会感到迷惑和不耐烦,影响用户体验。

为了消除 “context deadline exceeded” 错误,我们可以采取以下几种办法:

  1. 合理设置超时时间:根据实际业务需求,设置合理的超时时间,避免过短或过长的设置。在对应位置返回转换后的业务错误。

比如,对于一个数据库查询操作,可以根据历史数据分析设置一个合理的超时时间,确保大多数查询都能在该时间内完成。

  1. 使用 context.WithTimeout:在请求开始时,使用 context.WithTimeoutcontext.WithDeadline 创建带超时时间的 context,并在请求完成或超时后及时取消该 context。

比如,在执行一个 HTTP 请求时,可以使用 context.WithTimeout 设置一个 5 秒的超时时间,如果这个请求超时,可以返回对应的业务枚举错误,在进行错误定位时,可以直接找到问题出现的原因。

  1. 监控长时间运行的请求:定期检查是否存在长时间运行的请求,如果有,则及时调整超时策略。

可以使用 Prometheus 监控请求的持续时间,并设置告警通知,提醒开发人员处理超时请求。

  1. 优化代码逻辑:review 代码,优化潜在的低效或阻塞代码,减少执行时间。

  2. 记录及分析错误:通过日志记录及时发现 “context deadline exceeded” 错误,并分析其根本原因。

消除 “context deadline exceeded” 错误不仅有利于系统的健康运行,也可以提高系统的可靠性和用户体验。在 Go 编程中,我们应该合理使用超时时间的设置,尽量避免这类错误的发生。

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

作者 whrss
2025年11月27日 17:18

处理 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 函数,是谁进行使用了呢?在什么时候进行使用了呢?

主键冲突的错误一定是出现在插入的时候,我们顺着 gormCreate 方法向下找,可以发现它调用了一个 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 接口进行了断言,断言成功,就调用对应 DialectorTranslate 函数,而当这里的 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

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

作者 whrss
2025年11月27日 20:58

Tunnel 可以做什么

  • 将本地网络的服务暴露到公网,可以理解为内网穿透。 例如我们在本地服务器 localhost:8091 搭建了一个 博客网站,我们只能在内网环境才能访问这个服务,但通过内网穿透技术,我们可以在任何广域网环境下访问该服务。相比 NPS 之类传统穿透服务,Tunnel 不需要公网云服务器,同时自带域名解析,无需 DDNS 和公网 IP。

  • 将非常规端口服务转发到 80443 常规端口。 无论是使用公网 IP + DDNS 还是传统内网穿透服务,都免不了使用非常规端口进行访问,如果某些服务使用了复杂的重定向可能会导致 URL 中端口号丢失而引起不可控的问题,同时也不够优雅。

  • 自动为你的域名提供 HTTPS 认证。

  • 为你的服务提供额外保护认证。

  • 最重要的是——免费。

Tunnel 工作原理

Tunnel 通过在本地网络运行的一个 Cloudflare 守护程序,与 Cloudflare 云端通信,将云端请求数据转发到本地网络的 IP + 端口。

前置条件

  • 持有一个域名

  • 将域名 DNS 解析托管到 CloudFlare (我目前都直接转到了 CloudFlare )

  • 有一台服务器(本地非本地都可以,有没有公网 IP 都 OK),用于运行本地与 cloudflare 通信的 cloudflared 程序

  • 一张境内双币信用卡(仅用于添加付款方式,服务是免费的)

开始

1. 打开 Cloudflare Zero Trust 工作台面板

2. 创建 Cloudflare Zero Trust ,选择免费计划。需要提供付款方式,使用境内的双币卡即可

image.png

填写 team name,随意填写

image.png

选择免费计划

image.png

添加付款方式

image.png

填写信用卡信息(仅验证,不会扣款),完成配置

3. 完成后,在 Access Tunnels 中,创建一个 Tunnel。

image.png

创建 Tunnel

image.png

4. 选择 Cloudflared 部署方式。

Tunnel 需要通过 Cloudflared 来建立云端与本地网络的通道,这里推荐选择 Docker 部署 Cloudflared 守护进程以使用 Tunnel 功能。

image.png

获取 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 服务端口。

image.png

配置内网目标 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。

image.png

创建 Application

2. 选择 Self-hosted。

image.png

选择类型

3. 填写配置,注意 Subdomain 和 Domain 需要使用刚刚创建的 Tunnel 服务相同的 Domain 配置

image.png

配置三级域名

4. 选择验证方式。填写 Policy name(任意)。在 Include 区域选择验证方式,示例图片中使用的是 Email 域名的方式,用户在访问该网络时需要使用指定的邮箱域名(如@gmail.com)验证,这种方式比较适合自定义域名的企业邮箱用户。另外你还可以指定特定完整邮箱地址、IP 地址范围等方式。

image.png

选择验证方式

5. 完成添加

image.png

此时,访问 https://app.yourdomain.com 可以看到网站多了一个验证页面,使用刚刚设置的域名邮箱,接收验证码来访问。

image.png

评价

除了上述直接转发 http 服务之外,Tunnel 还支持 RDP、SSH 等协议的转发,玩法丰富。

作为一款免费的服务,简单的配置,低门槛使用条件,适合简单部署尝试。

通过 ssh 隧道部署,还可以跳过国内服务器域名必须备案的条件。

不过要注意的是 Tunnel 在国内访问速度不快,并且有断流的情况,请酌情使用。

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

作者 whrss
2025年11月27日 17:18

在谈论 API 设计和开发时,有时,一个属性可以是多种类型中的一个,但不能同时是多种类型。比如支付接口的回调处理,常常为了兼容不同平台的参数,会使用以下方式中的一种来进行接收:

  1. 范型

  2. key-value 形式的 map

  3. 所有的 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 描述。选择哪种技术取决于具体的应用场景,但了解这些技术如何处理这些关键概念可以帮助开发者做出明智的决策。

我的自行车

作者 whrss
2025年11月27日 23:44

在今年7月份左右,我开始关注自行车,出于便携性而言,我更倾向于购买一辆折叠车,之前在大街上也看到了很多骑得超快的折叠车,所以我对于折叠车的竞速性能也是满意的,于是我开始看各种折叠车的测评和论坛。

折叠车和山地、公里车相比,会更加复杂,零件会更多一些。同样的,有整车 有组装。但在折叠车的市场里,不想公路和山地,大多数购买整车,在折叠车这里,组装是主市场。

组装,主要的零件就是车架,折叠车里最火的就是风行的车架。除了风行,另外就是大行、飞鱼 和 小布,大行是卖的最多的折叠车整车品牌,小布是一个很“高端豪华”设计很时尚的很贵的折叠车品牌。

风行蚂蚁腿:

image.png

大行P8:

image.png

小布折叠车:

image.png

因为风行折叠车的可选配、自定义方案足够多,所以我选择了从风行中寻找方案。风行的折叠车主要有四种车架,蚂蚁腿、Y架、K架、海豚架。


而我的出行并没有特别的需求,通勤短程使用,没有特别的需求,好看即可,而蚂蚁腿就是颜值这块儿的顶梁柱。

然后我看了很多的方案,看到最后,给我审美整疲劳了,最后把之前觉得好看的发给了女朋友,让她来给我选,最后,她一眼就看中了非碳的最贵的一个。。。

image.png

image.png

image.png

image.png

这个车架的颜色非常漂亮,有风行的标,但不是风行的颜色。问过老板才知道,是他们家自己喷的,我再翻,就没有过第二家这么做的了。

整车配置如下:

56101696910294_.pic.jpg

车架大概是 1200 + 700 涂装

两个轱辘 加一起 1k多

碳前叉 1k

加上其他的零零散散碎碎 ¥5300

看到这个价格,我当时确实是跪了。

买车最开始嚷嚷着预算800, 后来看着看着 1600, 再看风行折叠车9速最低配也要2500,好家伙一层一层,最后终于超越了5k。。。

可能是价格的烘托,这车是越看越好看。

女朋友觉得我喜欢的东西,买就完了。想到自己今年要换工作,工资也涨了一点点,买!

随后我也是痛痛快快下单了。

经过半个多月的漫长等待,商家终于想起我了,然后用了一天给我喷了漆,连夜顺丰,给我发过来了。


经过比较漫长的挑选和等待,我终于收到了心爱的车~

开箱,店家送了一副鲜红色的脚踏板,我透,然后我和店家说颜色不合适呀,店家又给我发了一个。

image.png

就是上面两幅,黑的发来的是个坏的,左脚螺丝口对的有问题,装上骑也有异响。我忍不了等待了,自己花一百多买了一副。 然后就是把套,也是在上图,是一个海绵把套,骑了几公里后我发现手腕疼,赶紧换! 海绵把套还贼难拆,直接刀子剌掉。

考虑过上弯把和牛角把,像这样的:

image.png

但是发现,会影响折叠(特别是我的车头管是内折的)。所以最后选了一个大行的把套,感觉还是不错的。

image.png

关于折叠车,业界是标配不上脚撑子和挡泥板的,我装一下,我也不上!

为了安全,买了车前灯和尾灯还有铃铛,买了自行车挎包 买了骑行护目镜,头盔目前还没有买。


至此,我的自行车相关配件也购买完毕。

开始遇到一些问题:

  1. 骑行异响

骑的时候,不是牙盘响,认真听 是车前面的部分。后来联系了商家,确认不会是别的问题,我就往前把的各种关节加了WD-40,解决!

  1. 换挡不流畅

5挡以下,换挡来回跳,后来问了下店家,说后面有一个档位的微调螺丝,调整下就好了,我就在车库调调骑骑,弄得差不多算流畅吧。

另外还有一个温馨提示,我这个车是碟刹嘛,刹车动作如果比较多,骑完车一定不要手贱去摸那个刹车片。


可以开始畅快地骑行了,然后,我发现,公司不让把自行车折叠带上楼!!! 放楼下又怕刮蹭,毕竟这车架就小两千啦,还是心疼的。

再往后两天,我又换了工作,离家17公里多,啊~ 骑不动了,不骑了

吃灰开始。

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

作者 whrss
2025年11月27日 17:18

使用kratos这个框架有近一年了,最近了解了一下kratos关于程序优雅退出的具体实现。

这部分逻辑在app.go文件中,在main中,找到app.Run方法,点进入就可以了

它包含以下几个部分:

  1. App结构体:包含应用程序的配置选项和运行时状态。

  2. New函数:创建一个App实例。

  3. Run方法:启动应用程序。主要步骤包括:

  • 构建ServiceInstance注册实例

  • 启动Server

  • 注册实例到服务发现

  • 监听停止信号

  1. Stop方法:优雅停止应用程序。主要步骤包括:
  • 从服务发现中注销实例

  • 取消应用程序上下文

  • 停止Server

  1. buildInstance方法:构建用于服务发现注册的实例。

  2. NewContext和FromContext函数:给Context添加AppInfo,便于后续从Context获取。

核心的逻辑流程是:

  1. 创建App实例

  2. 在App.Run()里面启动Server,注册实例,监听信号

  3. 接收到停止信号后会调用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

}

主要步骤是:

  1. 获取已经保存的服务实例

  2. 如果配置了服务发现,则从服务发现中注销该实例

  3. 取消应用上下文来通知应用停止

在Run方法中,我们通过context.WithCancel创建的可取消的上下文Context,在这里通过调用cancel函数来取消该上下文,以通知应用停止。

取消上下文会导致在Run方法中启动的协程全部退出,从而优雅停止应用。

所以Stop方法比较简单,关键是利用了Context来控制应用生命周期。

我们可以注意到,在Run方法中,我们使用到了一个signal包下的Notify方法来对操作系统的关闭事件进行监听,这个是我们动作的核心,我把这部分单独整理在了另一篇文章中。

通过对操作系统事件的监听,我们就可以对一些必须完成的任务进行优雅地停止,如果有一些任务必须完成,我们可以在任务开始使用 wg := sync.WaitGroup{} 来对任务进行一个Add操作,当所有任务完成,监听到操作系统的关闭动作,我们需要使用wg.wait() 等待任务完成再进行退出。以实现一个优雅地启停。

os.signal golang 中的信号处理

作者 whrss
2025年11月27日 17:18

在程序进行重启等操作时,我们需要让程序完成一些重要的任务之后,优雅地退出,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源码解析

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

作者 whrss
2025年11月28日 08:48

最近我发现了一个非常有趣的国外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

这个命令会在浏览器中打开一个页面

image.png

如果你已经登录了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)

image.png

5. 本地连接

点击Get connection strings,你就可以得到连接数据库所需的账号名和密码,然后可以在本地的数据库连接软件中直接连接数据库了。

![](https://raw.githubusercontent.com/whrsss/pic-sync/master/img/202307121100351.png)
  1. 选择适合你的编程语言的连接串,这样你就可以在不同的程序中直接使用了。

    image.png

通过这些简单的步骤,你就可以轻松地使用PlanetScale来管理和部署你的MySQL应用了。快来体验一下吧!

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

作者 whrss
2025年11月26日 10:43

先前,我的 RSS 订阅功能过于简化,只提供了几个基本字段,而且不展示全文。简介后面,我添加了一个链接指向原文,如下所示:

image.png

过于简陋,无法直接在 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

}

最后效果确实十分理想,手机上的观看效果不输我的原始站点,很不错~

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

作者 whrss
2025年11月27日 23:44

运动数据使用GitHub Action自动更新

看着每天的运动数据,满满的,还感觉有点充实:tw-1f606: 积少成多嘛

开通体验了一下GPT Plus

太想体验下GPT4了,之前苦于充值门槛过高,上个月出了app充值后,我使用支付宝礼品卡充值体验了下,感觉还是不错的,但是个人使用确实还是按量更划算一点,但是充值门槛过高:tw-1f632:

买了一支录音笔,全天候录音,使用 whisper 录音转文字

之前全程吃瓜了刘能离婚案,后来受他种草,也买了索尼的tx660,全天候录音,之后再把录音源文件和转的文本保存起来,这样想检索也会很方便的。

终于注册了 V2EX

这个社区知道很久了,一直是游客登录,看到别人发的别人社区啊推荐啊很不错,我说我也得养养号:tw-1f61c:

GPT API续费问题曲线救国

续上面充值问题,最近充值门槛更高了,对网络要求也更高了,还动不动就给封号。那我怎么整,Api确实便宜又方便,而且不用管那么复杂的网络。这不刚注册了V2EX嘛,上去翻翻。

image.png

然后我使用了这个-> aiproxy ,咱这V站注册就开始实际生产力了哈哈哈。

感觉还可以,然后使用自建的客户端输入地址和key就可以用啦。

image.png

报名了PMP的培训班和考试

6月中下旬决定报名8月份的PMP考试,一方面是我现在处于软考之后的一个学习的空窗期,一个原因是因为朋友刘某(一个年轻的项目经理)拉我,最重要的一个原因是,我确实很需要管理协调方面的工作技能,而且我刚好到了PMP的报名门槛:tw-1f604:。

报名费是真滴贵呀~

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

作者 whrss
2025年11月28日 10:00

在我最近使用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  

}

源码的主要逻辑如下:

  1. 获取数据库实例并准备执行SQL语句。value是要操作的数据。

  2. 利用反射机制确定value的类型。

  3. 如果value是Slice或Array,并且没有定义冲突解决策略(”ON CONFLICT”),那么设置更新所有冲突字段的冲突解决策略,并执行插入操作。

  4. 如果value是一个Struct,那么会尝试解析这个结构体,然后遍历它的主键字段。如果主键字段是零值,则执行插入操作。

  5. 对于除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 会自动检测并回滚其中一个事务来解决死锁。频繁使用这种语句在高并发场景下可能会对性能造成影响,因为每次冲突都需要进行额外的更新操作,并且涉及到锁的管理。

所以,如果你的业务涉及复杂和并发的业务场景,可以尝试手动在应用层进行检测冲突,避免数据库的冲突和锁的竞争。

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

作者 whrss
2025年11月27日 17:18

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

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序列化,这个过程非常费性能。当请求过多时,就发生了阻塞,阻塞过多,内存占用过大,溢出,服务就会拒绝服务。此时,连接的管道就会强行断开。

在很多业务场景中,都会出现这种情况:当计算资源不足时,请求就会阻塞堆积,最后最先崩溃的总是内存。

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

作者 whrss
2025年11月28日 11:33

Clash 是一款开源的网络代理工具,可以帮助用户实现对网络流量的控制和管理。我使用了很久,但苦于每次访问国内外网络需要手动开关代理,于是我就问了下GPT, 还真就解决了。

如果你也需要设置 Clash 区分国内和国外流量,可以按照以下步骤进行操作:

1. 打开配置文件(windows在右下角):

image.png

image.png

使用 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)。

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

作者 whrss
2025年11月27日 23:44

最近,我接触了很多关于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 !

Interesting & Useful 的开源项目

作者 whrss
2025年11月25日 22:08

| 地址 | 描述 |

| ——————————————————- | ——————————————————————————————————————————————————- |

| 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 |

❌
❌