2026-08-24 博客:几个让你少踩坑的概念
今天聊的东西有点杂,但都指向同一件事:代码/数据在"边界"处最容易出错------整数到上限、循环停不下来、编码对不上、数据库连不上。一个个说。
1. CancellationToken:给"死循环重试"装一根刹车线
概念
你有没有写过这种代码:连数据库失败 → 重试 → 再失败 → 再重试,用 while(true) 转圈。平时没事,可一旦数据库永久坏了 (密码错、表没了),这个循环就永远停不下来 ,调用它的地方跟着永久卡死。
问题不在"重试",在于没有出口 。CancellationToken 就是给这个循环装一根「刹车线」------外面一喊停,循环立刻退。
类比
CancellationToken = 餐厅点餐后的「取消按钮」线路。它本身不做任何事,就是一个信号:
ThrowIfCancellationRequested()= 厨房每做一步前瞄一眼「取消按钮被按了吗?按了就收工」。Task.Delay(毫秒, token)= 等待出餐时也留意取消,一按就醒。
伪代码(改造前 → 改造后)
csharp
// 改造前:死循环,没有出口
void 保存数据()
{
while (true)
{
try { 写入数据库(); return; }
catch (异常) { Thread.Sleep(5000); } // 干等 5 秒,谁也打断不了
}
}
// 改造后:能被打断
void 保存数据(CancellationToken token = default) // ① 加一根刹车线
{
while (true)
{
token.ThrowIfCancellationRequested(); // ② 每轮先问:要我停吗?
try { 写入数据库(); return; }
catch (取消异常) { throw; } // ③ 取消要原样抛出去
catch (异常) { await Task.Delay(5000, token); } // ④ 等待也能被打断
}
}
语法详讲
| 语法点 | 为什么这么写 / 常见坑 |
|---|---|
CancellationToken token = default |
default 表示"不传也能用",不破坏其它调用方 |
token.ThrowIfCancellationRequested() |
被取消就抛 OperationCanceledException,是给循环开的出口 |
Task.Delay(5000, token) |
对比 Task.Delay(5000):前者等待期间可被取消,后者是"干睡" |
catch (OperationCanceledException) { throw; } 放在 catch (Exception) 前面 |
坑:catch(Exception) 会连取消异常一起吞掉。必须先单独 catch 取消异常并 rethrow,否则"取消"会被当成普通失败重试,永远停不了 |
源 vs 令牌(权限分离)
CancellationToken 其实有「一对」,别混:
| 能干什么 | 关键成员 | |
|---|---|---|
CancellationTokenSource(源 / 扳手) |
能「写」------主动取消 | .Cancel() |
CancellationToken(令牌 / 信号线) |
只能「读」 | .IsCancellationRequested |
.Token:从源上接一根信号线出来。- 为什么分两个:权限分离------取消权留在源手里(能按扳手的人),知情权(能不能读"是否取消")发出去给干活的人。干活的人只能"看灯有没有亮",不能自己拉闸。
- 联动 :
CreateLinkedTokenSource(令牌A, 令牌B)= 一个联动开关,同时监听两个令牌,任一取消就取消。常用于把"外部取消 + 内部取消"合并成一个。
2. int 溢出:为什么 2GB 用 int 存不下
概念
int 是 32 位有符号数,范围 -2,147,483,648 ~ 2,147,483,647(约 ±21 亿)。
而 2GB = 2,147,483,648 字节 = 正好比 int 的最大值大 1。
所以「2GB」这个数,int 存不下------差一个字节。
类比
int 像汽车里程表 ,最多显示 999,999,再走 1 公里就「回绕」回 000,000。数值一旦超上限,不是报错,而是悄悄变成负数或一个错的小数。
伪代码
csharp
// 危险:三个 int 相乘,结果还是 int
int 总字节数 = 长 * 宽 * 高;
// 假设 2000 * 2000 * 600 = 24 亿,超过 int 上限 21.4 亿
// 结果:总字节数 = -18 亿(回绕成负数!)
// 然后校验:
if (总字节数 > 2GB上限) { 拒绝; }
// 负数 > 2GB → false → 没拦住 → 用负数去分配内存 → 崩
// 安全:先把第一个数转成 long,整个乘法就在 long 里算
long 总字节数 = (long)长 * 宽 * 高;
// 24 亿装得下 → 校验能正确拦住
语法详讲
int乘法溢出是"静默"的:不会抛异常,直接回绕,所以特别难查。- 修法 :
(long)长 * 宽 * 高------ 把第一个操作数转成long,后面整条乘法链自动按long算。 long范围:约 ±9.2 × 10¹⁸,装 2GB 绰绰有余。- 通用原则:做"乘法 + 上限校验"时,先想清楚"结果最大能到多少",会不会超过参与运算的类型上限。
3. MySQL 认证插件:为什么连不上库
概念
MySQL 8.0 默认用 caching_sha2_password 认证(取代旧的 mysql_native_password)。两者在「非加密连接」下的表现完全不同:
| caching_sha2_password | mysql_native_password | |
|---|---|---|
| 非加密连接下 | 必须用服务器的 RSA 公钥加密密码 | SHA1 挑战应答,不需要公钥 |
| 关键参数 | AllowPublicKeyRetrieval=True(同意取公钥) |
无 |
类比
caching_sha2_password = 寄贵重快递必须上锁 。没有 TLS 押运车时,要用收货方的锁(RSA 公钥)锁密码;而这个锁默认不给你 (防中间人给假锁偷密码),必须显式 AllowPublicKeyRetrieval=True 才给。
常见报错链条
连接串 SslMode=Preferred(能加密就加密,不能就裸奔)
→ 现场 TLS 证书坏 → 降级成非加密
→ caching_sha2_password 要公钥 → 默认不给 → 认证失败
语法/配置详讲
- 两种修法 (殊途同归):
- 服务器端 :
ALTER USER ... mysql_native_password------ 只对这一台库生效,每台要手动改。 - 客户端 :连接串加
AllowPublicKeyRetrieval=True------ 随软件走,所有机器都生效。
- 服务器端 :
- 注意 :
mysql_native_password在 MySQL 8.4+ 已移除,长期看客户端修法更稳。
4. git cherry-pick vs merge:挑单个提交 vs 合整条分支
概念
| merge 整分支 | cherry-pick | |
|---|---|---|
| 带进什么 | 对方分支所有提交(含别人的) | 只挑指定的提交 |
| 结果 | 保留原提交,可能产生 merge 提交 | 创建新提交(新 hash),原提交不动 |
| 用途 | 合并整个功能线 | 只合「某个人」或「某几个」提交 |
类比
- merge = 把别人整个文件夹搬过来(包括你不想要的文件)。
- cherry-pick = 只从别人文件夹里抽几张纸抄过来。
一个容易踩的坑
git merge-base A B 只告诉你「A 和 B 的共同祖先 是谁」,不能 证明「B 是从 A 分出来的」。判断"谁从谁分出来"要看 first-parent 线:某分支第一个提交的父提交在不在另一分支的 first-parent 线上。
5. UTF-8 vs GBK:为什么日志中文变乱码
概念
- UTF-8(无 BOM):很多程序默认的写文件编码。
- GBK :中文 Windows 的默认系统编码。
- 关键:记事本/查看器遇到「无 BOM」文件时,只能靠猜 编码。中文 Windows 默认往 GBK 猜 → UTF-8 的中文被按 GBK 读 → 乱码。
类比
UTF-8 和 GBK 是两套"密码本"。文件用 UTF-8 写,查看器却拿 GBK 密码本去"翻译",翻出来的中文就是天书。
排查关键
- 乱码要「日志里有中文」才会显形------如果哪天日志第一次出现中文,乱码就冒出来了,容易误以为是"编码今天变了",其实编码一直没变。
- 修法:写文件时带 BOM (
new UTF8Encoding(true)),查看器看到 BOM 就知道是 UTF-8,不用猜。
6. 代码排查 4 条自检
排查"没报错但结果不对"、"偶发异常"这类问题时,按这 4 条过一遍:
- 「没报错但不正常」先查静默失败 ------
catch {}空吞异常、default else 兜底、漏项、不落日志。 - 改一个值先 grep 所有分发点 ------ 枚举/JSON 键/类型 → UI 下拉、序列化、过滤、复制、模板,别只改一处漏一片。
- 异步完成标志在
await成功后再置位 ------ 别在 await 之前就把"完成"标志设了。 catch Exception会吞掉取消异常 ------ 主动取消要先catch (OperationCanceledException)静默复位,否则取消永远不生效。
类比
这 4 条像开车前的检查清单:油箱(静默失败)、后视镜(分发点)、手刹(异步标志)、刹车灯(取消异常)------少查一项,路上就出事。
7. git 分支合并:把散在多分支的"某人的提交"汇到一起
概念
今天的主线工作其实是这个:一个人的改动散在好几个分支上,要汇到一个测试分支 先测,测过再合主线。核心两件事------只挑这个人的提交 、别把别人的改动带进来。
只挑某人的提交:cherry-pick + 按作者过滤
bash
# 1. 先列出「某人」在几个分支上、且还没进主线的提交
git log 分支A 分支B 分支C --author="某人" --not 主线 --no-merges
# 2. 按时间顺序逐个挑出来(会生成新提交、新 hash)
git cherry-pick <commit1> <commit2> <commit3>
为什么不能直接 merge 整个分支 :merge 会把分支上所有人的提交都带进来;只想带"某人"的,就得用 cherry-pick 一个个挑。
merge-base 只能证明"共同祖先",不能证明"谁从谁分出"
一个很容易踩的坑:看到两个分支的 merge-base 是某个提交,就以为"B 是从 A 分出来的"------错。
bash
git merge-base A B # 只返回"最近共同祖先",仅此而已
判断"B 是不是从 A 分出来的"要看 first-parent 线:B 的第一个提交的父提交,在不在 A 的 first-parent 线上。
bash
git rev-list --first-parent A | grep <B的第一个提交的父提交>
# 有 → B 从 A 分出;没有 → 各自从上游分出,只是都带了这个共同祖先
合并时的取舍:跳过文档、处理"编号撞车"
- 只合代码提交,跳过纯文档提交(尤其两边的文档编号撞车时)。
- 「代码 + 文档」混在一个提交里:保留代码改动,文档部分取主线版,别让撞车的编号混进来。
- 这类"文档编号撞车"(两边各自用了同一个编号、内容却不同)交给团队统一重排,不在测试分支上硬拼。
类比
- 多个分支像不同抽屉,你只想要自己放进去的那几样 → cherry-pick 是"从抽屉里挑出你要的",merge 是"整个抽屉搬走"。
- merge-base 是"两人共同的祖先照片",只能证明你们有关系,不能证明"谁是谁的爸爸";first-parent 线才是"户口本"。
8. 闭包捕获循环变量:lambda 里循环变量会"迟到"
概念
在 for 循环里写 lambda / 委托,如果直接捕获循环变量 i,等 lambda 真正执行时,i 已经变成循环结束后的值了,不是你写 lambda 那一刻的值。
类比
像在墙上贴便利贴:"记住第 3 个抽屉"------但你只写了"当前抽屉",没写死"3"。等你回头再看时,"当前抽屉"已经是最后一个了。
伪代码(错误 → 正确)
csharp
// 错误:直接捕获 i
for (int i = 0; i < 路径.Count; i++)
{
Dispatcher.BeginInvoke(() => 处理(路径, i)); // 执行时 i 已经是循环结束的值
}
// 正确:先用局部变量固定当前值
for (int i = 0; i < 路径.Count; i++)
{
var idx = i; // idx 是"当轮的快照"
Dispatcher.BeginInvoke(() => 处理(路径, idx)); // 每轮各自捕获自己的 idx
}
语法详讲
- C# 的
for循环变量只有一个,每次迭代复用同一个变量 → lambda 捕获的是"那个变量",不是"那一刻的值"。 - 修法:循环体内
var idx = i;新建一个局部变量 ,每轮迭代都有独立的idx,lambda 捕获各自的值。 - 注意:
foreach循环变量在 C# 5 之后每轮是独立的(没有这个坑),for才有。
9. 执行动作前,先检查前置条件
概念
"没报错但不正常"的一种常见根源:该查的前置条件没查,盲目执行,等底层拒绝才报错。
类比
开车前不检查油箱,踩油门车不动了才发现没油。应该"先看仪表盘,再点火"。
原则
- 执行一个动作前,先检查前置条件(状态是否满足、资源是否就绪、输入是否合法)。
- 满足才执行;不满足就阻断 + 友好提示,而不是盲目执行、等底层报错。
- 这和"判断两个东西会不会冲突,看有没有全局查找"是同一套思路:先查清楚,再动手。
10. 配置项驱动界面显隐:一个开关控制多个控件
概念
想「一个开关,控制界面上某几个按钮显不显示」,别写死注释,而是用一个配置项(bool) + 数据绑定------开关改配置项,界面自动跟着显隐。
类比
像电灯开关:拨一下开关(配置项),灯(按钮)亮/灭;把开关位置记下来(持久化),下次进屋还是上次的状态。
伪代码
csharp
// 配置类
bool 显示某按钮 { get; set; } = false; // 默认隐藏
// 界面:勾选框双向绑定配置项
勾选框.IsChecked = 双向绑定(显示某按钮)
// 界面:按钮显隐绑定配置项(bool → 可见/折叠)
按钮A.可见性 = bool转可见(显示某按钮)
按钮B.可见性 = bool转可见(显示某按钮)
// 视图模型:只读属性暴露给界面
bool 显示某按钮 => 配置.当前值.显示某按钮;
// 关闭设定弹窗后:通知"显示某按钮"变了 → 界面重新读、刷新显隐
语法/模式详讲
- 双向绑定(TwoWay):勾选框改 → 配置项变;反过来配置项变 → 勾选框跟着变。
- bool → 可见性 :用「布尔转可见性」转换器,
true=可见、false=折叠。 - 持久化:配置改动要写回配置文件(重启还记得)。常用「深拷贝 → 编辑 → 深拷贝回写 + 保存」。
- 刷新时机:弹窗关闭、配置保存后,手动发一个「属性变更」通知,让界面重新读配置项------否则界面不会自动知道配置变了。
11. 声明 vs 定义:贴标签 vs 造实物
概念
声明 和 **new(定义/实例化)**是两回事,别以为「写了变量名」就等于「有了对象」。
类比
声明 = 在墙上钉一块「此处放灭火器」的牌子;new = 真的把灭火器挂上去。只有牌子、没有灭火器,出事时伸手就是空。
伪代码
csharp
CancellationTokenSource 源; // 声明:贴了个类型标签,此刻是 null,没有实物
源 = new CancellationTokenSource(); // 定义:真的 new 出对象,塞进这个标签
语法详讲
- 声明 = 告诉编译器「有这个变量、是某某类型」,但还没造对象 (引用类型此刻是
null)。 new= 真正在堆上创建对象,赋给变量。- 常见坑:只声明没
new就.Cancel(),会直接空引用崩溃。