2025-07-26 09:14:00
最近有一次换笔记本的机会,因为之后需要频繁旅行,于是选择了14寸的笔记本作为主力机;考虑到平时开发经常需要占用大量内存,32G的内存不够用,需要64G。而现在轻薄本里提供64G选项的,主流的选择只有可以自己加内存的ThinkPad T14p,但是T14p的CPU和GPU性能都在轻薄本中中规中矩,且被11代酷睿折磨了5年的我,现在已经变成了十足的I黑😅。而AMD只有Max+系列的设备有搭载64G及以上内存的产品,且当前只有华硕幻X和HP ZBook Ultra G1a(又称战99Ultra)这两款。前者平板形态虽然非常帅,但是在非桌面场景下使用体验较差,所以最终选择了后者。主要配置如下:
配置 | 配置 |
---|---|
CPU | AMD Ryzen AI Max+ 395 16C32T Zen5 |
内存 | 64G LPDDR5 8000MT 4通道 |
硬盘 | SK Hynix PC801 2TB PCIe 4 |
显卡 | Radeon 8060S 20CU RDNA3.5 |
显示器 | 2880x1800 144Hz OLED带触屏 |
重量 | ~1.6kg |
电池 | 74Wh |
16核32线程的Zen5加20CU的RDNA 3.5居然能装在14寸的笔记本上,在这颗处理器真正面世之前简直是不可想象的。虽然用脚趾头想都知道这么小尺寸的笔记本不可能可以发挥这颗处理器的全部性能,但是,规模大了,提高多少功耗就能提高多少性能,而实测下来这台笔记本的功耗在14寸的笔记本中也不低,所以在所有同样的尺寸和功耗等级的笔记本中,这颗CPU的性能应该也是最强的。而四通道内存甚至在MSDT平台上花多少钱都体验不到。
由于价格原因,大多数人应该没有机会能用到这样的笔记本,网上也少有这款设备真实的使用体验的评测。到现在也算用了这个处理器的笔记本3周了,可以说说体验。
买这台笔记本就是为了性能。这台笔记本的性能确实很强。
插电的状态下CPU-Z可以跑760/12300左右。这段时间内CPU功耗最高80W,虽然不能一直持续,但是长时间跑仍然可以稳定70W。14寸笔记本能稳定70W的据我所知还是比较少的,再加上这个CPU恐怖的规模,基本可以确定这颗CPU的性能应该就是这个尺寸笔记本的顶尖了。
离电状态下CPU-Z的结果是326/6892,正常功耗在35W,长时间功耗在25W左右,这个功耗在离电状态下也比较正常,并且可以看出,CPU在低功耗下倾向于重点使用一个CCD,这也是控制功耗的一个做法。
打游戏的话,最近只玩了三角洲,大战场下2880*1800分辨率超高画质有100帧左右。
内存性能测试结果这里也放一下。考虑到是LPDDR5内存,延迟较高,但是读写性能确实体现出了4通道内存的水平。
影响笔记本续航的因素有很多,并且随着驱动的更新,结果也会变化,这里只说一下我这段时间的使用情况。在正常的主要使用微信和浏览器的办公场景下,CPU基本上在6-15W范围波动,整机功耗12-20W。由于电池是74Wh,所以整机续航在5小时左右。
这个续航表现只能说中规中矩。主要原因我认为还是空载功耗仍然太高。上面这个图里,虽然核心只占了1.41W,但是整个CPU居然消耗了7.75W,和Lunar Lake以及苹果M系列处理器的1W以下的外围功耗比起来,这个功耗实在是过于离谱了。
万万没想到,这台电脑的充电情况比我遇到过的所有设备都要复杂。
首先,这台电脑的充电器是C口的,没有问题。但是,这台电脑并不支持最常见的PD 65W充电! 插入PD 65W充电器后直接不充电,而不是像一些联想笔记本一样慢速充电。
研究后发现,这台设备的包装盒上明确写着仅支持100W以上的PD充电器。
但是具体测试下来,设备支持联想T34WD-40(京东)上的96W Type-C供电,但是系统会提示低功耗充电器,且CPU功耗被限制在了45W。在这个功耗限制下,CPU-Z全核最高频率2.8Ghz,跑分9500左右,比未限制状态下的12300低了22%,还是下降得比较严重的。并且,现在市面上,除了DELL有两款支持PD 140W给笔记本供电的显示器外,其他显示器都最高只支持90W。所以一线通,这个我本来已经习以为常的办公解决方案,现在也不得不放弃了,在工作时必须再插一个充电器,不过也还好,只是多一根充电器线的事。
设备自带的充电器是支持PD 140W的大板砖,除了重,没什么问题。
从上可以看到,其支持28V 5A也就是PD标准的140W的充电器。了解了一下,目前市面上真正在使用PD标准进行高功率充电器笔记本,似乎也就只有MBP一家了。没想到苹果的充电在手机上扭扭捏捏,好不容易搞个C口还和很多iPhone 15出现之前的老C口设备存在兼容问题,反而在笔记本上如何激进地拥抱标准。
可能是覆盖的产品太少,支持这个标准的充电器并不多,并且同样是声称支持MBP和这个标准的充电器,在这台电脑上仍然存在兼容性问题。
酷态科的15号充电器是140W充电器里价格最便宜的(179不带线),支持小米快充,也声称支持MBP,但是实际使用中并不兼容这台电脑,插入电脑后,系统充电图标显示一下,HP的软件弹出如下的提示并且系统稍微卡一下,然后马上断开,以此循环。为了验证是不是线的问题,我还快速购入了酷态科的支持240W的6A线,但是问题依旧。
没有办法,只能换了Anker的充电器。虽然Anker的产品相比起来都更贵,但是考虑到Anker牌子和销量更大,兼容性应该更好,于是花更高的价格(279)购入了Anker的支持140W的充电器。而这个充电器确实终于可以用了,但是仍然会有时候会弹出上面的提示并且系统会卡一下。合理怀疑这个问题应该是系统驱动的问题,可能是随着驱动更新解决,但现在也没什么拆塔办法。
这个充电器带一个屏幕,可以实时显示系统使用的功耗。在CPU-Z跑分、CPU功耗在70W左右时候,整机功耗为120W+,100W的PD确实不能发挥整台机器的所有能力。
搭载这块CPU的设备的宣传材料都是本地跑AI大模型。内存和显存共享芯片确实是一个大卖点。
可是,它并不是像苹果那样,完全动态地在运行时分配内存和显存,而是需要在BIOS里将固定量的内存分配给显存,且分配给显存的内存就不可以再作为系统的内存使用了。
如上图所示,我的64G版本,可以手动选择将512MB、4GB、8GB、16GB、32GB或48GB的内存分配给显存。我日常一般是分配8G,于是日常就只有55G的内存可用了。
从硬件来说,确实是融合内存:内存和显存共享一块芯片。四通道内存也比常见的双通道内存带宽更高,更适合显卡使用。但是这个具体实现上离最理想的由硬件自己决定分配内存给显存还是有差距。这个实现更像是传统的核显和独显分别使用一块内存的模式,这个方案只是提供了手动调整内存分配的方式,似乎甚至还比不上传统的核显直接使用内存作为显存的方案。
这台设备的模具及外围配置同样一般。一句话总结:CPU以外就是个6000块左右设备的水平。
厚度和重量:最厚处18.5mm,重量1.6kg左右,和MBP一个水平,和轻薄不沾边(考虑到我之前的笔记本是980g的Yoga Carbon,这落差就更大了)
屏幕:一块2880x1800 144Hz的OLED屏幕,但是最高亮度只有400nit,且是镜面屏、触控层存在网格纹,甚至似乎还是PWM调光。
触控板:大小、滑动和按键手感还可以,但是并非压感触控
键盘:中规中矩的笔记本键盘,和ThinkPad等不可比。
另外还有一些小问题,例如
综上,虽然这台笔记本性能很强,但是我还是不推荐这台笔记本给绝大多数人。
这种小众机器看着非常诱人,但是价格昂贵,和外围设备兼容性存疑,并且遇到问题(例如充电器)都无法在网上找到解决方案,厂商的后续支持(例如驱动更新等)也一定会比较有限。只有像我这样什么都要的且愿意折腾的,才可以建议试试这台机器😊。
这台机器在史无前例地在14寸的机器下提供了16C32T CPU+64G/128G四通道内存的配置。虽然表现很亮眼,但是最终市场上设备种类少,价格贵,销量也低,后续AMD还会不会出下一代呢?
2025-03-08 08:47:00
最近在一个项目中,我遇到了一个使用Node.js编写的请求转发服务的性能瓶颈问题。这个服务的主要工作看似非常简单:获取用户的请求,将请求体(body)转发到后端的服务器,然后将服务器的响应原样返回给客户端。
然而,在进行压力测试时,我们发现当并发请求达到约2000时,系统表现出了明显的性能问题:
大家都说Node.js的IO性能并不算差。这个现象引发了我的思考: 是什么限制了Node.js在这种场景下的性能表现? 一个看似简单的请求转发工作,为何无法充分利用多核资源?
带着这些疑问,我决定对Node.js代理服务的基准性能进行一次深入研究。我想了解在没有任何特殊优化的情况下,一个标准的Express服务究竟能够处理多少并发请求。这将帮助我确定问题是否出在Node.js本身的并发处理能力上,并在之后遇到类似性能问题或技术选项的时候,对Node本身所能达到的极限能力有个心理预期。
为了进行这项研究,我采取了以下步骤:
编写两个简单的项目:
使用wrk作为性能测试工具,这是一个常用的HTTP基准测试工具,能够产生大量并发连接来测试服务器性能
在相同硬件条件下,测试不同并发级别下的性能表现,包括:
这个项目是一个简单的Express服务器,它接收POST请求,并返回一个模拟的AI响应。
为了模拟真实响应,这个服务器返回结果前可能会延迟一段时间。我同样会测试延迟不同的时间会对代理服务的性能表现的影响。
代理服务同样使用Express实现,它的核心功能是:
这个服务保持了最小化的实现,没有添加额外的错误处理、负载均衡或缓存等优化措施,以便我能够测试Node.js的基准性能。
测试运行于WSL2,CPU为5900X 12C24T @ 4.5 Ghz,Node版本22.14.0。
使用6个线程和不同的连接数,超时时间设置为5s,使用wrk对两个服务分别进行压力测试。其中,运行在5001
端口的是模拟后端服务,5000
是代理服务。
Server | Connections | Requests/sec | Avg Latency | Max Latency | Total Requests | Timeouts | Timeout % | Total Errors | Error % |
---|---|---|---|---|---|---|---|---|---|
5001 | 50 | 9776.19 | 6.03ms | 291.00ms | 97839 | 0 | 0.00% | 0 | 0.00% |
5001 | 100 | 9294.12 | 12.84ms | 537.33ms | 93006 | 0 | 0.00% | 0 | 0.00% |
5001 | 150 | 9322.45 | 31.57ms | 1.31s | 93276 | 0 | 0.00% | 0 | 0.00% |
5001 | 200 | 8688.89 | 63.29ms | 2.14s | 86951 | 0 | 0.00% | 0 | 0.00% |
5001 | 500 | 8769.33 | 163.54ms | 4.99s | 87741 | 121 | 0.14% | 121 | 0.14% |
5001 | 1000 | 8200.58 | 98.60ms | 4.96s | 82284 | 67 | 0.08% | 67 | 0.08% |
5001 | 2000 | 8808.86 | 102.43ms | 4.95s | 88480 | 54 | 0.06% | 54 | 0.06% |
5001 | 5000 | 7769.21 | 248.05ms | 314.09ms | 78333 | 25 | 0.03% | 1134 | 1.45% |
5001 | 10000 | 7531.77 | 453.61ms | 592.39ms | 76076 | 13 | 0.02% | 6176 | 8.12% |
5001 | 20000 | 6601.98 | 414.19ms | 582.31ms | 66423 | 15 | 0.02% | 15917 | 23.96% |
5000 | 50 | 2673.49 | 74.76ms | 2.34s | 26763 | 0 | 0.00% | 0 | 0.00% |
5000 | 100 | 2884.08 | 40.05ms | 909.96ms | 28860 | 0 | 0.00% | 0 | 0.00% |
5000 | 150 | 2729.84 | 94.20ms | 2.16s | 27322 | 0 | 0.00% | 0 | 0.00% |
5000 | 200 | 2586.36 | 176.31ms | 3.61s | 25887 | 0 | 0.00% | 0 | 0.00% |
5000 | 500 | 2375.25 | 192.97ms | 4.96s | 23767 | 70 | 0.29% | 70 | 0.29% |
5000 | 1000 | 2454.10 | 139.51ms | 5.00s | 24629 | 90 | 0.37% | 90 | 0.37% |
5000 | 2000 | 2449.30 | 326.00ms | 4.85s | 24597 | 33 | 0.13% | 33 | 0.13% |
5000 | 5000 | 1786.15 | 326.11ms | 5.00s | 18038 | 476 | 2.64% | 1768 | 9.80% |
5000 | 10000 | 2016.64 | 78.55ms | 3.78s | 20313 | 10 | 0.05% | 6306 | 31.04% |
5000 | 20000 | 1398.34 | 3.40ms | 639.14ms | 14072 | 0 | 0.00% | 16275 | 115.66% |
数据比较多,值得关注的结论如下:
对于后端服务:
对于代理服务:
在所有实验中,我还记录了CPU各个核心的使用率,下面是测试后端、2000个连接数时其中一秒的CPU使用率,可以看到,只有一个核心(2)很忙,其他核心没有被充分利用。其他所有数据都具有类似的情况。
10:51:38 AM CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
10:51:39 AM all 5.93 0.00 1.52 0.00 0.00 4.97 0.00 0.00 0.00 87.58
10:51:39 AM 0 1.75 0.00 0.88 0.00 0.00 11.40 0.00 0.00 0.00 85.96
10:51:39 AM 1 0.00 0.00 1.00 0.00 0.00 1.00 0.00 0.00 0.00 98.00
10:51:39 AM 2 84.00 0.00 6.00 0.00 0.00 0.00 0.00 0.00 0.00 10.00
10:51:39 AM 3 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
10:51:39 AM 4 11.00 0.00 3.00 0.00 0.00 0.00 0.00 0.00 0.00 86.00
10:51:39 AM 5 18.18 0.00 3.03 0.00 0.00 0.00 0.00 0.00 0.00 78.79
10:51:39 AM 6 1.00 0.00 1.00 0.00 0.00 0.00 0.00 0.00 0.00 98.00
10:51:39 AM 7 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
10:51:39 AM 8 4.00 0.00 1.00 0.00 0.00 0.00 0.00 0.00 0.00 95.00
10:51:39 AM 9 2.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 98.00
10:51:39 AM 10 2.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 98.00
10:51:39 AM 11 0.00 0.00 1.98 0.00 0.00 0.00 0.00 0.00 0.00 98.02
10:51:39 AM 12 3.96 0.00 4.95 0.00 0.00 0.00 0.00 0.00 0.00 91.09
10:51:39 AM 13 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
10:51:39 AM 14 4.50 0.00 3.60 0.00 0.00 10.81 0.00 0.00 0.00 81.08
10:51:39 AM 15 0.00 0.00 0.00 0.00 0.00 2.04 0.00 0.00 0.00 97.96
10:51:39 AM 16 2.65 0.00 0.88 0.00 0.00 15.93 0.00 0.00 0.00 80.53
10:51:39 AM 17 0.92 0.00 1.83 0.00 0.00 12.84 0.00 0.00 0.00 84.40
10:51:39 AM 18 2.44 0.00 4.07 0.00 0.00 19.51 0.00 0.00 0.00 73.98
10:51:39 AM 19 0.00 0.00 2.75 0.00 0.00 13.76 0.00 0.00 0.00 83.49
10:51:39 AM 20 4.63 0.00 0.00 0.00 0.00 11.11 0.00 0.00 0.00 84.26
10:51:39 AM 21 2.73 0.00 0.00 0.00 0.00 11.82 0.00 0.00 0.00 85.45
10:51:39 AM 22 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
10:51:39 AM 23 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
在上述实验中,后端服务收到请求就直接返回。但是实际上后端服务可能需要一定时间处理。打印日志也是Node服务常见的实践。这两个场景到底对资源消耗有多大呢?
为了更直观地对比不同场景下的性能表现,多设计两个场景
console.log
)我整理了以下对比表格,重点关注平均延迟(Avg Latency)、超时率(Timeout %)和错误率(Error %)这三个关键指标:
连接数 | 服务器 | 直接返回-延迟 | 延迟-延迟 | 输出日志-延迟 | 直接返回-超时率 | 延迟-超时率 | 输出日志-超时率 | 直接返回-错误率 | 延迟-错误率 | 输出日志-错误率 |
---|---|---|---|---|---|---|---|---|---|---|
50 | 后端 | 6.03ms | 503.92ms | 9.17ms | 0.00% | 0.00% | 0.00% | 0.00% | 0.00% | 0.00% |
500 | 后端 | 163.54ms | 506.11ms | 132.43ms | 0.14% | 0.00% | 0.13% | 0.14% | 0.00% | 0.13% |
2000 | 后端 | 102.43ms | 533.43ms | 105.53ms | 0.06% | 0.00% | 0.08% | 0.06% | 0.00% | 0.15% |
5000 | 后端 | 248.05ms | 529.92ms | 358.23ms | 0.03% | 0.00% | 0.05% | 1.45% | 7.73% | 2.23% |
10000 | 后端 | 453.61ms | 607.15ms | 641.06ms | 0.02% | 0.00% | 0.00% | 8.12% | 9.89% | 12.64% |
20000 | 后端 | 414.19ms | 610.10ms | 375.80ms | 0.02% | 0.00% | 0.00% | 23.96% | 30.43% | 35.20% |
50 | 代理 | 74.76ms | 519.05ms | 54.27ms | 0.00% | 0.00% | 0.00% | 0.00% | 0.00% | 0.00% |
500 | 代理 | 192.97ms | 717.28ms | 208.93ms | 0.29% | 0.00% | 0.29% | 0.29% | 0.00% | 0.29% |
2000 | 代理 | 326.00ms | 726.51ms | 417.52ms | 0.13% | 2.03% | 0.21% | 0.13% | 2.03% | 0.21% |
5000 | 代理 | 326.11ms | 892.77ms | 434.87ms | 2.64% | 1.35% | 2.87% | 9.80% | 13.92% | 10.33% |
10000 | 代理 | 78.55ms | 788.52ms | 89.68ms | 0.05% | 2.90% | 0.01% | 31.04% | 36.76% | 36.39% |
20000 | 代理 | 3.40ms | 777.70ms | 94.90ms | 0.00% | 0.00% | 0.00% | 115.66% | 232.21% | 107.78% |
从这个对比表格中,我们可以得出几个重要观察:
延迟500ms的影响:当后端服务增加500ms延迟后,整体延迟有增加,连接数越多,平均延迟增加越少,但是增加的延迟仍然会使得错误率增加
超时情况分析:在大多数连接数下,超时率都相对较低;但在代理服务的中等连接数(2000-5000)场景下,超时率明显上升,特别是在延迟返回的测试中,显示出代理服务在处理较长延迟请求时的瓶颈
日志输出的影响:与直接返回相比,增加日志输出确实会增加延迟,但影响不是特别显著。在低并发情况下(50-500连接),日志对后端服务的影响较小;但在高并发时(10000+连接),日志输出会明显增加系统负担
错误率增长点:无论哪种场景,代理服务的错误率普遍高于后端服务,且在5000连接数左右开始出现明显的错误率上升
极端高并发下的异常:在20000连接的极端情况下,所有配置都表现出较高的错误率,但代理服务的延迟反而下降,这可能是因为大量请求被直接拒绝,导致成功请求的平均延迟降低
为了对比,我又准备了用Go使用标准库net/http
编写的相同功能的两个程序,并在后端500ms延迟、不打log的情况下,测出go的成绩如下(未列出的连接数并未发生错误):
Server | Connections | Requests/sec | Avg Latency | Max Latency | Total Requests | Timeouts | Timeout % | Total Errors | Error % |
---|---|---|---|---|---|---|---|---|---|
5001 | 5000 | 7310.92 | 501.39ms | 516.91ms | 73728 | 0 | 0.00% | 902 | 1.22% |
5001 | 10000 | 7060.75 | 501.72ms | 517.20ms | 71154 | 0 | 0.00% | 5900 | 8.29% |
5001 | 20000 | 6493.01 | 501.33ms | 512.36ms | 65536 | 0 | 0.00% | 15902 | 24.26% |
5000 | 5000 | 5910.59 | 636.54ms | 3.46s | 59611 | 0 | 0.00% | 903 | 1.51% |
5000 | 10000 | 5441.54 | 657.59ms | 1.25s | 54953 | 0 | 0.00% | 5900 | 10.74% |
5000 | 20000 | 4857.47 | 663.12ms | 2.11s | 48912 | 0 | 0.00% | 15902 | 32.51% |
二者的差距还是在预期的,go从来没有发生过超时,整体延迟、错误率数据也比node好很多。如在5000个连接下,代理程序出现1.51%的错误,node版本出现9.08%的错误,差距在6倍左右。但是需要注意的是,go可以利用全部CPU核心,而node的js线程只能利用一个核心,如果启动多个node并进行负载均衡,最终结果不一定有很大的差别。
通过这些简单数据,我们发现
要想解决这个问题,在代码中做出一定的优化,例如减少日志打印、简化Node中的逻辑等,也会有一定的效果。如果优化代码的效果不佳,唯一的办法是运行多个node进程,可以考虑的方法主要是启动多个node服务并增加负载均衡,或者使用node cluster让node可以启动多个worker process。
本实验中的代码和结果均在 https://github.com/ddadaal/node-express-concurrency-baseline-test 中可用。
整个测试项目、测试脚本甚至本篇文章基本都是直接使用Copilot + Claude 3.7 Sonnet模型生成的,不得不说,让AI生成大框架、自己再来完善细节的做法确实能提高不少效率。这种实验的大多数工作实际上是框架代码,代码本身逻辑简单,但是需要使用大量API、编写繁琐的测试逻辑、数据分析以及做表,手写非常耗费精力,让AI来做这些繁琐的工作实在是再合适不过了。写文章也可以让AI帮忙做,我之前写篇文章至少需要花一整天,这一次居然一个上午就搞定了。
哦,上面这一句话不是AI写的😀
2025-02-14 23:54:00
去年我给我的文章增加了AI文章总结功能。在介绍此功能的文章,我提到当时这个功能是通过Azure AI Language Service Text Summarization的功能实现的。
当时我已经发现,这个功能在英文文章上效果还行,但是在中文文章上就基本不可用。
比如,上一篇文章2024年总结的总结结果是:
本文讲述了作者在毕业后的第一年,通过深入体验现有生活,旅游,搬家,工作和生活。
这是人话吗?且不说用的英文逗号,前两个分句看着还行,后面就变成关键词的叠加,完全没有概括意思。
一年过去了,DeepSeek全球爆火,而我又想起了这个问题,又重新尝试了用原有的方案生成概括,结果差不多,仍然不可用。
拜托,随便一个大模型都应该比这个好吧!
去研究了一下此功能的文档,发现文档上完全没有这个功能的任何细节信息,没说用的什么模型、什么Prompt,也不让用户自己定义模型。一年过去了,功能效果丝毫没有改进,似乎像是被放弃了。说得通,毕竟是微软。
实话说,我很少直接和大模型聊天。我使用AI基本只有让Copilot回答编程问题以及生成代码,在编程场景之外我基本完全不用AI,所以也一直不知道怎么把AI应用到我自己的工作和生活流中。我的工作也和AI毫无关系,即使公司策略是All in AI,但是我组仍然和AI似乎扯不上边。
而此时,需求终于来了,为何不让大模型帮我总结文章?
Azure AI是一个微软做的Model as a service平台,可以直接在上面部署、使用、微调模型,不需要自己管理基础设施。DeepSeek R1模型发布后没几天,Azure AI可以直接就支持了部署(公告),甚至没有价格表,意思是:它是免费的?
我立刻去Azure AI上注册了Project,部署了DeepSeek R1。
用Azure AI部署模型非常方便:
Prompt我随便想了一个:
Summarize the article in the next message in language ${languageCode} in 100 words. Return the result in plain text format, without any other information.
其中包含三个消息:
${languageCode}
,这是文章的语言参数(cn
, en
),这样文章的总结和文章本身的语言一致另外,R1模型总是会返回思考过程,思考过程用<think>
标签包裹,所以还需要在结果中把<think>
中去除。
然后我立刻把2024年总结扔给AI,很快就拿到了结果:
作者回顾毕业一年后生活,探讨如何在最佳年龄充分体验当下。通过密集旅行与亲友刷新地图边界,感受陪伴的意义;开启独居生活后在郊区保租房打造个人空间,享受烹饪与自由。工作方面身处技术运维"小而美"团队,虽遇职业发展隐忧但珍惜当前性价比高的稳定状态。面对年龄增长带来的体能下降与生活抉择困惑,既珍视现有平衡又焦虑是否该打破舒适区。最终秉持认同感驱动原则,既保持现状又在游戏启发中思考:是继续深耕现有生活,还是用黄金年龄冒险转型,答案留待明年揭晓。
哇!这效果真不错啊,句子比我自己写得工整多了!
于是,我将脚本应用到博客中的所有文章,基本上所有文章都获得了极好的效果。这里给出几个例子:
文章 | 原总结 | 现总结 |
---|---|---|
在西雅图,给生活换个环境 | 本文描述了作者在参加微软的Aspire Start Strong+活动时,感受到了全新的生活环境和社交元素。 | 作者参与微软组织的Aspire Start Strong+活动,赴美体验全新生活节奏以缓解职场单调感。西雅图市区高楼林立却人流稀少,郊区以分散式城市结构为主,无车出行困难。活动中高强度社交贯穿全程,跨文化交流时语言障碍明显,英语听说能力面临实战考验。与全球同龄校招生互动后,作者反思环境对人际关系的影响:校园阶段共同目标易建立联结,职场后各自生活轨迹差异导致社交需求转变。短暂六天打破常规,接触多元文化,更深刻思考个人追求与环境的关系。 |
从调库到翻源代码:给wakapi增加SQL Server支持 | 本文作者在上一篇文章中提到了如何为博客增加点击量监测,并部署到Azure。 后来,同学推荐了wakapi项目,重新实现了wakatime的后端API。 作者发现wakapi并没有原生支持SQL Server,但使用了gorm作为数据库访问框架。 作者通过重用Dialector的逻辑,为关键词加上引号,把同一个go类型在不同的数据库中映射为不同的列类型,以及使用merge into 语句来模拟upsert的行为。 他认为go非常explicit,虽然他不喜欢它,但它非常简单。 解决这些问题后,PR顺利合并进了主分支。 |
作者在尝试将Wakapi项目迁移至SQL Server时,遇到一系列数据库适配问题。包括SQL语法差异、ORM框架配置、外键约束冲突及GORM库的Upsert功能缺陷。通过修改原生SQL语句、动态调整时间字段类型映射、重构外键关系、手动处理唯一索引冲突,最终解决兼容性问题并成功合并代码。此次实践深入了解了SQL Server特性与GORM内部机制,验证了通过实际项目攻坚学习技术的有效性。 |
A Kotlin DI Framework in 50 Lines and Thoughts on Kotlin | The document discusses the challenges and benefits of using a Dependency Injection (DI) framework in a Java project. It highlights the two main options for dependency management: introducing a full-blown DI framework or using traditional object instantiation or simple factory pattern, which can be time-consuming and cumbersome. The author uses the example of a simple Java project where the interface and implementation class pattern was used to decouple the interface and implementation, but it also introduced complexity. The document suggests using delegation and classpath scan capability to achieve minimal dependencies and extra code, and provides a code example to help understand the process. | The article discusses choosing dependency injection (DI) for small projects, comparing full DI frameworks (verbose) versus factory patterns (clumsy). The author developed a lightweight DI solution using Kotlin’s delegation and classgraph for scanning. Annotations (@Service, @ServiceImpl) mark interfaces and implementations, while a di() function delegates dependency resolution, enabling singleton injection with minimal code. Benefits include simplicity, circular dependency support, and dynamic resolution, though limitations include no init block usage and lack of advanced features. The author praises Kotlin’s modern features (null checks, lambdas) and how diverse programming paradigms expand problem-solving approaches, emphasizing tools’ influence on design thinking. |
本来这篇文章到这里就该结束了,可是写到第一段的时候,突然想到:
随便一个大模型都应该比这个好吧!
那……我本地跑一个呢?
我的台式机是3070,一搜得知这个显卡最大可以装DeepSeek R1 8B版本。于是赶紧打开许久没有打开的Ollama,拉下deepseek-r1:8b
ollama pull deepseek-r1:8b
Windows版本的Ollama会在本地11434
端口启动Ollama的API(定义),而由于我们是用的JS,Ollama单独出了ollama-js SDK,用起来更简单。
我直接pnpm i -D ollama
,学习了ollama的SDK的使用,直接把给完整版R1用的Prompt发给R1 8B,拿到以下结果:
这篇文章通过作者的个人经历和思考,探讨了在“最好的年龄”该如何度过这一主题。作者从20多岁进入巅峰年龄开始,经历了身体素质逐渐下降、工作环境变化以及面对各种选择的内心挣扎。\n\n### 主要内容总结:\n1. 身体与年龄的关系:作者承认随着年龄增长,身体素质逐渐不如从前,尤其是到了“下半场”,这可能对日常生活和工作产生影响。\n \n2. 工作环境与不确定性:虽然选择了一家外企,因为其稳定性和良好的发展机会,但半年后发现公司结构频繁调整,高层变动较大,这让他对未来充满担忧。\n\n3. 面对选择的内心挣扎:\n - 职业与生活的平衡:作者更倾向于保持现状,尤其是对稳定工作的认可和对外资企业组织结构和晋升路线的信任。\n - 物质与生活的影响:虽然对大城市有向往,但也清楚地认识到当前平淡的生活状态的珍贵,不愿为了追求新潮而放弃现有的优秀工作。\n\n4. 游戏中的价值观:通过《沙石镇时光》这款游戏,作者找到了认同感和热爱一件有意义的事的精神状态,这成为他生活中重要的支撑力量。\n\n### 总结:\n文章表达了作者在成熟与不稳定之间的平衡问题上缺乏明确答案,内心对现状的满意与对变化的恐惧之间的矛盾。尽管没有给出明确的选择建议,但通过个人经历和情感描写,揭示了在最佳年龄如何度过这一哲学性问题,鼓励读者反思自己的生活态度和价值观选择。\n\n文章语言流畅,情感真挚,通过对工作、生活和游戏的多角度描述,展现了作者内心的复杂性和对未来的不确定性。
嗯?这个模型怎么不听话?这个Prompt提到的三点(语言、字数、格式),字数和格式的要求根本没有满足!
Summarize the article in the next message in language ${languageCode} in 100 words. Return the result in plain text format, without any other information.
我试了很多次prompt,仍然没找到什么方法能够让它同时满足这三个需求。同一个提示词,有时候能生成不含markdown的文本,有时候生成又包含;文字字数的限制也是不一定生效。试了多次也没获得好的结果。
除了DeepSeek R1 8B,同时还试了llamafamily/llama3-chinese-8b-instruct,而这个模型的效果就好一些,但是生成多了也会出现不听话等问题。
本文讲述了一名25岁男青年在他的第二十年代度过的时间,他在这段时期里,选择了深入体验现有的生活,在旅游和工作方面都有所变化。他认为这个阶段是他的成长期,是他开始独立生活、选择自己喜欢的事物,并且接受不确定性的阶段。在文章中,他对未来充满了想法和担忧,但最终还是无法预测。
小模型的效果确实和大模型没法比,而不同小模型的精调不一致,效果也差别很大。
推理过程中GPU计算量不大,主要是占了很多的显存。看来接下来换个16G显存的显卡,应该就可以跑更高级的模型了。
这次体验让我认识到一点,用LLM写功能的流程和传统的软件工程完全不同:
在传统软件中,业务逻辑总是精确地通过代码表示。不管需求多么复杂,这些需求总会在代码中出现。
而用LLM做的功能,不管写什么需求、是什么领域,写出来的代码是基本上都是一样的,代码本身只是个大模型API Caller,实际上的业务逻辑包含在大模型里,二者的接口是提示词。
如何编写提示词完全就是一个玄学,完全和精确、科学完全不沾边。不同的提示词就得出完全不一样的结果,甚至同一个Prompt得到的结果都不一定相同。
这种不确定性让我感觉有点不安。传统的软件即使再复杂,如果模块划分合理、测试充分,起码行为是可预测的,也总做或多或少的维护。而用大模型实现的功能,世界上没有人能直到它是怎么运行的,下次能不能用、有没有可能出什么问题,完全靠天决定。
不过AI确实解决了很多之前想都不敢想让机器解决的问题,很多问题也不需要那么精确。希望以后能找到更多大模型适用的使用场景。
2024-12-31 19:45:00
毕业后的第一年,没有物质压力,有大把时间可以自由支配。这最好的年龄该怎样度过? 我今年的答案:深入体验现有生活。
随着之前的朋友毕业、上班,有了更多时间,以及个人工作生活的稳定,今年的旅行数量可能大于之前26年来的总和,三个国家,国内十余个城市和景点。根据航旅纵横的数据,只算飞机,今年的飞行里程甚至超越了99.13%的用户。要是有规划地选择航司,现在应该能飞个银卡吧。
我不是一个非常喜欢旅游的人,如果只有一个人,外出旅游不会成为我的首要选择。正因为有了朋友和家人一起,我才能有机会和动力去打卡这些可能之前都没有听说过的地方。比旅游本身更重要的,是和朋友和家人在一起的这段经历。很感谢你们愿意花时间陪我去做我一个人不会去做的事情。
和大多数人一样,上大学前和家人合住,上大学后和舍友合住,而在今年8月,随着合住的大学同学搬走,我终于租下了一个一室一厅的公寓,第一次开始独居生活。
我的“第一套房子”是一个公司附近的保租房公寓。好处很明显:离公司比较近(电动车10分钟),楼体和装修新,空间勉强够用,各个功能区分割比较现代和合理(甚至是三分离的卫生间),且也没有遇到太多质量问题。
但更重要的是,没有其他选择了!公司地处偏远,附近几乎全是老破小楼梯房,电梯房本来就寥寥无几,而离地铁和公司近的房子只有几栋10年前的动迁房,而实地看房的第一印象就是廉价的装修:本来就只是为了出租而进行的装修,在10年后更加显现出岁月的痕迹,租金却也没比保租房公寓便宜多少。
于是也不用犹豫了,很快定下合同,还没入住的时候我就迫不及待地购入了升降桌,在家人的帮助下,将这个可能会是待的时间最多的地方好好打造了一番,还正好蹭上手机号所在的运营商的活动,拉上了18块一个月500M的宽带,开始美美地独享这个小窝。
由于公寓是住宅标准,所以有正常的厨房和煤气。一个人住,也不用拘泥于吃饭的形式:想吃火锅却懒得买电火锅,也可以直接凑在灶台前吃。
为了解锁更多菜谱,我还买入了一个电压力锅,平时可以当快速的电饭锅,当想做需要长时间炖煮的菜品,如烧牛腩、焗鸡、梅菜烧肉时,因为锅可以产生压力,所以这些菜品都可以用一小时左右的时间完成,操作也非常方便。
这次搬家,算是达成了独立生活的第一个小目标。接下来又追求什么呢?
公司有非常宽松的在家工作的政策,越来越老油条的我去公司的次数越来越少。而只要进城,不管具体去哪儿,甚至都得先花40分钟才能到城市边缘。即使不进城,当我在外完成每天8000步,差不多6-7km的散步任务时,路线上只有老破小、厂房和大货车。
突然有一天想通了:既然不用每天通勤,为何不进城呢?公寓的单位租金已经100块/平米,接近中外环的楼梯房,为什么不再加点钱,去体验真正的大城市生活?
下列打油诗来自这个知乎回答,我觉得不仅是设备,任何选择都适用此原理。
明年的7、8月又将迎来搬家,我已经开始期待下一个房子,以及真正的住在大城市的体验了。
今年是第一个完整工作的年份,也第一次享受了公司组织的福利和活动:
我对公司和小组的工作也有更深入的理解。我发现,我很幸运在一个“小而美”的组:
再加上宽松和假期和在家工作政策,听起来是一个性价比高、适合享受生活的工作。
当然,有时候也会有一些担忧:
所谓相对稳定也是当年选择公司的一个重要原因。
可是,
外企总是被公认为稳定。可是,仅仅半年就能发生这么多变动,谁知道明天又会有什么惊喜?
回答文章开头,最好的年龄该如何度过?,为什么会有这么一个问题?
今年有一次羽毛球局,和一个偶尔组的同事大姐姐混双打男双,打完后她对我说的第一句话是:“感觉你的身体素质不如之前”。
混双打男双局里,混双方男队员的能力能很大程度影响表现,而能力主要又分技术和身体素质。我没有做技术和身体素质的专门训练,但我自认为和之前也没有什么区别。排除了这些因素,那为什么身体素质下降了?只能归结于年龄大了一岁。20多岁是人类的巅峰年龄,而我已经处于20多岁的下半场,身体素质已经开始走下坡路,时间已经不站在我的这边。
工作之后,生活逐渐稳定,可内心却充满了各种患得患失:
该维持现有状态,还是应该有所改变?
今年我也面临过一些选择,进行了或者仍然在进行很多心理斗争。在这些选择和心理斗争的过程中,心里没有决定、身体没有行动二者互为因果,最终保持不变成为了今年对这个问题的答案。
今年我游戏时间最长的游戏是《沙石镇时光》。在这款游戏里,玩家扮演一个新的沙石镇工坊主,去到一个没落的城镇开始职业生涯,认识了大量的镇民、经历了各种各样的事,最终让这个没落的城镇重现辉煌。
这游戏剧情平平无奇甚至有点幼稚,好在内容丰富,设定也比较接地气。玩完后,给我印象最深刻的反而是主线的精神状态:认同、热爱一件有意义的事,和一个团队一起去追求它。
回想自本科以来,所有我真心投入了大量时间精力的事,都是因为我认可它。如果我不认可一件事,那么无论这件事能给我提供多大的物质回报,我都没有办法说服自己去做它。即使不得不去做,最终都放弃了。
我总是尝试做一个理性的人,可是这一点就是最大的不理性。很多选择,即使理性告诉我它就是最优解,即使我知道我的想法是有局限性的,可是我还是无法接受。
明年还会出现选择,且会更加急迫。最好的年龄正在慢慢消失,明年我会做出什么选择呢?是充分珍惜当前难得的生活工作状态,在已经拥有的基础上尝试改良?还是打破平衡,用最好的年龄去冒险?那就只能明年才知道了。
2024-06-11 23:13:00
上班之后的生活千篇一律:起床,上班,去食堂吃午饭,去游戏室睡觉,上班,下班,看视频玩游戏,睡觉。按朋友的话来说,这样的日子会让人“班味很重”。所以当在3月份第一次听说公司组织的Aspire Start Strong+活动的时候,我虽然不是那么一个喜欢旅游的人,但是还是仍然感觉非常激动,很珍惜这个机会,毕竟这个活动由公司出钱、不占用自己的假期、和全世界去年的校招生一起去美国参加,而因为有了这三点,活动具体是干什么的反而都不重要了,在全新的环境里给生活暂时换个节奏,按同事的话说,“洗洗班味”,是最重要的。于是开始火速走流程,给老板科普这个活动是干什么的、去北京办美签、订酒店,终于在上周成行。
这不是我第一次出国出境,但是确实是第一次在到美国,这个完全和国内、甚至东亚主流生活方式完全不一样的地方。
由于活动会场在市区,所以我们住在西雅图的市区(downtown)。这一个区域确实有大城市的感觉,高楼大厦鳞次栉比,由于西雅图并不平,有山,车道也普遍仅有双向四车道,第一次进市区以为回到了重庆的渝中。但是这个区域并不移居,人不多,有经典流浪汉,并且也比较危险,途中左边的麦当劳被称为“死亡麦当劳”,听说我们到达的前一天发生了枪击案,因此,第三天晚上被迫不得不在快天黑的时候出去时,我和舍友不得不加快脚步。并且,市区虽然有很多商场,但是实际上非常萧条,我们去的一个比较大的商场Pacific Place里面仅仅只有几个商店。
而在市区之外的地方,并不是国内郊区常见的农田或者工厂,而仍然是城市,只不过是一望无际的平房大house,大多数的中产生活在这里。这些地方不能叫西雅图的郊区,因为实际上是另一个小城市,各个城市有自己的downtown,甚至Apple Store和一些品牌的专卖店都只有这些小城市的购物中心才有。各个城市之间的交通,也主要以车为主。各个城市之间非常近,例如微软总部所在的Redmond和西雅图市区开车仅需30分钟,相当于同属主城区的闵行和人民广场之间的距离。所以,这次我深刻体验到了什么叫”没车就是没腿”。
由于我对我的驾驶技术并没有太大信心,加上市区开车成本极高,所以没有租车,所以我也只能在市区简单逛逛旅游景点,旅游景点之间的交通也是通过打车。这和国内所有资源都集中在市区的情况可谓大相径庭。
世界的不同同样也体现在社会的各个角落:和陌生人进入电梯也要打招呼,和其他人沟通时经常能看到幅度很大甚至有点夸张的表情和肢体动作,几乎每次打车遇到的司机都想和乘客聊天,甚至餐厅吃饭时,服务员的服务,结账时的流程也完全不同,于是在前一两天,干啥事都要做好出丑的准备。
微软官方对这个活动的主要内容的一个关键词就是Networking,社交。整个活动总共三天,每一天都由几个讲座以及讲座之间的休息时间组成,而在休息时间,也包括讲座期间,主要工作就是和其他人社交。到这里,E人听了狂喜,I人听了害怕。而我作为一个IE各半的人(测试结果仅供参考),虽然并不害怕日常社交,但是仍然感觉有点陌生和有挑战性,完全不知道和这些来自世界各地的人会如何社交。
在活动正式开始的前一天,我所在的组织安排了一次参观交流的活动,邀请了全组织的新人去园区参观,以及和组织内老板的的交流。这种形式的参观交流活动,我也是经历过很多次了,本科时我甚至参与组织过一次组织微软俱乐部的同学去苏州微软参观交流的活动,总体体验还是比较放松的:行程都安排好了,照着做就好了,交流的时候有问题就问,不想公开问就等着私下交流的时候问,没啥好问的就划水,轻松+愉快。但这次的参观交流活动加上了浓浓的社交元素,情况就完全不一样了。从10点多登上去园区的大巴开始,一直到4点整个活动结束,社交过程一刻不停:在大巴车上,主持人就让所有人打乱座位,开始和不认识的同事聊天;下午,先和老板有一次Panel,也就是各位领导坐在前面,参与者坐在下面,领导分享,参与者提问的形式,之后,又是所有所有的参与者在一个空间里自由的交流。只有上下午中间的参观园区的Visitor Center的环节有一点休息的时间。
而接下来的三天也是同样的节奏:讲座的时间,大家坐在一个大圆桌,除了听讲座的内容,就是根据讲座的要求做一些同桌之间的交流;而在讲座时间以外,就是在一个大会场内找吃的喝的,以及社交。甚至第三天的晚上,公司组织了所有的参与者去西雅图的流行音乐博物馆(Museum of Pop Culture, MoPoP),在里面除了常规的参观博物馆展馆,还可以参与蹦迪、剧场小游戏等可能在西方世界常见的娱乐活动,而,当然,还有一个大的空间可以用于社交。每天这样的节奏从早到晚,使得每天晚上几乎都是沾枕头就能睡着。
由于我从小到大都是生活在汉语的环境下,社交遇到的第一个问题就是语言。
虽说工作在外企,但是这只意味着工作相关的文字资料以及占据少比例时间的会议是英文的,而其他时候,尤其是日常的沟通交流,仍然使用汉语。而当处在英语环境下的时候,情况就完全不一样了。读写英文对大多数来说都不是问题,看到不会的可以查,写的时候可以慢慢斟酌用词,也可以让AI帮忙。而开会的时候,由于大多数内容都是工作相关的,工作相关的内容本来从头就是用英文思考的,所以听说没有遇到什么大的障碍,即使遇到可能听不懂的,也可以用Teams的Live Caption实时生成字幕,把听转换为读,难度一下子就降下来了。而日常交流最重要的是听和说的情况就完全不一样了。
关于听,容易出现的一个问题就是在关键的地方卡壳,这会使聊天进入一个不停地pardon/sorry状态,很影响聊天的氛围。第二天中午吃饭的时候和几个来自美国的员工聊关于电动汽车的事情,其中一个美国员工提到美国对中国的电动汽车加征了100%的关税tariff。这个词如果写出来给我看我还是认识的,但是当时完全没有反应过来,于是聊天被迫中断了一下。还好我很快根据语境猜出了这个词是在说关税,聊天才可以继续进行下去。这还是比较容易的情况,而更多的例子是伙伴说了一句话,我甚至没有听出这句话是在问问题,敷衍地笑笑,然后尴尬地发现聊天中断了,甚至还不知道为什么。另外,众所周知,微软、亚马逊等公司招募在大量的来自全世界的员工,本次活动我推测来自美国、英国等英语国家的员工甚至不到一半,而非英语国家的员工的英文也有不同的口音,毫不夸张地说,每听一句话都说一下sorry,让对方重复一下。
而至于说,说出一个句子简单,但要流畅、快速地说出完整的、简单的句子继续对话,这让我耗费了大量精力,以至于时间长了我甚至有点不敢说话了。去园区参观的车程总共20分钟,我在对话刚开始的时候,除了找关键词表达意思之外,还可以注意句子的时态、语法等细节。但是聊到后面,尤其是聊到熟悉或者不熟悉的话题、情绪比较激动的时候,就只能保证表达出关键词,什么is
/was
、问句结构,通通一边去吧。另外,由于中文和英文的语言表达习惯不一样,而我仍然是用中文思考,加一个汉译英的环节,这会使得说出来的话不那么简洁,甚至感觉有点奇怪。有一次打车的时候,我想问司机最近有没有接到其他同样来自微软的员工。由于我仍然是中文思维,要说出来需要进行一次汉译英,但是定语稍微一长(这里的同样来自微软),我就喜欢用从句,于是我脱口而出:
Have you taken any other people who also come from Microsoft?
说到who的时候,我就感觉有点奇怪,于是后面变得有点不自信,说话声音都变小了。果不其然,司机没有听清,于是我又重新说了一句这段话,这时才感觉到,似乎没有必要这样说,一个简单的Microsoft employees甚至Microsoft people就行。
总的来说,这是我第一次在全英文的环境下的与人日常交流。虽然大家都会很耐心,但是在本来就不是很擅长日常聊天的情况下叠加一个语言debuff,仍然让我精疲力竭。我一直以为我的英语能力还可以,每天都无字幕看YouTube视频,但是真到对应的环境下,还是处处体现出不适应。语言果然还是要一个环境,没有环境的语言就是哑巴语言,如果之后真被relocate到国外,第一个要迈的坎就是语言壁垒。
由于公司在上海的规模不大,而且近几年校招的名额非常少,分配到每个组的新人就更少,而且绝大多数员工来公司都不是来奋斗的(奋斗比滚出微软!),所以公司的氛围比较传统,公司就是工作,到点就回家,甚至公司组织的活动都是面向家庭的。这对于有家庭的人来说当然是天堂,但是对于我这种刚毕业单身狗来说,虽然工作也非常轻松愉快,但是同样容易感觉无聊。再加上公司所在的地理位置又是邻近一个50年代开始开发的郊区卫星城,周边的城建、商业、住房等都是纯纯的老城区模样,居民也是中老年人居多,甚至在附近的羽毛球俱乐部里每次都能遇上头发花白的老大爷老奶奶,毛估所有参与者的平均年龄没有40也得有35。虽然有两个高校,但是高校自成一体,基本和社会面不在一个圈子里。本来我并没有注意到身边环境的特征,而契机是在今年春节回家时,和研究生同学约在家里的附近羽毛球馆,发现球馆里全是年轻人的时候,我突然意识到,我所处的环境似乎有点老了。
这次去了Aspire活动,我体验到了一种年轻人的环境。所有参与者都是2023年4月后加入微软的校招新员工,背景都是类似的,很多人还愿意去互相了解,对职业发展抱有期待,聊天的时候更容易有共同话题,即使都只是工作中,由于同处同一个职业阶段,所以大家思考的、追求的东西基本都是类似的。在去园区参观的路上,我和一位来自印度的女生聊了很久,虽然上文提到,对我来说听说仍然不够流畅,但是仍然交流了很多,为什么选择学计算机,为什么来微软,来美国的感受,组内的情况,想要什么样的生活。我们甚至后面还留了邮箱,互相发了几封极其类似上学时英语课写的小作文(英语课小作业还是有用的:D)。在活动中,也认识了来自印度、美国、日本、以色列等各个地方的新同事。有的同事很符合“刻板印象”,有的甚至完全相反;有的主动过来聊天,完全被带飞,而有的交流寥寥几句后,似乎只能以Nice to meet you结束;有的加了LinkedIn等联系方式,有的甚至出现在面前也不会再认出来。在认识新人之外,同样也见了很多许久没有见面的、同在微软的南大同学,聊的话题除了工作,还包括本科时期经过的一些事情,似乎又回到了2020年前的本科生活。
回想这次活动之前和期间的自己的感受,我发现环境真的很影响人。为什么在学校的时候,和同学交朋友似乎很容易?朝夕相处,同处一个人生阶段,工作和休息的节奏、思考、烦恼、追求的事情都是一致的,自然而然就能有话聊,相互理解,发展关系。而在工作后,有家庭的同事的生活的重心自然而然会放到家庭中去,没有家庭的也会去形成以自己为主的工作生活节奏,不会轻易因为他人而改变,即使参加活动,目的性也都极为明确,也就是说,每个人或多或少都会稳定到一个适合自己的生活方式上。而环境又会影响回每个人。如果实验室的同学都在努力学习发论文,那么自己不去科研就会被认为为“异类”;如果身边的人都到处参加活动,那么自己可能也在某一刻想去试试;如果身边的人的生活都非常稳定,那自己也得主动或者被动地去寻找一个稳定的生活方式。而这个环境的不同,才是上学和上班最大的区别。
由于没有找到固定的搭子抱团,所以我并没有安排活动的后续旅程,活动结束后在市区和几位之前认识的小伙伴简单玩了玩市区景点后就回上海了。
回想起来,我到底得到了什么?在西雅图的全新的环境中,锻炼了一下之前一直处于哑巴状态的英语,体验了美国的生活方式,和来自全世界各地的同龄人高强度社会……这是一次独一无二,甚至很可能不会再有第二次的机会。感谢在西雅图的6天短暂、全新,比现在更有活力的生活,让我更理解环境的意义,让我在解决“我想要什么”这个终极问题的路上更进一步。
2024-01-17 22:58:00
在上一篇文章中,我给博客增加了点击量监测,并将这个服务部署到了Azure,数据库采用了使用SQL Server的Azure的SQL服务。由于SQL Server有免费的订阅,微软的Azure Data Studio也还算好用,于是我觉得可以重用一下刚才学习的这个技能,将其他的服务也使用SQL Server部署。
我之前在用wakatime来记录我的编程的数据(例如每天的编程时间、所使用的编程语言等),但是wakatime免费用户只能保存14天的数据,而且wakatime没有提供官方的可自己部署的后端,所以我也一直在寻找wakatime的替代品。之前尝试使用了一段时间的codetime。这个软件的功能和wakatime类似,但是它当前只支持VSCode客户端,虽然免费保存数据,但是仍然没有提供可自己部署的后端。我在很早之前也尝试找过wakatime的后端替代品,但是当时并没有找到一个能用的。但前不久,同学给我推荐了wakapi项目,这个项目重新实现了wakatime的后端API,这样wakatime的丰富的客户端插件可以直接使用,并且完全可以自己部署,不用担心数据并存放在别人的服务器上。
它就是我一直寻找的wakatime替代品!
很激动地浏览了一下项目,看到wakapi当时并没有原生支持SQL Server,但是在README中提到,wakatime使用了gorm作为数据库访问框架,而gorm本身是支持SQL Server的。
我想,既然库都支持了,SQL Server支持有什么难的?引入gorm.io/sqlserver
包,引入创建一个Dialector
,用gorm的API编写的绝大多数数据库操作就完成了。
sqlserver.Open(mssqlConnectionString(c))
这有什么难的,开跑!结果遇到了一大堆报错。
仔细一看,这些报错主要是来自于数据库migration中的原生SQL语句和片段。
程序启动的时候,会运行数据库migration脚本。随着软件开发更多的新功能,其使用的数据库的结构总会发生变化,而migration是指一些代码,这些代码的作用是修改数据库schema、让schema满足当前版本的要求。当软件功能越来越多,对数据库的变化也就越来越多,所以在一个成熟的软件中,你常常会看到有很多的数据库migration的代码。在程序启动的时候,这些migration将会被一个一个地执行,使得程序正式开始时,数据库的schema也更新到最新。
> tree migrations
migrations
├── 20201103_rename_language_mappings_table.go
├── 20201106_migration_cascade_constraints.go
├── 20210202_fix_cascade_for_alias_user_constraint.go
├── 20210206_drop_badges_column_add_sharing_flags.go
├── 20210213_add_has_data_field.go
├── 20210221_add_created_date_column.go
├── 20210411_add_imprint_content.go
├── 20210411_drop_migrations_table.go
├── 20210806_remove_persisted_project_labels.go
而有的migration(尤其是自动生成的migration)很可能包含了一些原生SQL。同时,由于ORM是对数据库操作的抽象,而再强大的抽象也不如原生的SQL来得强大和方便,所以很多时候,开发者仍然会选择在一些地方使用原生SQL语句的片段或者语句来实现一些功能。虽然SQL本身是有标准的,但是各个数据库厂商实际上自己实现了很多新功能,很多时候我们本以为理所当然的功能,实际上并不在标准中,而是数据库厂商自己实现的,一旦手写SQL而没有意识到有的SQL实际上只在部分数据库中兼容,就可能会在不兼容的数据库中遇到问题。
举个例子,wakapi的某个版本需要在数据库的users
表中新增一个has_data
列,而对于已经存在的users
表中的数据,这个列需要被设置为TRUE
。代码中用于执行此次migration的代码使用如下SQL语句实现了这个功能:
UPDATE users SET has_data = TRUE WHERE TRUE;
看上去是个很简单的人畜无害的SQL语句,对吧?但是这个SQL语句在mssql中中是非法的,因为mssql中并没有TRUE
, FALSE
常量!sqlserver中没有boolean
类型,所有这些类型都是使用一个字节的tinyint
来表示的,而TRUE
和FALSE
就对应使用1
和0
来表示。但是,1
和0
在sql server中并不是一个合法的boolean
表达式,所以它们并不能直接用在WHERE
中。所以,在MSSQL中,以上SQL语句就必须重新成以下的样子:
UPDATE users SET has_data = 1;
另一个情况是SQL语句片段。虽然ORM的一大作用就是将软件代码映射为SQL语句,减少我们手写SQL可能带来的错误,但是为了实现的灵活性,ORM常常同样也允许在自己的API中编写一些SQL的片段,而ORM会尝试将这些SQL片段嵌入进去。但是这些SQL片段可能也会有不兼容的情况!例如,下面的代码
result := db.Table("summaries AS s1").
Where("s1.id IN ?", faultyIds).
Update("num_heartbeats", "3")
上述gorm数据库仓库将会被映射为类型以下的SQL语句。注意Table
和Where
方法的参数与下列SQL语句中对应的语句的对应关系。
update summaries AS s1 set num_heartbeats = 3 where s1.id IN ?
是不是感觉很简单?但是MSSQL仍然不支持!MSSQL不支持在update
持语句中给表新增一个别名,所以以上SQL语句中的AS s1
必须被去掉。
再看一个SQL语句片段:
r.db.Model(&models.User{}).Select("users.id as user, max(time) as time").
这段SQL语句一般会被映射为:
select users.id as user, max(time) as time from users;
有问题吗?有!user
在MSSQL中是关键词,要作为标识符使用必须使用""
或者[]
将它包裹起来!而user
在mysql等数据库引擎中都不是关键词,因此可以随意直接使用。
这个将关键词作为字符串使用的过程实际上编程中很常见,被称为escape
,或者更简单的叫quote
,加引号。但是更坑的是,不同的SQL引擎所使用的加引号的方法不一样。MSSQL使用的是""
或者[]
,但是mysql使用的是`。如何通过一段代码来为不同的数据库加上正确的引号呢?
其实,这个加引号的过程实在是非常常见,只要ORM需要将程序员所写的名字(表名、列名等)映射到SQL,那么就需要考虑给这些名字叫上引号,防止这些名字和SQL自己的关键词冲突。如果你写一个名字叫select
的表,没有这段逻辑,那么这个表就没法通过ORM来映射成SQL了!
因此,根据Don't Repeat Yourself原则,ORM为数据库系统的适配器中肯定会有对应的逻辑。而我们与其自己编写这个处理逻辑,最好的方法当然是调用适配器中已经写好的逻辑了。
gorm使用Dialector
接口作为ORM支持不同数据库系统的适配器的接口,所有gorm支持的数据库都有一个对应的实现了Dialector
接口的Dialector
。所以,第一步就是去看看gorm的Dialector
里定义了哪些接口。
// https://github.com/go-gorm/gorm/blob/0123dd45094295fade41e13550cd305eb5e3a848/interfaces.go#L12C1-L21C2
type Dialector interface {
Name() string
Initialize(*DB) error
Migrator(db *DB) Migrator
DataTypeOf(*schema.Field) string
DefaultValueOf(*schema.Field) clause.Expression
BindVarTo(writer clause.Writer, stmt *Statement, v interface{})
QuoteTo(clause.Writer, string)
Explain(sql string, vars ...interface{}) string
}
从名字判断,这个QuoteTo
方法似乎就是我们要的接口。再随便点开一个dialector实现的代码浏览一下,就能确定,QuoteTo
就是加引号的方法。
可是这个方法看上去和我们想象中的不太一样:我们预期这个功能是一个(string) => string
的函数,这里怎么是一个(clause.Writer, string) => void
的函数,这个clause.Writer
是什么玩意?
由于拼接SQL说到底,就是要生成各个SQL的片段,然后将这些片段的字符串拼接起来。在绝大多数编程语言里,string
都是不可变的,所以拼接字符串实际上是创建了第三个字符串,然后把两个字符串的内容复制进去。这个过程如果进行太多次,就非常浪费时间和内存。所以编程语言常常会提供一个组装字符串的工具类。这个工具类可以理解成是一个可变的字符串,你可以直接在这个_字符串_的后面增加新的字符串,而不需要每次操作都创建一个全新的字符串对象。而这个Writer
接口,就是gorm定义的一个这样的能够组装字符串的工具类所需要实现的接口。这个Writer
接口定义如下:
// https://github.com/go-gorm/gorm/blob/0123dd45094295fade41e13550cd305eb5e3a848/clause/clause.go#L13
type Writer interface {
WriteByte(byte) error
WriteString(string) (int, error)
}
很简单,很直白:WriteByte
:在后面增加一个字符;WriteString
,在后面增加一个字符串。
熟悉Go的同学可能会说了:啊,这个接口看上去非常熟悉,原生的strings.Builder
就是用来做这个事情的,而且也有这两个方法!是不是可以直接用一个strings.Builder
来作为这个clause.Writer
?
正确!所以我们可以直接创建一个*strings.Builder
来作为clause.Writer
(用指针的原因是这个strings.Builder
的这两个成员方法是使用的指针接收者而不是值接收者,所以只有对应的指针类型才算实现了这个接口。具体关于指针接收者和值接收者的区别我们就不在这里介绍了,有兴趣的可以学习一下Go语言),并在调用后获取这个builder
最终的字符串,这样就直接调用了dialector
中实现了加引号逻辑。
func QuoteDbIdentifier(db *gorm.DB, identifier string) string {
builder := &strings.Builder{}
db.Dialector.QuoteTo(builder, identifier)
return builder.String()
}
然后,我们将此函数用在所有需要在SQL片段中使用自定义的名字的地方,这样,我们就重用了dialector的逻辑,用同一套代码兼容了所有的数据库。
r.db.Model(&models.User{}).Select(
fmt.Sprintf("users.id as %s, max(time) as time"), utils.QuoteDbIndentifier(r.db, "user")
)
而在进一步查找并修改原生SQL的语句中,我找到了一段如下的原生SQL语句,直接让我眼前一黑。
with projects as (
select project, user_id, min(time) as first, max(time) as last, count(*) as cnt
from heartbeats
where user_id = ? and project != ''
and time between ? and ?
and language is not null and language != '' and project != ''
group by project, user_id
order by last desc
limit ? offset ? )
select distinct project, min(first) as first, min(last) as last, min(cnt) as count, first_value(language) over (partition by project order by count(*) desc) as top_language
from heartbeats
inner join projects using (project, user_id)
group by project, language
order by last desc
这段SQL语句就这样赤裸裸地写在代码里。经过以上几个例子,是不是以为这个SQL在MSSQL中必定问题巨大,不重新写一份根本跑不起来?
我一开始也是这么想的。而我自己对SQL Server并没有那么熟悉,所以聪明的我,在给一些名字叫上引号后,直接把这段代码扔给了GitHub Copilot,让它帮我改成SQL Server能用的。反直觉的是,这段代码实际上问题没那么大:
具体来说,除了第三点去掉了不需要的引号后,有两个问题:
join using
,需要使用常规的join on
代替limit offset
。查找了一下,mssql可以使用offset ? ROWS fetch next ? rows only
来代替,当然limit
和offset
的参数顺序是反过来的啊,原来这么简单!由于需要修改的地方并不多,于是我就简单的通过if判断,将其中SQL Server不兼容的部分修改为SQL Server兼容的SQL语句,这段SQL语句也就完成了。
修改了这些SQL语句后,程序仍然运行不起来,仔细一看,报错如下:
mssql: A table can only have one timestamp. Because table 'users' already has one, the column 'last_logged_in_at' cannot be added.
简单翻译,一个表只能有一个类型为timestamp
的列,而users
中有多个列都是timestamp
的类型,所以SQL Server报错了。
去代码中一看,users
表对应的User
struct确实有好几个字段都通过gorm的field tag功能type:timestamp
,确定了在数据库中这些列需要映射为timestamp
类型。
// https://github.com/muety/wakapi/blob/fc483cc35cb06313da8231424d1d85b291655881/models/user.go#L17
type User struct {
// ...
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
// ...
SubscribedUntil *CustomTime `json:"-" gorm:"type:timestamp" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
SubscriptionRenewal *CustomTime `json:"-" gorm:"type:timestamp" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
}
timestamp
有什么问题呢?MySQL不都是使用timestamp
来代表时间类型吗?去查找sql server timestamp
,我才发现,SQL Server里的timestamp
的含义和别的数据库中的含义不一样。在别的数据库中,timestamp
就是一个表示时间戳/时间点的类型,而根据这个stackoverflow回答,sql server
中的timestamp
主要用于乐观锁的情况(具体不在这里阐述),只是用来标记本行上一次被修改的时间,所以每个表只能存在一个timestamp
的列。而在SQL Server中如果要表示一个时间戳,需要使用datetimeoffset
。
所以现在问题就变成了:如何将一个类型在不同的数据库中映射为不同的列的类型。
type:timestamp
这个tag肯定是不能用了,于是我们就需要查找有没有其他更动态的方法,可以自定义一个go字段映射到数据库中的类型。经过一番查找,我们找到了Customize Data Type功能。gorm允许定义一个自定义的struct
,并给这个struct
实现GormDBDataTypeInterface
接口,来返回这个类型的字段所真正对应的数据库类型。
type GormDBDataTypeInterface interface {
GormDBDataType(*gorm.DB, *schema.Field) string
}
这个函数的第一个参数就是gorm.DB
对象,指向当前操作的数据库对象,可以获取到当前正在使用什么类型的Dialector
,我们就可以根据Dialector
类型,来输出对应的数据库列类型。另外,这个CustomTime
正好是代码中所定义的一个新的struct
,我们可以直接在CustomTime
上实现这个接口。
看来比较简单,但是为了以防万一,我们再全局搜索一下type:timestamp
,看看有没有什么其他的用法。果然被我找到了!
// https://github.com/muety/wakapi/blob/fc483cc35cb06313da8231424d1d85b291655881/models/heartbeat.go#L27
type Heartbeat struct {
// ...
Time CustomTime `json:"time" gorm:"type:timestamp(3); index:idx_time; index:idx_time_user" swaggertype:"primitive,number"`
// ...
}
这里用到了timestamp(3)
。根据mysql的文档,timestamp(n)
中的n
是代表精确到秒后面的第几位浮点位,3
代表精确到毫秒,而6
代表精确到微秒。而SQL Server的datetimeoffset
也支持类似的标记(文档),datetimeoffset(n)
中的n
也同样表示精确到秒后面的第几位浮点位。由于我们不能直接使用type
tag,但是我们还需要一个tag用来表示这个精确的位数(根据sql server的文档,将这个位数称为scale
),所以,我们可以定义一个全新的timeScale
的tag,其值就是scale
的值。而一个字段上具体有哪些tag,正好可以通过GormDBDataType
的第二个参数获取。
于是根据这个思路,我们实现了CustomTime
的GormDBDataType
方法:
// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/models/shared.go#L44
func (j CustomTime) GormDBDataType(db *gorm.DB, field *schema.Field) string {
t := "timestamp"
// 如果使用的是SQL Server Dialector,则将其类型设置为datetimeoffset
if db.Config.Dialector.Name() == (sqlserver.Dialector{}).Name() {
t = "datetimeoffset"
}
// 如果一个属性有TIMESCALE的tag,那么给类型后面增加(n)参数
// gorm将所有gorm下的tag的key转换成了全大写
// 参考:https://github.com/go-gorm/gorm/blob/0123dd45094295fade41e13550cd305eb5e3a848/schema/utils.go#L35
if scale, ok := field.TagSettings["TIMESCALE"]; ok {
t += fmt.Sprintf("(%s)", scale)
}
return t
}
同时,我们将所有的timestamp
都直接去掉,将type:timestamp(n)
修改为了timeScale:n
:
// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/models/user.go#L17
type User struct {
// ...
CreatedAt CustomTime `gorm:"default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
LastLoggedInAt CustomTime `gorm:"default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
// ...
SubscribedUntil *CustomTime `json:"-" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
SubscriptionRenewal *CustomTime `json:"-" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
}
// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/models/heartbeat.go#L12
type Heartbeat struct {
// ...
Time CustomTime `json:"time" gorm:"timeScale:3; index:idx_time; index:idx_time_user" swaggertype:"primitive,number"`
// ...
}
这样就解决了这个问题。
没想到的是,到这里仍然无法启动程序。报错如下:
[2.938ms] [rows:0] CREATE TABLE "summary_items" ("id" bigint IDENTITY(1,1),"summary_id" bigint,"type" smallint,"key" nvarchar(255),"total" bigint,PRIMARY KEY ("id"),CONSTRAINT "fk_summaries_editors" FOREIGN KEY ("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT "fk_summaries_operating_systems" FOREIGN KEY ("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT "fk_summaries_machines" FOREIGN KEY ("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT "fk_summaries_projects" FOREIGN KEY ("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT "fk_summaries_languages" FOREIGN KEY ("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE)
2024-01-19T19:29:35.607826413+08:00 [ERROR] mssql: Could not create constraint or index. See previous errors.
panic: mssql: Could not create constraint or index. See previous errors.
Could not create constraint or index. See previous errors.
这个等于什么都没说嘛,只知道创建一个外键约束或者索引的时候失败了,而其他的错误信息被gorm或者gorm的sqlserver dialector忽略了。还
还好我能看到具体执行的SQL语句,所以我们可以把这段SQL语句手动输入到Azure Data Studio中,看看数据库引擎到底报了什么错误。
Introducing FOREIGN KEY constraint 'fk_summaries_operating_systems' on table 'summary_items' may cause cycles or multiple cascade paths. Specify ON DELETE NO ACTION or ON UPDATE NO ACTION, or modify other FOREIGN KEY constraints.
看起来,是因为fk_summaries_operating_systems
这个级联的外键索引会造成级联递归(cycles)。和外键约束有关,而外键约束是通过gorm定义,所以我们先去看看代码里涉及这个外键约束的两个表的定义。
// https://github.com/muety/wakapi/blob/fc483cc35cb06313da8231424d1d85b291655881/models/summary.go#L29
type Summary struct {
// ...
Projects SummaryItems `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Languages SummaryItems `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
type SummaryItems []*SummaryItem
type SummaryItem struct {
// ...
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
SummaryID uint `json:"-" gorm:"size:32"`
}
也就是说,Summary
和SummaryItem
两个表是1:m的关系,这个关系映射到数据库中,也就是SummaryItem
表中有到Summary
的ID的外键summary_id
,而这个外键的约束则是通过Summary
表中的tag定义的。
转回头去看生成的SQL,生成的SQL里有5个完全相同的外键(fk_summaries_editors
, fk_summaries_operating_systems
,fk_summaries_machines
,fk_summaries_projects
和fk_summaries_languages
)。由于这五个外键都是级联删除(ON DELETE CASCADE
)和级联更新(ON UPDATE CASCADE
),实际上确实是会存在一个级联递归:当一个SummaryItem
被删除,会造成其对应的Summary
被删除,而Summary
被删除可能会造成这个SummaryItem
也被删除。
那问题又来了,那为什么之前在MySQL、PostgresSQL等数据库里均正常呢?通过这篇Stack Overflow的回答,我们知道了这种问题在其他数据库中并不是问题:其他数据库会尝试解出一条级联路径出来。而SQL Server不会去尝试解,发现了这种循环,就会直接报错。
那如何解决这个问题呢?根据外键名和Summary
中的属性的对应关系,我们可以推测,这五个外键是Summary
中五个引用一一对应过来的。由于这五个外键实际上是一模一样的,只需要保留一个就可以了。所以,最终的解决方案是,只保留一个字段的外键tag,其他字段全部给一个-
的tag,让gorm不要为这些字段生成外键。
// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/models/summary.go#L30
type Summary struct {
Projects SummaryItems `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Languages SummaryItems `json:"languages" gorm:"-"`
Editors SummaryItems `json:"editors" gorm:"-"`
OperatingSystems SummaryItems `json:"operating_systems" gorm:"-"`
Machines SummaryItems `json:"machines" gorm:"-"`
}
至此,系统终于能启动起来了,并且大多数功能已经可以正常使用了。而在代码审核过程中,作者还发现了一个问题:代码中有一个Heartbeat
表,它就是wakatime的客户端插件定期给服务器端发送的数据,其中包含了正在工作的项目、使用的编程语言等信息。而这个表中有一个字段为Hash
,这个字段的值根据其他信息算出来,并且有一个unique index
,通过这个字段,就避免给Heartbeat
表插入多个重复的数据。
// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/models/heartbeat.go#L13
type Heartbeat struct {
// ...
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
// ...
}
在插入的时候,作者使用了ON CONFLICT DO NOTHING
的语句,这个语句的作用就是,如果当插入一行的时候,发现有些行违反了某个unique index
的要求,则忽略这个问题,不插入这一行,也不报错。这刚好非常适合插入有可能有重复的Heartbeat
的场景。
// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/repositories/heartbeat.go#L52
// 下述代码将会被映射为ON CONFLICT DO NOTHING
if err := r.db.
Clauses(clause.OnConflict{
DoNothing: true,
}).
Create(&heartbeats).Error; err != nil {
return err
}
return nil
这种行为被称为插入或者更新,又被称为Upsert(Update/Insert),其行为是一个简单的判断:如果一行已经存在,则更新这一行的内容;如果不存在,则插入这一行。而如何判断一行是否存在呢?则是通过unique index
来判断。
可是遗憾的是,SQL Server不支持ON CONFLICT DO NOTHING
。要想在SQL Server中模拟upsert的行为,有很多方式,一个比较常见的方法是使用merge into
语句。具体如何编写比较复杂,这里就不再具体讲解。
由于这里是使用的gorm的API,并不是使用原生SQL语句,所以理论上来说,gorm是可以知道用户的意图,并根据不同的数据库生成行为相同的、但是兼容对应数据库的SQL语句的。根据这个issue,gorm的SQL Server库确实在很早之前就尝试通过生成一段的SQL Server兼容的SQL语句来支持这个功能,在gorm的文档里也提到了,clause.OnConflict
会根据不同的数据库生成不同的语句,而对于SQL Server,其对应生成的正好就是MERGE INTO
语句。
但是,既然库都支持了,那为什么还会遇到这个错误呢?再次检查所实际执行的SQL,发现这一次Create
实际上生成的SQL语句仍然是最普通的INSERT INTO
,并没有不是正确的MERGE INTO
,。
024/01/19 20:10:23 /home/ddadaal/Code/wakapi/repositories/heartbeat.go:54 mssql: Cannot insert duplicate key row in object 'dbo.heartbeats' with unique index 'idx_heartbeats_hash'. The duplicate key value is (d926be93ebcc4b6f).
[10.830ms] [rows:0] INSERT INTO "heartbeats" ("user_id","entity","type","category","project","branch","language","is_write","editor","operating_system","machine","user_agent","time","hash","origin","origin_id","created_at") OUTPUT INSERTED."id" VALUES ('ddadaal','/home/user1/dev/project1/main.go','file','','wakapi','','Go',1,'','','','curl/8.5.0','2024-01-16 02:16:18.954','d926be93ebcc4b6f','','','2024-01-19 20:10:23.378');
看来,要想弄明白这个问题,就得进到gorm
的源码里来查看问题出在哪里了。使用VSCode启动一个调试版本的程序,给上面的代码的位置打上断电,使用curl
连续插入两次同样的Heartbeat
,根据断点往内查找,当进入到gorm的sqlserver的适配器的时候的代码的时候,我们发现了一些端倪:
当前,我们正在图中的if hasConflict
的位置,且hasConflict
当前为true。根据下面的一段代码,如果hasConflict
为true
的话,将会进入MergeCreate
函数,而这个函数即是一个在MySQL实现类似行为的SQL的语句的代码。但是,实际在橙色块结束后,hasConflict
被改成了false
,所以最终并没有进入MergeCreate
,最终的结果就是一个普通的INSERT INTO
SQL。那么也就是说,橙色这段代码就是罪魁祸首!
这段代码干了什么事情呢?简单来说,它会检查要插入的行中的各个列中有没有包含主键。如果没有设置主键,则将会把hasConflict
改为false
;而如何设置了主键,才会进入MERGE INTO
流程。回想前面,要想正确生成upsert行为,我们需要判断一行是否存在。而很显然,这里的代码将一行是否存在的判断标准等价为了主键是否存在,而忽略了其他具有的unique index
的列。
当然,其他人也发现了这个问题,gorm-sqlserver的仓库中也有已经半年前打开的issue正好就是关于这个问题。而很遗憾,这个问题过了半年依然没有修复。
要想解决这个问题,有多种方法,而我最终选择了最简单粗暴的方法:不管!这个函数的目的是同时插入多个Heartbeatt
,那我们就手动一个一个插入,如果一个行插入失败了,我们简单判断一下,如果这个错误是因为hash重复造成了,那我们就不管了!检查了一下代码中调用这个方法的场景,其实大多数情况下都是只插入一行,所以这样写对性能造成的影响是可以接受的。
// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/repositories/heartbeat.go#L34
if r.db.Dialector.Name() == (sqlserver.Dialector{}).Name() {
for _, h := range heartbeats {
err := r.db.Create(h).Error
if err != nil {
if strings.Contains(err.Error(), "Cannot insert duplicate key row in object 'dbo.heartbeats' with unique index 'idx_heartbeats_hash'") {
// ignored
} else {
return err
}
}
}
return nil
}
终于,经过了一周的时间,本来以为只是一个简单的调库,结果却发现了这么多的问题。经过这个过程,之前连SQL Server用都没用过的我也知道了很多SQL Server的细节;之前没有接触过gorm,现在却连源代码都好好翻了一通。
而关于go,虽然我一直不喜欢go的语法,觉得它太简单,类型系统太弱,很多代码都花在固定的模板代码上(比如if err := nil
),但是不得不承认的一点是,go确实非常的explicit。没那么多乱七八糟的反射、运行时黑魔法,控制流非常清晰,想知道什么代码调用了一个方法,直接Shift+F12
就完事了,出来的结果不会多不会少;要想某个错误是在哪里处理的,跟着繁琐的错误处理代码总能找到。它确实缺少一些特性,但是它非常地工业化。
解决这些问题后,PR顺利合并进了主分支,成就感满满,这可能也是我第一个没那么简单的开源项目的贡献了。
对我来说,设定一个目标是学习的最好的途径,在这次实践里再次得到了印证。