
--- 摄于 2017 年 9 月 藏川线前段
书接前几回,近期在处理一些棘手的线上问题和新需求时,又攒了不少值得记录的“坑”。
两个月前,监控系统抛出了一个诡异的错误:协议在打包阶段拒绝了当前消息,理由是消息体过大,随后断开了连接并拉黑了对端。
默认的最大消息上限是 2MB。在 Debug 过程中,我们发现请求数据确实略微超过了 2MB。但疑问随之而来:该协议支持压缩,消息在打包前理论上应先经过压缩处理。为什么一个略超 2MB 的消息,没能被压缩下去?
通过在压缩出入口增加日志,我们发现了一个扎心的事实:压缩后的数据反而比压缩前更大了。
让我们来回忆一下压缩算法的基本原理,它的主要能力大致是:
无论算法多精妙,压缩结果都需要包含一段头部信息(Header),记录算法类型、字典偏移等。如果原始数据是高熵的二进制数据(如 Hash 值或加密流),数据本身几乎没有冗余,压缩算法不仅无法消减体积,还会因为强行添加 Header 导致“压缩膨胀”。
这块的实现也相对比较有意思,为了支持在一个协议中同时存在压缩和不压缩数据,它是这么做的:
fn compress(raw: Bytes) -> Bytes {
let res = {
let mut flag = Vec::from([0]);
flag.put(raw);
flag
}
if raw.need_compress() {
let input = res.split_off(1);
let compress = compress_inner(&input);
res[0] = 1;
res.put(compress);
res.into()
} else {
res.into()
}
}
数据的第一个 u8 是 flag 位,用来判断当前的消息是否是被压缩过的。那么针对上面的压缩反作用协议来说,这里就可以将整个协议的逻辑改成不压缩即可。
书接上回文末,说了一个多协议组合不可避免的多次复制问题,可能稍微有点抽象,这次用一个案例来说明这个问题。
比较生活化的例子是:邮寄一封信,快递员不会先拆开这封信看看写了啥,然后再装箱,这种行为是非常奇怪的。
上面这个压缩案例下,还叠加了一个问题,就是协议打包和压缩是完全独立的两个过程,双方互不感知,这就非常类似一个多协议组合。
我们来看协议消息打包的流程:
fn compact(raw: Bytes) -> Bytes {
let mut res = (raw.len() as u32).to_be_bytes().to_vec();
res.extend_from_slice(&raw);
res.into()
}
细心的朋友们可能发现了,在这套流程中:
这样两份复制,才是多协议组合的常态,那么有没有办法避免呢?有的,将这两个过程合并到一起,相互感知,同时实现,只需要一次复制就能转换成协议消息包模样。代码类似这样:
fn compress_with_compact(raw: Bytes) -> Bytes {
let res = Vec::new();
if raw.need_compress {
let compress = compress_inner(&raw);
let len = compress.len() + 1;
res.extend((len as u32).to_be_bytes());
res.push(1);
res.extend(compress);
res.into()
} else {
let len = raw.len() + 1;
res.extend((len as u32).to_be_bytes());
res.push(0);
res.extend(raw);
res.into()
}
}
这样就减少了一次全消息拷贝的消耗,这也是这个 PR 的整个背景了。而在两个过程双方互不感知的情况下,我确实不知道该怎么减少这样的消耗。
书接上上回,本以为坑已然填平,但没想到最近项目给了几个新需求,导致问题又凸显出来了。
在时序表中,数据可能存在冗余,通常需要用 DISTINCT ON (col) 进行去重。但 PostgreSQL 规定:DISTINCT ON 必须与 ORDER BY 的第一个字段保持一致:
ORDER BY 在大数据量下会导致严重的性能劣化。相较于 db 中的数据冗余,更需要在意的是,无 index 的 order by 会导致的极端性能问题。
于是,此时的办法是,创建 物化视图 (MATERIALIZED VIEW),定时刷新最新的无重复数据用来应对用户的排序需求。
有一个数据类型为 u128, 而 db 原生数据类型最大支持 i64, 更大的需要使用一种扩展类型叫 numeric,这玩意的排序性能对比 i64 可以说是差了一个数量级,于是在开始认为这个数据并没有排序需求,用 text 存十六进制的方式去做了数据的类型选择。
但是,后面有这个需求了,这里就有问题了,如果在查询的时候将十六进制转成 numeric 再排序,那这里得再慢一个数量级。后面无意中发现,实际上大端的十六进制排序与数字排序是一致的,于是方案变成了 text 存大端十六进制,这样又解决一个排序问题。保留大端是有理由的且有意义的,小端的文本排序是乱的。
之后又碰到一个问题,需要的数据是两张表合并而成,而且其中一张表的原始字段还无法使用,需要经过一系列计算之后得出。这时候,很容易使用子表查询,大概嵌套三层就够了。
但是,这个数据需要支持排序,三层嵌套查询的临时数据需要支持全量排序,并提供分页功能。这种行为一看就是未来性能爆炸的地方。
这里一定要做 MATERIALIZED VIEW 去冗余数据并提供 index 支持。但是三层嵌套的 SQL 写起来实在是太难看了,并且不容易理解,这时候就体现出 AI 的好处来了,它给了一种叫 CTE (Common Table Expressions,公用表表达式)的 SQL 语法:
WITH FilteredOrders AS (
-- 第一步:先过滤时间
SELECT * FROM orders WHERE date > '2023-01-01'
),
UserStats AS (
-- 第二步:基于第一步统计数量
SELECT user_id, count(*) as cnt FROM FilteredOrders GROUP BY user_id
)
-- 第三步:输出结果
SELECT * FROM UserStats WHERE cnt > 10;
对比嵌套子查询,结构清晰,非常容易识别,性能方面也差不多。这种语法如果不是 AI 提供,说实话,我还真不知道,AI 真棒。
虽然这种语法是 1999 标准定义(SQL3)的,但 PostgreSQL 2009 年才支持,而 MySql 是 2018 年才支持的,看似很老,实则很新。同时,SQL2 在教材中大行其道,很少有教材更新到 SQL3 标准,这也是原因之一。
最近为了验证代码在安卓平台的兼容性,尝试用模拟器走跑一遍代码,然后就入了个深坑。
一个简单的安卓代码,不带任何功能,单纯一个日志输出,在我这调试了三天,始终无法确认代码写对了没有,非常离谱。问题在哪,给各位看看我遇到的无限循环错误:
这是手动起模拟器的:
❯ ~/Android/Sdk/platform-tools/adb install app/build/outputs/apk/debug/app-debug.apk
Performing Streamed Install
adb: failed to install app/build/outputs/apk/debug/app-debug.apk: cmd: Failure calling service package: Broken pipe (32)
❯ ~/Android/Sdk/platform-tools/adb install app/build/outputs/apk/debug/app-debug.apk
Performing Streamed Install
adb: failed to install app/build/outputs/apk/debug/app-debug.apk: cmd: Can't find service: package
这是通过 studio 运行的:
Error running 'app'
The application could not be installed. Installation failed due to: 'Error code: 'UNKNOWN', message='Unknown failure: 'cmd: Can't find service: package'''
List of apks:
[0] '/home/luoc/rust-work/ckb-work/test-lib/app/build/intermediates/apk/debug/app-debug.apk'
这已经是算好的了,至少能到 install 命令,在此之前一天多,我还在努力解决为什么模拟器起不来的问题,一直卡在 logo 页面。
其实起不来的原因很简单,下载了一个studio 默认推荐的 x86 架构 image,它跑在我这个 x86_64 架构的机器上给我推荐 x86 的 image!
我是怎么知道的?拉日志,复制给 Gemini,他告诉我的。
经过三天痛苦的尝试,最终我绝对放弃单打独斗,去找同事问问,这种开发体验真的是太憋屈了。通过交流发现,这玩意跑在 windows 上最佳,ubuntu LTS 也凑合,而我的 archlinux 就省省吧,没必要折腾自己。
archlinux 上确实也有不一样的模拟器,比如 waydroid,但这个东西至少有三层转译层,作为开发环境感觉很不对劲,看过介绍之后就没兴趣研究了。
这次体验是真的吃了大亏,太坑了,当然目前这项工作还没结束,未来应该还有更多坑在等我,我就纳闷了,一个安卓开发平台的开发体验怎么连板子都不如,板子也没这么折腾啊。
好消息是,已经确认了,安卓兼容问题是由于 rust 的 instant 不支持记录休眠时间导致的,模拟器关闭 doze 服务,就正常工作了,具体问题在这里,而要搞定这个问题也相对简单,只需要用 ffi 换一个参数就能支持记录休眠时间了,要支持 tokio runtime 也只需要用 asyncfd 就能实现,唯一不确认的问题是安卓是否对此有权限要求。
坏消息是,远端 Windows 环境其实还没搭建完成,还需要继续嘛?
吐槽完毕。下一篇如果不出意外,应该是元旦的年度总结了,到时候见!
请登录后评论
评论区
加载更多