jackc/pgx 查询错误处理避坑

前言   最近,一个数据查询服务被业务方反馈拿不到数据,但接口响应是成功的,不报错,仔细排查后发现数据查询库用的是 pgx,但 pgx 返回的错误未被处理,导致服务接口没有响应错误。   在后续的排查过程中,发现这其实不算是 pgx 的问题,而是 database/sql 中的坑,所有涉及用 database/sql 查询的都需要显式处理 rows.Err()。 问题篇   服务所用 pgx 版本为 4.10.1。查询函数主要用的是 QueryRow(返回一条数据) 和 Query(返回多条数据),更近一步的测试中(人为制造查询错误,eg:锁表)发现,调用 QueryRow 函数的接口,如果发生查询错误的问题,服务接口会正常响应错误。深入 pgx 源码发现,QueryRow 本质是对 Query 的进一步封装,对应的 Scan 函数源码为: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 // QueryRow 返回的 connRow 对应继承自 Query 返回的 connRows 对象 type connRow connRows func (r *connRow) Scan(dest ...interface{}) (err error) { // 显式类型转换 rows := (*connRows)(r) if rows.Err() != nil { return rows.Err() } if !rows.Next() { if rows.Err() == nil { return ErrNoRows } return rows.Err() } rows.Scan(dest...) rows.Close() // 关键的错误 return rows.Err() } func (rows *connRows) Scan(dest ...interface{}) error { ci := rows.connInfo fieldDescriptions := rows.FieldDescriptions() values := rows.values if len(fieldDescriptions) != len(values) { err := errors.Errorf("number of field descriptions must equal number of values, got %d and %d", len(fieldDescriptions), len(values)) rows.fatal(err) return err } if len(fieldDescriptions) != len(dest) { err := errors.Errorf("number of field descriptions must equal number of destinations, got %d and %d", len(fieldDescriptions), len(dest)) rows.fatal(err) return err } if rows.scanPlans == nil { rows.scanPlans = make([]pgtype.ScanPlan, len(values)) for i := range dest { rows.scanPlans[i] = ci.PlanScan(fieldDescriptions[i].DataTypeOID, fieldDescriptions[i].Format, dest[i]) } } for i, dst := range dest { if dst == nil { continue } err := rows.scanPlans[i].Scan(ci, fieldDescriptions[i].DataTypeOID, fieldDescriptions[i].Format, values[i], dst) if err != nil { err = scanArgError{col: i, err: err} rows.fatal(err) return err } } // 由于返回多行数据,需要多次 Scan, 所以不能返回 rows.Err() return nil } // 有些数据库查询错误,只有在 Close 之后,再调用 rows.Err() 捕获 func (rows *connRows) Close() { if rows.closed { return } rows.closed = true if rows.resultReader != nil { var closeErr error rows.commandTag, closeErr = rows.resultReader.Close() if rows.err == nil { // 赋值错误 rows.err = closeErr } } if rows.multiResultReader != nil { closeErr := rows.multiResultReader.Close() if rows.err == nil { // 赋值错误 rows.err = closeErr } } if rows.logger != nil { if rows.err == nil { if rows.logger.shouldLog(LogLevelInfo) { endTime := time.Now() rows.logger.log(rows.ctx, LogLevelInfo, "Query", map[string]interface{}{"sql": rows.sql, "args": logQueryArgs(rows.args), "time": endTime.Sub(rows.startTime), "rowCount": rows.rowCount}) } } else { if rows.logger.shouldLog(LogLevelError) { rows.logger.log(rows.ctx, LogLevelError, "Query", map[string]interface{}{"err": rows.err, "sql": rows.sql, "args": logQueryArgs(rows.args)}) } if rows.err != nil && rows.conn.stmtcache != nil { rows.conn.stmtcache.StatementErrored(rows.sql, rows.err) } } } }   从源码中可看出 QueryRow 的 Scan 函数有一系列的错误处理,而 Query 对应的 Scan 是更底层的函数,返回的仅是 Scan 过程中的错误,其他的错误需要在业务上层处理。Close 函数同样可能会出现错误,需要调用 rows.Err() 主动检查错误(这一步至关重要)。对于 Close 报的错,可以这样处理: 1 2 3 4 5 6 7 8 9 10 11 12 var err error defer func() { rows.Close() closeErr := rows.Err() if err != nil { if closeErr != nil { log.Printf("failed to close rows: %v", err) } return } err = closeErr }   数据库执行 sql 失败的错误(eg:canceling statement due to conflict with recovery),在 Close 后才会暴露出来,所以不处理这个错误,就不会返回错误,但数据又查不到,服务接口也表现为响应成功,导致上层业务误认为数据库里还真没数据。   最好的方式还是避免每次都手动 Scan,pgx 其实还提供了更上层的函数 QueryFunc,该函数封装了大部分错误处理: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 func (c *Conn) QueryFunc(ctx context.Context, sql string, args []interface{}, scans []interface{}, f func(QueryFuncRow) error) (pgconn.CommandTag, error) { rows, err := c.Query(ctx, sql, args...) if err != nil { return nil, err } defer rows.Close() // 最后一次 Next() 会自动调用 Close() for rows.Next() { err = rows.Scan(scans...) if err != nil { return nil, err } err = f(rows) if err != nil { return nil, err } } if err := rows.Err(); err != nil { return nil, err } return rows.CommandTag(), nil } 不过 QueryFunc 函数在新版本中(5.7.2)已被 ForEachRow 替代: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 func ForEachRow(rows Rows, scans []any, fn func() error) (pgconn.CommandTag, error) { defer rows.Close() for rows.Next() { err := rows.Scan(scans...) if err != nil { return pgconn.CommandTag{}, err } err = fn() if err != nil { return pgconn.CommandTag{}, err } } if err := rows.Err(); err != nil { return pgconn.CommandTag{}, err } return rows.CommandTag(), nil } 而 ForEachRow 的使用示例可以看这个函数: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 func (c *Conn) getCompositeFields(ctx context.Context, oid uint32) ([]pgtype.CompositeCodecField, error) { var typrelid uint32 err := c.QueryRow(ctx, "select typrelid from pg_type where oid=$1", oid).Scan(&typrelid) if err != nil { return nil, err } var fields []pgtype.CompositeCodecField var fieldName string var fieldOID uint32 rows, _ := c.Query(ctx, `select attname, atttypid from pg_attribute where attrelid=$1 and not attisdropped and attnum > 0 order by attnum`, typrelid, ) // 这里是示例 _, err = ForEachRow(rows, []any{&fieldName, &fieldOID}, func() error { dt, ok := c.TypeMap().TypeForOID(fieldOID) if !ok { return fmt.Errorf("unknown composite type field OID: %v", fieldOID) } fields = append(fields, pgtype.CompositeCodecField{Name: fieldName, Type: dt}) return nil }) if err != nil { return nil, err } return fields, nil } 后记   对于一个不熟悉的底层库,最好的学习方式还是看它的示例代码,库的开发者很难知道用户会踩哪些坑,文档中自然不会有,毕竟当局者迷。只从文档出发,很容易陷进未知的坑里,甚至掉坑里都不知道,业务出问题后,花费大代价排查之后,才知道掉坑里了。陌生的开源库在使用的时候还是先全库 clone 下来,用 api 的时候,就去源码里搜一下,看看开发者写的示例(不管是测试,还是其他地方的调用),当然现在也可以让 AI 先写,人只要再核实一下文档和源码,能节省很多学习的功夫。

2025/3/22
articleCard.readMore

天文通预测气象奇观小记

前言   出去玩看风景很大程度上受天气的影响,所以一般会使用「莉景天气」和「天文通」提前看下天气预报。莉景天气对于各种气象奇观都有明确的预报概率,但需收费;天文通免费,但只能根据其上的数据自行推断,Shaun 根据网上的一些资料和个人见识总结了一些奇观的出现条件。天气数据都有个准确率的问题,尤其是在高海拔地区,气象多变,数据仅供参考,所以下面列出的条件也都仅供参考,气象奇观出现是幸运,不出现才是正常的。 奇观篇 朝霞/晚霞:高中层云量在 30%~70%,空气湿度低,微风,空气质量好,能见度高。一般在日出之前与日落之后,太阳在地平线后 15~20 分钟之内的余辉最好看。预测火烧云可以看「火烧云分析与记录」 云海:低空云量越多越好。 佛光:天气为晴天,低空云量多,气温低,湿度大。 雾凇:气温低于露点,湿度在80%以上,微风,昼夜温差大。 日照金山:天气为晴天,云量低于 10%,尤其是高中层云量。 附录 徒步经验篇 同样的动作,高海拔相比低海拔消耗的能量更多,所以高海拔徒步时,尤其需要注意自己的节奏,切忌急上急下,当心率变快,或者呼吸不畅时,应该及时停下休息,同时补充能量(喝含糖饮料,吃东西),等呼吸和心率正常时再行走; 高海拔上陡坡时,宁可慢一点,一步只走半只脚的距离,必须要高抬脚上坡的时候,上完之后可休息 10s; 高海拔尽量不洗澡,不泡脚,长时间的泡热水容易高反,当然如果想测试洗澡后会不会高反,可以在行程的最后一天试试; 在无人区徒步,最好记录自己的行驶轨迹(最差也得记录起终点的 GPS 坐标),在开阔的地方,就算没有网络信号,但 GPS 一般还是有的; 徒步完之后,最好拉伸一下,主要是大腿前后和小腿前后四块肌肉群,爬升大的话,需要拉伸大腿前侧肌肉(用手向后抬起小腿,尽量抬高小腿,脚后跟贴近臀部)和小腿后侧肌肉(整条腿伸直,抬起脚尖),下降多的话,需要拉伸小腿前侧肌肉(腿略微弯曲,脚尖尽量向后)。在拉伸时需要注意姿势,找好支撑,别扭伤了; 万能腿部拉伸动作

2025/1/26
articleCard.readMore

LLM 本地部署运行初体验

前言   最近看到这么一个工具——ollama-ocr,利用本地大模型直接进行 OCR,试用了一下,感觉效果还不错,联想之前看到的一个项目——PDFMathTranslate,感觉本地大模型已经非常成熟了,部署使用也越来越简单了。 如果需要提供 LLM 服务,还是用 vLLM 部署相对合适。 配置篇   硬件:M3 MBP,10 核 GPU(Metal 3),16G 内存   系统&软件:macOS Sonoma 14.5,Ollama 0.5.4,AnythingLLM 1.7.2; 体验篇 ollama 篇   第一次启动 Ollama 时,会出现安装「command line」的引导界面,输入电脑用户密码安装即可,安装命令行之后,可以直接点击「Finish」,之后通过终端命令 ollama 操作 Ollama,毕竟 Ollama 没有提供 UI 界面,当然也有很多三方的界面。   之后在终端中直接运行 ollama run llama3.2:3b,等待模型拉取完成后,即可直接与模型对话。之后可以在 Ollama 官网 Models 搜索尝试更多模型。 命令解析: ollama pull [model:tag]:拉取模型; ollama run [model:tag]:运行拉取的模型,若没有,会自动拉取之后运行; ollama list:查看全部已拉取的模型; ollama show [model:tag]:显示模型信息; ollama rm [model:tag]:删除已拉取的模型; ollama ps:查看正在运行的模型; ollama stop [model:tag]:停止正在运行的模型;   Mac 中修改全局环境变量可通过 launchctl setenv 命令,eg: launchctl setenv OLLAMA_ORIGINS "*",允许 ollama 请求跨域。   Mac 中 ollama 拉取的模型文件默认放在 ~/.ollama/models 目录中,可通过修改 OLLAMA_MODELS 环境变量更改模型安装目录。   Ollama 默认服务地址端口是:127.0.0.1:11434,Mac 中查看进程监听的端口号命令为 lsof -nP -p <pid>。可通过修改 OLLAMA_HOST 环境变量更改默认端口,eg:launchctl setenv OLLAMA_HOST "0.0.0.0:6006"。   默认情况下,运行模型后,如果 5 分钟未与模型进行交互,将会自动停止该模型。   Mac 启动 Ollama 后,会在菜单栏上出现一个羊驼图标,但有时这个图标会被“刘海”挡住,导致无法退出 Ollama,这时可以使用 osascript -e 'tell app "Ollama" to quit' 命令退出 Ollama。   对于开发者,若需要更改默认端口,需修改环境变量:export OLLAMA_HOST=0.0.0.0:6006(如此可将 ollama 的默认端口修改为 6006),之后通过 ollama serve 命令启动 Ollama,通过该命令启动的不会在菜单栏上出现羊驼图标。若出现跨域问题,同样需要修改环境变量:export OLLAMA_ORIGINS="*"。 AnythingLLM 篇   AnythingLLM 有两种安装模式,一种是桌面版,一种是 Docker 版,桌面版只能本地使用,Docker 版相当于是服务版,支持多用户云端使用。本次选用的桌面版,基本的 RAG 功能也都有。Docker vs Desktop Version   第一次启动 AnythingLLM 时,有一些设置引导,设置「LLM 偏好」时选择 Ollama,其他的都默认即可。创建工作空间之后就可以上传本地文件,建立自己的知识库。   Shaun 在使用中感觉,AnythingLLM 响应还是比较慢,分析/提炼/归纳/总结本地文档的速度有限。可能是机器配置还是有点低了。 PDFMathTranslate 篇   命令行使用: 1 2 3 4 5 # 用 ollama 将本地文件 Black Hat Rust.pdf 从英文翻译为中文 pdf2zh Black\ Hat\ Rust.pdf -s ollama -li en -lo zh # 启动 pdf2zh 网页,免去命令行使用 Web 页面设置翻译参数 pdf2zh -i   出现 NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3' 警告时可忽略,不影响使用。   如果出现 huggingface_hub.errors.LocalEntryNotFoundError 错误,需要配置 hugging_face 国内镜像: 1 2 pip3 install -U huggingface_hub hf_transfer -i https://pypi.tuna.tsinghua.edu.cn/simple export HF_ENDPOINT=https://hf-mirror.com   同时下载模型到本地(需要具体看报错的是哪个模型,这里是wybxc/DocLayout-YOLO-DocStructBench-onnx): 1 huggingface-cli download --resume-download wybxc/DocLayout-YOLO-DocStructBench-onnx --local-dir .   选择本地ollama模型作为翻译服务的话,需要配置环境变量: 1 2 3 4 # 本地ollama服务地址 export OLLAMA_HOST=http://127.0.0.1:11434 # 选用phi4模型翻译 export OLLAMA_MODEL=phi4   有个浏览器插件「沉浸式翻译 - Immersive Translate」同样是个比较好用的翻译工具。不过对于 PDF 文件,都有可能出现译文重叠的现象,需要二次编辑一下,或者将 PDF 格式转换为其他格式(eg:html,epub 等,相关 issue)。 VSC 辅助编程插件篇 Continue    Continue 插件可结合本地的 ollama 使用 qwen2.5-coder:7b 模型,可辅助读/写代码,需打开以下设置: Continue: Enable Quick Actions Continue: Enable Tab Autocomplete Continue: Show Inline Tip 模型设置添加: 1 2 3 4 5 { "model": "qwen2.5-coder:7b", "title": "ollama-qwen2.5-coder", "provider": "ollama" } 自动补全设置中 apiKey 保持为空字符串就行。 若需要使用远程部署的 ollama 服务,可以新增参数 "apiBase": "http://<my endpoint>:11434"。 Cline    Cline 插件同样可结合本地的 ollama 使用,可辅助 CR 以及自动化优化修改代码。如果使用 vLLM 部署的 AI 服务,API Provider 选择 OpenAI Compatible,Bsae URL 填 http://ip:port/v1,API Key 随便填就行(eg: ollama),Model ID 则是模型名称(eg: deepseek-r1:14b)。 20250207 更新: 英译中模型推荐使用 qwen2.5:14b; 中文问答聊天模型推荐使用 deepseek-r1:14b,模型会输出详尽的思考过程; 后记   本地部署 LLM 的好处在于无数据泄漏问题,对于个人使用而言,轻量级的模型也差不多够用了,但即使已经轻量化了,本地运行大模型还是有点吃力,在 Shaun 的电脑上运行 phi4:14b 略显勉强(8 tok/s)。Mac 的内存和显存是共享的,后续如果买新的,有部署 LLM 的需求,最好把内存拉满,由于模型文件也相对较大,有条件的可以把 SSD 也拉满。希望后续大模型的推理能够进一步轻量化,效果也更好,真正实现人人都能使用。   自 GPT-3 出现以来,也就短短 4 年不到,从大规模的高性能 GPU 集群到单机部署,从胡言乱语到精准命中,各行各业都迎来了 LLM 的冲击,在可预见的未来,LLM 将深刻影响到每一个人,这种影响无关好坏,单纯只是时代的浪潮,LLM 将和操作系统,数据库一样,成为整个 IT 行业的基础设施,就 Shaun 而言,应该很难亲自动手去开发优化 LLM,能做的也就是尽可能的熟练使用。 参考资料 1、基于Ollama+AnythingLLM搭建本地私有知识库系统 2、ollama搭建本地个人知识库

2025/1/12
articleCard.readMore

工作中特殊场景下的黑魔法

前言   工作中偶尔会遇到一些特殊需求需要解决,这里记录一下。 需求篇 Mac 修改文件创建时间和修改时间   使用 setfile 命令: 修改创建日期:setfile -d "mm/dd/yy hh:mm:ss" filename 修改修改日期:setfile -m "mm/dd/yy hh:mm:ss" filename 同时修改 xxx.txt 文件两个时间为 2023-07-27 01:23:53: setfile -d "07/27/2023 01:23:53" -m "07/27/2023 01:23:53" ./xxx.txt Excel 修改创建时间   word 和 excel 本质上都是 zip 文件,可利用 openpyxl 修改 xlsx 文件元信息创建时间。对于 xls 文件,若文件有密码,需先去除密码,再将 xls 转换为 xlsx 文件,之后使用 openpyxl 修改时间。具体步骤如下: 用 AppleScript 将 xls 转换为 xlsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 def run_applescript(script): """运行 AppleScript 脚本""" subprocess.run(["osascript", "-e", script]) def xls_to_xlsx(file_path="./xxx.xls"): """使用 AppleScript 修改 Excel 文件元数据""" applescript = f''' tell application "Microsoft Excel" -- 打开 .xls 文件 set inputFile to "{file_path}" -- 修改为你的文件路径 open inputFile -- 获取当前工作簿 set wb to active workbook -- 定义输出文件路径 set outputFile to "{file_path}x" -- 修改为你想保存的文件路径 -- 保存为 .xlsx 格式 save workbook as wb filename outputFile file format Excel XML file format -- 关闭工作簿 close wb saving no end tell ''' run_applescript(applescript) 修改 xlsx 文件创建时间 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import openpyxl def modify_excel_metadata(file_path = "./xxx.xlsx"): # 打开 Excel 文件 wb = openpyxl.load_workbook(file_path) # 获取元数据(properties) # properties = wb.properties # print(properties.__dict__) dt = datetime.strptime("2023-01-07 14:00:45", "%Y-%m-%d %H:%M:%S") dt -= timedelta(hours=8) wb.properties.creator = "" wb.properties.modified = dt wb.properties.created = dt wb.save("./xxx_tmp.xlsx") 将 xlsx 转换为 xls 文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 def xlsx_to_xls(file_path="./xxx_tmp.xlsx"): """使用 AppleScript 修改 Excel 文件元数据""" applescript = f''' tell application "Microsoft Excel" -- 打开 .xls 文件 set inputFile to "{file_path}" -- 修改为你的文件路径 open inputFile -- 获取当前工作簿 set wb to active workbook -- 定义输出文件路径 set xlsFilePath to (inputFile as text) set xlsFilePath to text 1 thru -6 of xlsFilePath -- 去掉 ".xlsx" set xlsFilePath to xlsFilePath & ".xls" # log xlsFilePath -- 保存为 .xls 格式 save wb in xlsFilePath # save workbook as wb filename xlsFilePath file format Excel98to2004 file format with overwrite -- 关闭工作簿 close wb saving yes end tell ''' run_applescript(applescript) JPG 修改创建时间   利用 pillow 和 piexif 修改 jpg 文件 exif 信息时间 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 from PIL import Image import piexif def modify_jpg_exif(img_file="./xxx.jpg", time_str = "2023:01:07 14:00:45"): im = Image.open(img_file) if "exif" not in im.info: return exif_dict = piexif.load(im.info["exif"]) # for ifd in ("0th", "Exif", "GPS", "1st"): # for tag in exif_dict[ifd]: # print(ifd, tag, piexif.TAGS[ifd][tag], exif_dict[ifd][tag]) del exif_dict["1st"] del exif_dict["thumbnail"] exif_dict["0th"][piexif.ImageIFD.DateTime] = time_str.encode() exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = time_str.encode() exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = time_str.encode() exif_bytes = piexif.dump(exif_dict) im.save("./xxx_m.jpg", exif=exif_bytes, quality='keep', subsampling='keep')   Pillow 保存 jpg 图片默认会同时保存 JFIF 和 EXIF 头,若需要去掉 JFIF 头,需修改 Pillow JpegEncode.c 文件源码: 1 2 3 4 5 6 if (context->xdpi > 0 && context->ydpi > 0) { context->cinfo.write_JFIF_header = TRUE; context->cinfo.density_unit = 1; /* dots per inch */ context->cinfo.X_density = context->xdpi; context->cinfo.Y_density = context->ydpi; } 修改为: 1 context->cinfo.write_JFIF_header = FALSE; 之后执行:python3 -m pip -v install . 从本地源码安装 Pillow。 PDF 修改创建时间   使用 pikepdf 修改 pdf 文件元信息时间 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import pikepdf def modify_pdf_metadata(file_path="./xxx.pdf", time_str = "20241203140045+08'00'"): # 打开 PDF 文件 with pikepdf.open(file_path, allow_overwriting_input=True) as pdf: ## 获取 PDF 的元数据 # metadata = pdf.docinfo # for key, value in metadata.items(): # print(f'{key}: {value}') # 修改元数据 pdf.docinfo["/CreationDate"] = time_str pdf.docinfo["/ModDate"] = time_str # 保存文件 pdf.save()

2024/12/22
articleCard.readMore

HTTP 超时浅见

前言   最近业务调用方反馈接收到服务连接中断的错误(python requests 请求抛出异常 raise ConnectionError(err, request=request) \n ConnectionError: ('Connection aborted.', BadStatusLine("''",))),但从 golang 服务日志中看,服务应该是正常处理完成并返回了,且抛出异常的时间也基本和服务返回数据的时间一致,即表明在服务响应返回数据的那一刻,请求方同时抛出异常。   这个问题很奇怪,起初拿到一个 case 还无法稳定复现,最初怀疑是网络抖动问题,但后续一直会偶发性出现,直到拿到了一个能稳定复现的 case,深入跟踪排查后才发现与网络问题无关,是服务端框架应用设置不合理的问题。 问题篇   从网上搜索 python ConnectionError: ('Connection aborted.'),错误种类非常多,有网络问题,服务端问题(关闭连接,拒绝服务,响应错误等),客户端关闭连接,超时设置不合理,请求参数/协议错误等等,但若带上 BadStatusLine("''",) ,错误就相对比较明确了(BadStatusLine Error in using Python, Requests,Python Requests getting ('Connection aborted.', BadStatusLine("''",)) error),主要是由于收到了一个空响应(header/body),空响应可以明确是服务端返回的问题,一般可能有以下几个原因:1. 服务端反爬;2. 服务端超时(比如 nginx 默认 60s 超时);3. 网络错误。   由于是内部服务,所以反爬策略是没有的,而反馈的 case 都带有明显的特征(请求数据量大,处理耗时长),没有网络抖动那种随机性,所以应该也不是网络问题,剩下的只能是超时问题,由于业务方在前置策略上已经识别该 case 数据量大,所以不经过 nginx 网关,直连服务请求,所以也不会有 nginx 超时问题,只能是服务端自己超时。于是直接在代码中查找 timeout 关键字,发现在服务启动时设置了 ReadTimeout 和 WriteTimeout,进一步深挖之后,才对 go 服务的超时有了浅显的认识。 超时篇 参考资料:1. 你真的了解 timeout 吗?,2. i/o timeout , 希望你不要踩到这个net/http包的坑,3. net/http完全超时手册。   由于 HTTP 协议规范并未提及超时标准,而为保证服务稳定性,一般的 HTTP 服务请求都会设置超时时间,各 HTTP 服务端/客户端对于超时的理解大同小异,而这次的问题又起源与 go 服务,所以以 go 为例,分析一下超时。 客户端超时 http.Client.Timeout   客户端超时,即 GET/POST 请求超时,这个很好理解,就是客户端发送请求到客户端接收到服务器返回数据的时间,算是开发的一般性常识,控制参数一般也特别简单,就是一个 timeout,当然 go 服务客户端支持设置更精细化的超时时间,一般也没啥必要。当客户端感知到超时时,会正常发起 TCP 断开连接的“四次挥手”过程。 服务端超时 http.Server Timeouts   服务端超时,这才是引发问题的根本原因,go 服务端的超时,主要有两个参数,ReadTimeout 和 WriteTimeout,从上图可以看出,ReadTimeout 主要是设置服务端接收请求到读取客户端请求数据的时间(读请求的时间),WriteTimeout 是服务端处理请求数据以及返回数据的时间(写响应的时间)。GoFrame 框架的 ReadTimeout 默认值是 60s,在请求数据正常的情况下 ReadTimeout 也不可能超时,这次的问题主要出在 WriteTimeout,GoFrame 的默认值是 0s,代表不控制超时,但之前的开发者也同样设置为了 60s,导致服务端在处理大量数据时,发生了超时现象。   更深挖之后,才发现 WriteTimeout 的诡异之处,当 WriteTimeout 发生之后,服务端不会即时返回超时消息,而是需要等服务端真正处理完之后,返回数据时,才会返回一个空数据,即使服务端正常写入返回数据,但都会强制为空数据返回,导致请求客户端报错。这种表现,看起来就像是 WriteTimeout 不仅没有起到应有的作用,在错误设置的情况下,还会起到反作用,使服务响应错误。WriteTimeout 无法即时生效的问题,也同样有其他人反馈了:1. Diving into Go's HTTP server timeouts;2. net/http: Request context is not canceled when Server.WriteTimeout is reached。可能是网上反馈的人多了,go 官方推出了一个 TimeoutHandler,通过这个设置服务端超时,即可即时返回超时消息。仿照官方的 TimeoutHandler ,即可在 GoFrame 框架中也实现自己的超时中间件。   至于 WriteTimeout 为啥不起作用,个人猜测主要原因在于 go 服务每接收到一个请求,都是另开一个协程进行处理,而 goroutine 无法被强制 kill,只能自己退出,通常是要等到 goroutine 正常处理完之后才能返回数据,WriteTimeout 只是先强制写一个空数据占位,返回还是得等 goroutine 正常处理完。   所以正常的 go 服务,在使用类似于 TimeoutHandler 中间件的时候,也最好让 goroutine 尽可能快的退出,一种简单的方法是:1. 设置请求的 context 为 context.WithTimeout;2. 分步处理数据,每一步开始前都先检查请求传入的 context 是否已经超时;3. 若已经超时,则直接 return,不进行下一步处理,快速退出 goroutine。 后记   这次问题排查,碰到的最大障碍在于,前几次反馈的 case 难以复现,客户端请求报错和服务器返回的时间一致也不会让人往超时的角度去想,在拿到一个能稳定复现的 case 之后,才死马当活马医,先调一下超时参数试试。   关于 go 服务超时的文章,其实之前也看过,但没碰到具体问题,名词也就仅仅只是名词,很难理解背后的含义和其中的坑点,实践才能出真知 ╮(~▽~)╭。 附录 长连接超时   关于超时问题,也曾看到过有人碰到一个长链接服务的问题,现象是这样的:后端服务宕机之后,客户端可能需要很久才会感知到,原因在于 tcp 的超时重传机制,在 linux 中,默认会重传 tcp_retries2=15 次(即 16 次才会断开连接),而 TCP 最大超时时间为 TCP_RTO_MAX=2min,最小超时时间为 TCP_RTO_MIN=200ms。即在 linux 中,一个典型的 TCP 超时重传表现为: 重传次数发送时间超时时间 -1(原始数据发送)0s0.2s 0 (第 0 次重传)0.2s0.2s 10.4s0.4s 20.8s0.8s 31.6s1.6s 43.2s3.2s 56.4s6.4s 612.8s12.8s 725.6s25.6s 851.2s51.2s 9102.4s102.4s 10204.8s120s 11324.8s120s 12444.8s120s 13564.8s120s 14684.8s120s 15804.8s120s 断开连接924.8s(≈15min) 所以客户端需要在 15 分钟之后才能感知到服务端不可用,如此,仅靠 TCP 自身的超时机制,很难发现服务端是否宕机/不可用,长链接不释放,进而可能导致客户端不可用且无感知,所以在长链接服务中,需要有其他的手段来保障服务稳定/可用性(eg:心跳探活)。 服务端 context canceled Refer to: context canceled,谁是罪魁祸首   从官方的 net/http 包中可以知道,go 服务在接收请求时,会同时生成一个协程监控连接状态,当发现连接有问题(eg:客户端设置请求超时主动断开)时,会将该请求对应的 context cancel 掉,这时服务端如果再继续使用该 context 时,就会报错「context canceled」。当然,如果服务端发生错误,也同样会导致请求对应的 context cancel 掉。   服务端主动 cancel context 的好处在于可以快速释放资源,避免无效的请求继续执行(当然也得业务代码上主动去感知 context 是否 cancel,从而及时退出);坏处在于,如果服务端需要上报这个请求发生的错误(一般在后置中间件中进行错误上报),这个时候上报错误的请求需要另外生成一个新的 context,绝不能直接使用现有的 context,因为已有的这个 context 已经 cancel 掉了,继续使用会导致上报错误的请求发送失败,达不到上报的目的。

2024/5/12
articleCard.readMore

关于中学的学习方法

前言   前些日子,小叔说堂弟的学习有点不太能跟上了,让 Shaun 和堂弟聊聊,回想十几年前, 父亲也是这样找堂哥的,仍记得那年的寒暑假,算是 Shaun 进步最快的一年,也是奠定 Shaun 后续学习方法的一年,现在轮到小叔来找 Shaun ,虽说不能当面聊,指导效果会大打折扣,而且当年堂哥教的具体方法也早已忘记,转化为自己的思想和方法,所以 Shaun 也只能把自己的东西说给堂弟,也算是某种意义上的传承。 序篇    Shaun 一直认为学习是有天赋,这种天赋体现在学习某一方面的事特别长记性,看个几眼就能完全记在脑海里,还能灵活变通记得的东西。同时,学习也需要方法的,在天赋不够的情况下,有个好的学习方法也能事半功倍。最后,学习是需要积累的,所谓的积累,就是增长见识,多练习,就中学而言,积累就是多做不同的题,同一类但举一反三的变题,在积累的足够多的情况下,考场上同样的题至少都是见过的,没有太多的心理压力,自然会好解一些。   当然中学的学习毕竟是通过考试来验证结果的,而这个结果才是最重要的(也算是一种唯结果论,不过现实如此,社会如此,没人能逃过,只以成败论英雄,唯一需要注意的是英雄很多时候是有保质期的,扯远了 😅),所以应试技巧也很重要,考试是一个在一定时间内如何得分最多的任务,即使是所有的题都能解,但超时了也没用,更何况大部分人只能解一部分,所以对于这种任务,最好是先快速扫一下卷子,心里先有个数(大概都是些啥题),后面再按部就班的的做,性价比低(要花费大量时间,得分又低)的后面再解。当然在绝对的实力面前,所谓的应试技巧都是虚幻,打铁还是得自身硬,应试本质上是一个熟练的事,需要大量的练习,简而言之就是多刷题 🤪。   闲话说完了,下面就是正文了,由于 Shaun 是理科生,仅记录 Shaun 还能记得的当年理科六门学科的学习经验。 正篇 语文   语文一直是 Shaun 的弱项,不过从 Shaun 现在的经验再回过头去看语文,感觉语文考验的更多是对人生和社会的一种感悟,这种感悟不仅仅只是对于自身的体验,也是对别人人生经历和当时社会的一种体会。在学生时代,大部分人受限于家庭和外部环境因素,自身体验很少有丰富的,只能多体会别人的人生,别人的人生只能依赖多看书(小说传记历史都可以),最重要的是在读的时候能有自己的一些思考,假如自己在别人的处境下会是一种什么心态,会有什么行动,一些好的文章,作者为什么会那样描写,遣词造句。当然语文也有直接需要记忆的,字词拼音,古诗文这种,就全靠记忆背诵了。 数学   中学数学最重要的两个分支就是代数和几何,以及介于两者之间的解析几何,于是也有了数学中最重要的思想——数形结合,抽象的数字有了形状,就不再那么枯燥。熟练使用函数图像以及对应的特点,数学及格就没啥问题了,至于几何,立体空间想象力不够的情况下,也可以加坐标系当解析几何计算了,不过就是时间花的多些。   导数算是函数中最核心的概念(导数以及对应的微分也会在高等数学中贯穿始终),函数导数的几何意义就是对应点切线的斜率,当在实际的物理场景下,导数也有其实际意义,比如路程关于时间的导数就是速度,速度关于时间的导数就是加速度。   数列可以认为是一种纯数字游戏,虽然通项公式或者递推式可以认为是某种函数,但数列本质还是数字自身的规律,这种更多的是经验和一种直觉,发现不了就是不能发现,无从下手也无法计算。   集合和数理逻辑,不等式,极值,推理与证明,对应的反证法。概率与排列组合,这类问题熟记公式,太难的问题,不会就是不会了 🙃。   向量计算,数形结合完美的体现,中学物理的利器,三角函数,向量的内外积,单位向量的意义,这些东西还是只能在练习中画图理解。向量这个数学工具的美,也只能在实际应用中体会,角度,投影,面积,距离(点点/点线/点面距离),坐标变换(旋转/平移/缩放)等等。 英语   英语也是 Shaun 不太在行的,尤其是现在回想 Shaun 整个中学,英语及格的次数都屈指可数,初中英语最后一次考试能及格还是靠初三下死命的记单词,而高中英语也是到高三才能稳定的及格,原因也是单词和语法记少了,更重要的原因是对死记硬背很是反感,甚至由于这个原因还和高二的英语老师对着干,一上英语课 Shaun 就直接出去了,后来还好高三换了个英语老师,给 Shaun 稍微开了一段时间的小灶,就是让 Shaun 每天写篇英语作文,然后针对这篇作文进行指导批改,这种方式很适合 Shaun ,从此也算是踏上了英语及格之路, Shaun 现在依然很感激高三的英语老师。至于英语听力,这个没办法,只能靠多听,以 Shaun 现在的经验看来,每天都有一定的时间处在英语环境下,确实能提高听力水平,多听的频率很重要,不然过一段时间就没那种感觉了。 物理   尤记得高一的物理也有很多次没及格,后来在堂哥的指导下,物理好歹也算是入门了,每次考个 80 分都还算轻松。目前还能记得堂哥教的物理学习方式就是手推公式,当然手推公式同样能应用到数学和化学上。所谓的手推公式就是利用一些基础的公式推导出一个复杂的公式,或者是两个复杂的公司来回推导,能够熟练的手推公式,圆周运动和电磁场问题公式层面的问题就能比较清楚了。至于受力分析,支撑力与面垂直,摩擦力与面平行,杆提供支撑力或许也有拉力,绳只提供拉力,可以假设圆周运动的离心力真实存在,与向心力平衡。至于能量守恒和动量守恒,这个只能多刷题了。   物理是和数学强绑定的一门学科,数学不行,物理不可能会好的,所以要学好物理得先学好数学。 化学    Shaun 算是有一定天赋的,看几遍书上的内容,就基本上都能记住了,不管是无机还是有机化学实验也基本都很清晰会有啥现象,每个元素的性质当时也都能记得,以至于看到一些常见的物质大概就能知道会有啥反应。不过还记得当时对于化学方程式配平, Shaun 还只能靠眼睛看,没啥方法,后来堂哥教了个得失电子法,同时针对性的做了大量的题,让 Shaun 领先全班一个学期熟练使用这个方法,在配平这类问题上基本没怎么丢过分。在刷题的过程中,也可以活用一些书上没见过的公式,曾经有次看到一个理想气体状态方程的公式,发现用这个公式可以很轻松的解释一些化学平衡的移动问题。化学在 Shaun 这里没怎么太刷过题,感觉就靠多看书了,熟记元素和物质的物理化学性质。 生物   生物感觉没太多好说的,就实验而言和化学有点像,但需要记忆的东西更多,最需要计算的题也就是染色体概率和群落数量估计问题了,不过就算不会算,丢分也不多。 总结   刷题是一种很有效的应试技巧,国内的大部分考试都能通过刷题解决,如果解决不了,那就多刷几遍,针对性的刷题会更有效果。   死记硬背也是一种方式,但能活学活用更重要,在使用中记忆会更好,理科有个很重要的思想就是推理,大部分结论或公式都能通过一些简单前提或公式推导出来,可以试试自己推导一些常用的公式(关于推导,数学科普领域有本书叫「天才引导的历程」可以看看),注重平时的练习,不要怕麻烦,熟才能生巧。   至于错题本,得看收集错题的方式,最好是一类题收集在一起,每种解题方式各收集一个经典的题型,后续有时间就翻翻回顾下,就 Shaun 个人的经验,记得很杂的错题本,往往起不到应有的效果,针对性的学习很重要,需要注意的是错题集不要做成了难题/怪题集。   独立思考,本意是指不要人云亦云,需要有自己的思考和看法(这本应该是每个人的必备技能,但没有的人确实不少)。在学习领域,特指在寻求问题答案的过程中,一定先得有个自己的思考过程,苦思不得的问题会更深刻,同时思考的过程也是自己串通知识点的过程,更容易知道自己的盲区。   因材施教,同样也因人学习,每个人在不同的学习环境下学习效率是不同的,有些人需要被人催促,需要更有压力一点才能学的好,而有些人更主动一些,在宽松的环境下学习更有效果。而目前的学校都是填鸭式教育,一视同仁,虽说每个学校的教学风格不太一样,但不一定适合学校内的每个学生,所以需要找到适合自己的方式。 后记   回顾整个高中生涯,对 Shaun 影响最大的其实还是堂哥和高三的英语老师,当时的班主任虽然对 Shaun 也很好,但对 Shaun 的学习和做事方式影响就没那么大,只记得当时班主任常说的一句话——读书是能改变命运的。对于大部分人,读书确实是最可行的出路,其他的路不确定性会更多,虽说读书需要一定的天赋,但国内应试教育的本质注定了努力刷题是能弥补这一部分天赋的,当然,如果有个人能在刷题的路上再稍微指导一下,会走很多弯路,也更容易找到适合自己的学习和思考方式。

2024/3/3
articleCard.readMore

VNSWRR 算法浅解

前言   最近偶然在公司内网看到一篇文章「负载均衡算法vnswrr改进——从指定位置生成调度序列」。正好 Shaun 一直觉得调度类算法很有意思,就认真看了下,顺便写下自己的一些理解。 预备篇   通俗来讲负载均衡解决的是「在避免机器过载的前提下,多个请求如何分发到多台机器上」的问题,本质上是一个分布式任务调度的问题,在机器性能相同的情况下,最简单的策略就是轮询,多个机器依次轮流处理请求。Nginx 官方的 SWRR 算法解决的是「在机器性能不同的情况下,如何使请求分布更均匀,更平滑,避免短时间大量请求造成局部热点」的问题。 SWRR篇   在 SWRR 算法中,有两个权重,一个是初始实际权重(effective weight, ew),一个是算法迭代过程中的当前权重(current weight,cw),在负载均衡过程中,每次请求分发都选择当前权重最大的机器,同时更新每台机器的当前权重,当前权重更新策略如下: 若设定 n 台机器各自的初始权重为 \((ew_1,ew_2,...,ew_n)\),同时 \(ew_1 \le ew_2 \le ... \le ew_n\) ,且 \(W_{total}=\sum_{i=1}^n ew_i\) ; 第一个请求来时,n 台机器各自的当前权重 \(cw_i=ew_i, 1 \le i \le n\) ,由于此时 \(cw_{max}=\max(cw_i)=cw_n\) ,则请求分发给第 n 台机器处理,同时更新机器各自的当前权重 \(cw_1=cw_1+ew_1, cw_2=cw_2+ew_2,...,cw_{n-1}=cw_{n-1}+ew_{n-1},cw_n=cw_n+ew_n-W_{total}\),记为 \((2*ew_1,2*ew_2,...,2*ew_{n-1},2*ew_n-W_{total})\) ; 第二个请求来时,此时 n 台机器的各自权重为 \((2*ew_1,2*ew_2,...,2*ew_{n-1},2*ew_n-W_{total})\) ,选取权重值对应的机器进行处理,假设为第 n-1 台,则更新后权重为 \((3*ew_1,3*ew_2,...,3*ew_{n-1}-W_{total},3*ew_n-W_{total})\) ; 第 \(W_{total}\) 个请求来时,此时 n 台机器的各自权重应该为 \[(W_{total}*ew_1-m_1*W_{total},W_{total}*ew_2-m_2*W_{total},...,W_{total}*ew_{n-1}-m_{n-1}*W_{total},W_{total}*ew_n-m_n*W_{total}) \\\text{s.t.} \quad \sum_{i=1}^n m_i=W_{total}-1 \\\quad 0 <= m_i <= ew_i\] 由于每次调度都是权重最大值减权重和,重新分配权重后权重和无变化,所以理论上此时除第 k 台机器外,每台机器的权重都为 0,第 k 台机器的权重为 \(W_{total}\) ,所以这次调度处理之后,每台机器的权重又会重新回到初始权重。 VNSWRR 篇   VNSWRR 算法是阿里针对 Nginx 官方的 SWRR 算法实际运行中对于部分场景下(瞬时流量大,权重更新等)均衡效果不太理想的改进算法,其最大的改进点在于预生成调度序列,以空间换时间减少调度时间,同时在权重更新后随机选取调度序列的起点,使初次请求就调度在不同的机器上,减少高权重机器的局部热点问题。具体流程如下: 首先使用 SWRR 算法生成前 n 个调度序列; 再随机选取一个位置作为调度起点,后续的请求依次从调度序列中选取; 若调度序列用完,则继续用 SWRR 算法生成后 n 个调度序列; 如此循环,直到调度序列的长度为 \(W_{total}\),即一个周期内的全部调度序列,用完后,从头开始调度即可; 若有权重更新,则从 1 开始重新生成调度序列; 正文   从上面的逻辑中,可看出 SWRR 算法调度序列是以 \(W_{total}\) 为周期的一个循环序列,只需要知道一个周期内的调度序列,就可以推算出后续的调度机器(除非权重有变更或者有机器增删)。计算一个周期内的调度序列也比较简单,取当前调度权重中最大值对应机器,同时更新每台机器的当前权重,作为下次调度的权重,简而言之,就是从上次调度结果推出下次调度结果,是一个递推式。那有没有办法不从上次结果推下次结果,直接计算当前的调度结果,简化 VNSWRR 的第一步每次都从头开始预生成前 n 个调度序列,直接从任意位置开始生成调度序列,内网中这篇文章就给出了一个看似“可行的”解决方案,直接计算第 q 个请求的调度结果,具体方案如下: 在 SWRR 算法中,第 q 个请求时,全部机器的当前权重序列应该为 \[(q*ew_1-m_1*W_{total},q*ew_2-m_2*W_{total},...,q*ew_{n-1}-m_{n-1}*W_{total},q*ew_n-m_n*W_{total}) \\\text{s.t.} \quad \sum_{i=1}^n m_i=q-1 \\\quad 0 <= m_i <= ew_i\] 即权重序列中共减去了 \(q-1\) 个 \(W_{total}\) ,平均上 \(m_i=ew_i/W_{total}*(q-1)\),区分 \(m_i\) 的整数部分 \(mz_i\) 和小数部分 \(mx_i\),\(\sum_{i=1}^n m z_i\) 代表减去的 \(W_{total}\) 个数,计算差值 \(d=q-1-\sum_{i=1}^n mz_i\),即还剩 d 个 \(W_{total}\) 待减,对小数部分 \(mx_i\) 从大到小排序,取前 d 个对应的机器再减 \(W_{total}\),即可得到第 q 个请求时的当前权重序列,取最大权重对应的机器即为调度结果,后续调度结果可通过递推式得出。   初次看到这个方案的时候,就想动手实现一下,因为思路也比较清晰简单,实现完之后,简单测试一下,也确实没啥问题,后面再深度测试了一下,就发现该方案确实有点小小的问题,在大部分情况下,该方案确实能得到很正确的结果,但还是存在一些错误结果,就因为有少量错误结果,所以该方案不要在生产环境下应用。该方案错在了将 \(q*ew_i\) 看成最后一个整体进行处理排序,忽略了分步执行结果,导致小部分场景下的错误排序结果,进而生成错误调度权重,调度错误。   现在再回到初始问题「如何生成 SWRR 算法中指定轮次的调度结果?」,抽象来看,该问题是个数学问题「如何从数列的递推式计算数列求通项公式」, 但 SWRR 的递推式相对复杂,中间还有取最大值这个不稳定变量,实际很难得到通项公式,直接计算指定调度解果,Shaun 问了 ChatGPT,也自己想了很久,搜了很久,但都没有答案,内网中的这个方案算是最接近的一个答案。 后记   在内网中看到这个方案的思路很有意思,将整数和小数部分拆开,再单独对小数部分排序,所以就自己测试了一下,顺便学习了下负载均衡 SWRR 算法,虽然问题依旧还在,但总归是有点收获。 附录   附代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 import random def ouput_schedule(rs_arr, schedule_num): all_rs_weight_str = ";\t".join(["rs:%s,cw:%s" % (rs["rs_name"], rs["cw"]) for rs in rs_arr]) schedule_rs = max(rs_arr, key=lambda x:x["cw"]) print("%s:\t%s\t===>\trs:%s,cw:%s" % (schedule_num, all_rs_weight_str, schedule_rs["rs_name"], schedule_rs["cw"])) return schedule_rs def swrr(rs_arr, weight_total): schedule_rs = rs_arr[0] max_weight = schedule_rs["cw"] for rs in rs_arr: if rs["cw"] > max_weight: schedule_rs = rs max_weight = rs["cw"] rs["cw"] += rs["ew"] schedule_rs["cw"] -= weight_total return schedule_rs def swrr_test(): real_servers = [{"rs_name": chr(i+64), "ew": i, "cw": i} for i in range(1, 6)] weight_total = sum([rs["ew"] for rs in real_servers]) schedule_count = weight_total swrr_seq = [] for i in range(1, schedule_count+1): ouput_schedule(real_servers, i) schedule_rs = swrr(real_servers, weight_total) swrr_seq.append(schedule_rs["rs_name"]) print(swrr_seq) # swrr_test() # print("---------") def swrr_n(rs_arr, weight_total, schedule_num): ms = [(rs["ew"] / float(weight_total)) * (schedule_num-1) for rs in rs_arr] mzs = [int(m) for m in ms] mxs = [(i, m-int(m)) for i, m in enumerate(ms)] mxs = sorted(mxs, key=lambda x:x[1], reverse=True) for i, rs in enumerate(rs_arr): rs["cw"] = schedule_num * rs["ew"] rs["cw"] -= mzs[i] * weight_total d = (schedule_num-1) - sum(mzs) for i in range(d): rs_arr[mxs[i][0]]["cw"] -= weight_total schedule_rs = ouput_schedule(rs_arr, schedule_num) return schedule_rs def swrr_n_test(): real_servers = [{"rs_name": chr(i+64), "ew": i, "cw": i} for i in range(1, 6)] weight_total = sum([rs["ew"] for rs in real_servers]) schedule_rs_seq = [] for i in range(1, weight_total+1): schedule_rs = swrr_n(real_servers, weight_total, i) schedule_rs_seq.append(schedule_rs["rs_name"]) # swrr_n(real_servers, weight_total, 9) # err schedule rs print(schedule_rs_seq) # swrr_n_test() def vnswrr_preschedule(rs_arr, weight_total, N, schedule_rs_seq): for i in range(1, N+1): schedule_rs = swrr(rs_arr, weight_total) if len(schedule_rs_seq) >= weight_total: break schedule_rs_seq.append(schedule_rs) def vnswrr(rs_arr, rs_count, weight_total, prev_schedule_idx, schedule_rs_seq): N = min(rs_count, weight_total) schedule_idx = prev_schedule_idx + 1 schedule_idx %= weight_total if schedule_idx >= len(schedule_rs_seq)-1: vnswrr_preschedule(rs_arr, weight_total, N, schedule_rs_seq) return schedule_idx def vnswrr_test(): all_schedule_rs_seq = [] real_servers = [{"rs_name": chr(i+64), "ew": i, "cw": i} for i in range(1, 6)] rs_count = len(real_servers) weight_total = sum([rs["ew"] for rs in real_servers]) N = min(rs_count, weight_total) schedule_rs_seq = [] # 预生成调度序列 vnswrr_preschedule(real_servers, weight_total, N, schedule_rs_seq) # 随机取调度结果 prev_schedule_idx = random.randint(0, N-1)-1 for i in range(1, 2*weight_total+1): schedule_idx = vnswrr(real_servers, rs_count, weight_total, prev_schedule_idx, schedule_rs_seq) all_schedule_rs_seq.append(schedule_rs_seq[schedule_idx]["rs_name"]) prev_schedule_idx = schedule_idx print([rs["rs_name"] for rs in schedule_rs_seq]) print(all_schedule_rs_seq) vnswrr_test() 参考资料 1、QPS 提升60%,揭秘阿里巴巴轻量级开源 Web 服务器 Tengine 负载均衡算法 2、Nginx SWRR 算法解读

2024/2/7
articleCard.readMore

记一次资源不释放的问题

前言   最近发现一个 GoFrame 服务即使空载 CPU 使用率也很高,每次接受请求后资源没有被释放,一直累积,直到达到报警阈值,人工介入重启服务,于是压测排查了一下。 问题篇   先新增代码启动 go 自带的 pprof 服务器: 1 2 3 4 5 6 7 8 9 10 11 12 package main import ( "net/http" _ "net/http/pprof" ) func Pprof(pprof_port string) { go func(pprof_port string) { http.ListenAndServe("0.0.0.0:"+pprof_port, nil) }(pprof_port) } 压测以及 profile 命令: 1 2 3 4 5 6 7 8 9 10 11 12 # 压测命令 wrk -t8 -c1000 -d60s --latency --timeout 10s -s post_script.lua http://host:[srv_port]/post # profile 整体分析 go tool pprof -http=:8081 http://host:[pprof_port]/debug/pprof/profile?seconds=30 # 查看函数堆栈调用 curl http://host:[pprof_port]/debug/pprof/trace?seconds=30 > ./pprof/trace01 go tool trace -http=:8081 ./pprof/trace01 # 查看内存堆栈 go tool pprof -http=:8081 http://host:[pprof_port]/debug/pprof/heap?seconds=30   在压测 30 次后,即使服务空载 CPU 也被打满了,查看服务此时的 profile,发现 goroutine 的数目到了百万级别,查看 cpu 堆栈发现集中调用在 gtimer 上,但遍寻服务代码,没有直接用到 GoFrame 的定时器,问题出在哪也还是没想太明白。吃完饭后偶然灵光一现,既然 CPU 看不出啥,那再看看内存,查看内存发现,内存对象最多的是 glog.Logger,看代码也正好有对应的对象,可算是找到问题真正的元凶了。   log 对象一般都是全生命周期的,不主动销毁就会一直伴随着服务运行,所以 log 对象一般都是程序启动时初始化一次,后续调用,都是用这一个对象实例。而这次这个问题就是因为在代码中用 glog 记录了数据库执行日志,每次请求都会重新生成一个 glog 对象,又没有主动释放造成的。   知道问题的真正所在,解决问题就相对很简单了,只在程序启动时初始化一个 glog 对象,后续打印日志就用这一个实例,其实更好的方式是生产环境不打印数据库日志,毕竟影响性能。 后记   CPU 资源的占用往往伴随着内存资源的占用,当从调用堆栈以及线程资源上看不出问题的时候,可以转过头来看看内存堆栈,毕竟内存堆栈更能指示有问题的对象出在哪,知道内存对象是谁,也相当于提供了排查问题代码的方向。 附录   在排查过程中发现 goroutine 数目异常的高,于是想限制一下 goroutine 数目,在网上搜索的时候发现当用容器部署 go 服务时,go 默认最大的 goroutine 数目为宿主机 cpu 核数,而不是容器的 cpu 核数,从而并发时 goroutine 数目可能比容器 cpu 核数高很多,造成资源争抢,导致并发性能下降,可以通过设置环境变量 GOMAXPROCS 指定 goroutine 最大数目,也可以使用 go.uber.org/automaxprocs 库自动修正最大核数为容器 cpu 核数。 自适应设置 GOMAXPROCS 上下限代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 package main import ( _ "go.uber.org/automaxprocs" "runtime" ) func main() { procsNum := runtime.GOMAXPROCS(-1) if procsNum < 4 { procsNum = 4 } else if procsNum > 16 { procsNum = 16 } runtime.GOMAXPROCS(procsNum) // todo something... } python 内存泄漏排查 ※注:python 的默认参数是全局变量,若默认参数为一个引用类型(eg:字典对象),且函数中会对该参数进行写操作,就极有可能发生内存泄漏,所以 python 默认参数最好是值类型。 方法一是线上程序直接排查,通过 pyrasite 和 guppy 直接对应 python 程序: step1:绑定 python 程序 pid,开启 pyrasite shell 窗口,执行 pyrasite-shell <pid>; step2:使用 guppy 查看 python 程序内存情况, 1 2 3 >>> from guppy import hpy >>> h = hpy() >>> h.heap() step3:间隔一定时间后,再次使用 h.heap(),对比两次内存变化 该方法一般只能粗略查看内存泄露的数据对象,可能无法精确定位到指定位置,这时需要用方法二,手动插入代码查看程序运行日志: Python标准库的gc、sys模块提供了检测的能力 1 2 3 4 5 6 import gc import sys gc.get_objects() # 返回一个收集器所跟踪的所有对象的列表 gc.get_referrers(*objs) # 返回直接引用任意一个 ojbs 的对象列表 sys.getsizeof() # 返回对象的大小(以字节为单位)。只计算直接分配给对象的内存消耗,不计算它所引用的对象的内存消耗。 基于这些函数,先把进程中所有的对象引用拿到,得到对象大小,然后从大到小排序,打印出来,代码如下: 1 2 3 4 5 6 7 8 9 10 11 import gc import sys def show_memory(): print("*" * 60) objects_list = [] for obj in gc.get_objects(): size = sys.getsizeof(obj) objects_list.append((obj, size)) for obj, size in sorted(objects_list, key=lambda x: x[1], reverse=True)[:10]: print(f"OBJ: {id(obj)}, TYPE: {type(obj)} SIZE: {size/1024/1024:.2f}MB {str(obj)[:100]}") 找到内存占用稳定增长的对象,调用 gc.get_referrers(*objs),查看该对象的引用信息,即可快速定位泄漏位置 该方法更加灵活精确,不好的地方是有侵入性,需要修改代码后重新上线,同时获取这些信息并打印,对性能有一定的影响,排查完之后,需要将该段代码下线。 参考资料 1、python内存泄露问题定位:附带解决pyrasite timed out 2、技术 · 一次Python程序内存泄露故障的排查过程

2023/5/1
articleCard.readMore

社畜三年,风雨兼程

前言   财富和幸福算是绝大部分人的毕生追求,所以在读这本书时,更容易让人有一种思想上的共鸣,但一个人的成功总是独一无二的,需要依靠天时地利人和,正所谓,学我者生,似我者死,可以学习借鉴成功者的一些思想,但不要想着沿着成功者的路继续走下去就能成功。 ——读『纳瓦尔宝典』 感想篇   财富并不代表金钱或者地位,而是某种可以自然增长的东西,是一种追求共赢的东西,是一种可以长期存在的东西。最好的投资总是学习,成本低且有效,但接受新知识总是需要消耗更多的能量,而人总是想尽量减少能量的消耗,所以很多人都沉迷于快餐文化中,不需要过多的思考,又消磨了时光,一举两得 ( ̄ε(# ̄)☆╰╮( ̄▽ ̄///)。   人生总有选择,也总要承担选择所带来的后果。选择永远比努力重要,光凭运气去选择,或许能瞎猫碰上死耗子,但狗屎运总归会用完,下一次就不好说了。如何选择是需要去学习的,有从自身过去的经验去学习,有从别人身上去学习,更多的是依靠平时积累的各种信息,这就是努力了,努力或许有结果,也或许没有,但不努力,一定没结果。努力的目的是为了有更多的选择,在有更好的选择时能及时判断,不要单纯的为了结果而努力,很有可能得不偿失。付出和回报看对人对事吧,对自己的付出当然总是会有回报的,对事情付出同样会有,但对其他人付出,就别太指望一定要有啥回报,可以设个底线,在没有任何回报的情况下,能付出多少。   选择也从来不是一竿子买卖,局部最优也不代表全局最优,人生的选择更是如此,十年前的当时最优放在现在看可能选错了,放在十年后再看可能又选对了,时代在发展,当然倘若十年前真选对了,可能就不需要十年后再回过头来看了 :P。绝对最优这个词在人生道路上就不存在,只能说是相对更优,这个更优不是选项之间的对比,而是选项与自身的对比,哪个选项最能提升自己就选哪个就行,长远来看,投资自己不一定能飞黄腾达,但总能有口饭吃。投机是选择,稳扎稳打也是选择,本身无优劣,回报和风险总是共存的,能承受的起就行,别光见贼吃肉,不见贼挨打。   获取金钱的方式有很多,最快的方式都写在刑法里 ๑乛◡乛๑,而创造财富的方式也有很多,最直接的是去资本市场做投资,也可以想方设法积累自己的名气(不管是好是坏),然后走流量变现。创造财富都有其风险,就算是大航海时代,不也一大批人沉在海底,打工人虽然是在给别人创造财富,但对自身而言,也算是一种资本的原始积累,而且是相对最稳定的一种,对于技术人来说,技术经验也算是一种时间杠杆,所以边打工,边在资本市场中学习,也不失为一种创造财富的方式。   至于幸福,「哈佛大学公开课:幸福课」中指出幸福是不可持续的,永远幸福是不可能的。幸福的人都是相似的,不幸的人各有各的不幸,穷人的不幸比富人更不幸,借用一句伪科学的话:所有的不幸的都是对现状的一种不满足感,满足是一种幸福,适应也是一种幸福,总而言之就是看开,释放。「银河系漫游指南」中宇宙终极问题的答案是 42,或许本身就是无意义的事,为啥一定要有意义,无聊或许才是人生的常态主题。当欲望得到满足,或许能得到短暂的幸福,但随之而来的空虚感也得忍受,更关键的在于去选择下一件事,绝大部分人总还是要为了生计奔波,吃饱了撑得就容易胡思乱想,忙点啥,哪怕随便干啥,都能体会到闲暇时的幸福 :P。   曾经在 TED 上看到不要把梦想告诉别人,告诉了就很难实现,因为能得到的只有嘲笑,以 Shaun 个人经验来说是没错的,梦想还是放在心里比较好,或许等哪天实现了再放出来会更加畅快,有且仅有自己做的梦才是真正的梦想,这才是人最珍爱的东西,就像「来自深渊:烈日的黄金乡」中真正的挚爱是不会让任何人知道的,哪怕是自己最后的传承者。「把时间当作朋友」中有这样一句话,“来自外部的恐惧在于过分在意外界的评价”,Shaun 本身不是个太在意别人看法的人,在意别人的评价,无疑会让自己活得更累,尤其是大部分人的评价没有丁点儿建设性,只是一种优越感作祟,当然,自己真有问题而不自知也是不行的。很喜欢暗杠「童话镇plus」里唱的“很多人一辈子忙忙碌碌不会懂得:有个被嘲笑的梦想万一有天实现了呢?”,人活着,总得有个念想。奔波一世,虽说是为了这一日三餐,但能留下点脚印,哪怕是些许痕迹,好像也还不错。   老子云:知人者智,自知者明;邓宁-克鲁格效应也指出认知有四大境界:不知道自己不知道,知道自己不知道,知道自己知道,不知道自己知道;人贵有自知,可惜不知为知者有之,过而不改者有之,好为人师者有之。人之患在好为人师,道不可轻传,薪尽火传在很多时候也只是妄想,自以为是,人以为非,自以为厉害不算厉害,别人认为厉害才是真厉害。自知,知道自己几斤几两,自尊自爱,力所能及,道阻且长,持续学习,徐徐图之。   常言道:种一棵树最好的时机是十年前,其次是现在。做事情从来不嫌晚,就怕不行动,人人都会做梦,有些梦不切实际,有些梦有迹可循,空想无用,再周密的计划也比不过实际执行,计划赶不上变化,诚然,试错有成本,但这成本也同样是经验,按部就班的迭代执行,总会走向通往梦想之路,不过大部分人可能在路上就迷失了,甚至有些人走不上路,能真正到达终点的人,终究只是少数。 工作篇   一位长者曾经说过:一个人的命运啊,当然要靠自我奋斗,但是也要考虑到历史的进程。工作不是事业,对于大部分的人目标都只是简单的钱而已。三年期的目标算是达成了,五年期的目标的应该是完不成了,不过总归要尽力,大环境如此,个人的努力显得格外苍白。在时代的洪流之下,众人皆是蝼蚁,按部就班,做好自己该做的就行,至于回报,还是得看外面的环境调整预期。   Shaun 这三年来,从小公司跳到大公司,体会最深的就是,就单纯的论做事而言,大公司和小公司没啥区别,能接触到的人也就是自己组内和上下游的几十个人而已,唯一的区别可能也就是履历背景了,但这个也只能在跳槽时才能体现其作用,当然或许有人说人的能力强弱不同,但就 Shaun 的感觉来看,不是不同,而是术业有专攻,不同的公司对人的侧重点要求不同,大公司小公司都有工作能力很强的,不过都跑路了 ¯\_(ツ)_/¯,不过有一点是真的有区别的,就是数据量,这是实打实的区别,不过这更多的是对经验的要求,对人做事的本质能力并没有更高的要求,还是用原来的思维方式尝试新的方案和实验,真正难的问题还是交给学术界去解决吧。   无论大小公司,拥抱变化或许才是永恒的话题。这种变化,不只是裁员(只要是补偿没问题的裁员,若被裁,那便被裁,找个吃饭的地方,总还是不难的),更多的是体现在做事方面。打工人常听人说,事情是做不完的,没必要这么加班加点的干,没必要加班干是真的,但事情是会做完的,只是做完了又会有新的事过来,事情做完又两层含义:一层是事情可以告一段落了,做到这到此为止了,不要再投入大量人力做这个事,只是日常维护;另一层是这个事情不做了,现有人力要么换岗要么走人。对于公司上层来说,更关心的事是做完这个事之后下一个事做什么,如果没有下一个事,那留下这么多人就没必要了,体现到下层大头兵上,就是要么被裁,要么换个事情做,至于换个的事情可能是类似的事,也可能是全新的事,这就对人的学习能力或知识迁移能力有一定的要求。从上层的角度来说基本上很难让一个大头兵一直持续深入的干某件事,浪费人力,投入和产出完全不成比例,所以持续学习,拥抱变化才是真理。就 Shaun 个人的经验而言,基本上每半年换个新的活儿,有的活能照之前的经验完全迁移,有的活完全没接触过,得重新开始学,不过好在大多数的事都还能胜任,差强人意。   换活,有的是开辟新业务,有的是填坑,开辟新业务一般都算是好活,从 0 到 1,事情不一定轻松,但收益可是实打实的,而且越前期的收益越容易拿,除非新业务黄了,不然光啃老都能啃几年,这是真好活。至于填坑,就看坑是什么样的了,若是核心的坑,那当然是极好的,怕就怕是陈年老坑,还需要不断维护更新,经受无数人的东西还有去维护,也是个烂坑;另一种坑是,前人写了系统原型,需要沿着这个原型继续开发,可惜原型一般只是实现核心功能,更可怕的是还是个初版,代码也是一团糟的那种,这样的坑接收恐怕是只能重写了,但收益一般也就不好说了。   收益,是企业最核心的两个字,没有收益,一切都是免谈,收益有显而易见的,比如节省成本,开拓市场,也有不明显的,比如为以后节省人力,为将来快速解决问题。显式的收益很容易感知,也很好评估,隐式的收益就基本无法感知,将来的事谁能说的清,况且将来还有没有这事都很难说,员工的人力成本也容易忽视,这些收益就很难评估,也就基本不可能定为上下一心的 kpi,当下面的 kpi 不是上面的 kpi,那下面做的一切工作都是白干,虽然对自身可能有益,但也只能面试时体现了。每离职一个核心人员,至少需要调派 3 个人来填补空缺,为啥企业宁愿后面补充,而不是提前安排一个人来分担工作,主要原因在于企业在赌核心人员不会轻易离职,也不会安排太多人手干维护性质的工作,但当熟手离职,再要去维护的代价就很大了,后来者一边填坑,一边在心里骂街,就这样一轮一轮的后来者,一轮一轮的骂街,屎山也就这样炼成了,直到推倒重来,形成上下一心的 kpi,又开始新一轮的屎山炼成。   提问从来不是件丢人的事,只要自己尝试过解决且保持应有的礼貌,毕竟人非生而知之者,不知道才是常态。很多人喜欢闷头干活,遇到问题只会冥思苦想,独立思考固然是一种品质,但思考也需要讲究基本法,思考也分交流前中后,当完全没有交流,就开始想解决方案,这是呆子,没有任何背景,能想出好的方案,那可能是真正的天才,那也没必要和打工人混在一起了。软件系统的问题更多的可能是人的问题,遇到问题,最好的是先了解背景原因后果,了解一下来龙去脉,再和有经验的人交流讨论一下,可以怎么解决,最后再决定具体做法,怎么执行,真梳理完了之后,执行一般是一件比较简单的事了。遇事多沟通,想开点,对人愚钝,对事精明。   如无必要,勿增实体,漂亮话人人都会说,可真在具体实施中,一般都是怎么快怎么来,优先增加一个实体再说,而不是去想有没有必要;当然也有的会过度放大这个事,明明增加这个实体能给各方都便利很多,从成本考虑就是不增,孰不知在另一方面反而是增加了成本。软件开发行业中,有很多定律法则,但人是活的,定律是死的,每个人有每个人的理解,在不同的业务场景下,定律的体现都有不同,一般情况下,围绕定律走不会出太大的问题,但有时也需要灵活变通,就像设计模式一样,不是完全一层不变的,有那层意思在就行,Shaun 认为唯一恒定的定律就是「简单」,不管是设计还是开发,越简约,越直观,就越稳定,越正确。   年龄歧视在职场中是确实存在的,国内职场普遍认为,当到一定年龄的时候,就应该到一定的职级,到一定的职级就不会再在一线干活了,这就导致了在招干活的人,就不会招年龄大的,这真的就很畸形,职级高就不会也不需要在一线干活,就目前国内职场的环境,技术从来不是主流的上升手段,所谓的技术路线是不存在的,不需要真正的架构师,文档架构师更受欢迎,说的好才能活的好。做完和做好从来都是两回事,但相比较而言,做完更重要些,毕竟做的好与不好,很难量化评估,所以更多的要求是能不能做出来,不关心好与坏,有东西出来这更重要,而一般都能做出来,所以不重视技术人员,而更重视管理人员,这就是互联网 35 岁失业的本质,就算 35 岁以上的技术人员能做的更好,但 ROI 不成正比。管理者掌握是组织技能,执行者掌握的是生存技能,当经济环境下行时,生存技能有优势,当经济环境上行时,组织技能更有优势。   跟对人,做对事,简简单单六个字,做起来可太难了,首先怎么定义对,在合适的时间做合适的事,这个合适怎么把握。曾以为只要努力做事,就能得到应有的回报,可在人类世界,老实做事有回报的都上新闻了。做好事不如做对事,但不知道事情做的对不对的时候,也只能选择做好,至少这是对个人能力的要求,少留点骂名。当然,善战者无赫赫之功,做的好了,也没法突出个人的重要性,甚至给上面的感觉是有你没你没区别,只要还在这个位置上,能力就体现不了,相反,经常出问题的人,得一堆人围着转,这看起来就很重要 ๑乛◡乛๑,如果上面再分配不公,能力强的人自然就加速走了,某种程度上的死海效应,劣币驱逐良币。   工作也是讲方法论的,做事的出发点和方向错了,即使能解决一部分问题,但解不干净,最后还得推到重来,所以项目启动前的分析非常重要,一定得先抓头部问题,当然穿插一些能快速解决的问题也行,其次就是明确哪些问题一定要解,哪些问题可以不解,最后就是一定要留下文档纪要,这是工作量的体现,也是未来追溯问题的依据。汇报也一般可分为五步:背景,需求和目标,解决方案和排期,进展和依赖项,问题和风险。   工作自由,有两种境界,一种是选择做什么的自由,另一种是选择不做什么的自由。大部分或许能选择做什么,但却无法拒绝一些事,有些事情只能被动接受,但有些却是可选的,不要一直被牵着走,事有轻重缓急,要有自己的认知,学会拒绝,哪怕是上级的需求,现如今找个养家糊口的工作还是不难的,此处不留爷自有留爷处。了解并学习职场中一些常用的话术,分辨并挑出真正对自身有益的,别把别人太当回事,别把自己不当回事,事实是事实,话术是话术,主人翁意识是每个领导都希望下属有的,但解释权归领导所有,越上层就越不可能落到实处。尽心做事,尽力做好每一件接手的活,无论喜欢与否,真不想做,就不要接,既然做了,就尽职尽责,算是在工作中积德了,败人品的事还是尽量不要做。放宽心态,大方待人,不要把工作当生活,可以享受工作,但更应该享受生活。勇于分享,分享讨论可能不见得是件好事,但绝不是坏事,分享同样也是一种总结。灵感是易逝的,当有灵感时,就尽快行动起来,优秀的产品需要时间来打磨,对结果需要有更多的耐心。   在 22 年一片开源节流的浪潮中,人人自危。「浪潮之巅」中,科技的发展史就是一批企业的兴亡史,或因为自身的原因,尾大不掉,或因为更上层的力量,没有哪家企业能一直辉煌下去,打工人能做的,只有选条赛道,厚积薄发,尽量少换或不换行业,剩下的也就只有听天由命了,毕竟将来的事谁也说不好,潮起潮落,又有谁能一直屹立浪潮之巅 ╮(╯▽╰)╭。 生活篇   这三年,最大的主题就是“新冠”,新冠时代,每个人都是历史的见证者,这次疫情,足以在人类历史上留下浓墨重彩的一笔。这期间,也能真正在现实里体会一把魔幻现实主义,有大规模封城的,有叫嚣着他的软肋是儿子的,也有恶意返乡有家不能回的。天赋🧑权,疫赋🐶权,肉食者的政策总是不食人间疾苦。又或许是上面的政策出发点是好的,但奈何下面的执行者大部分是一帮饭桶蛀虫,能站在布衣角度去落实政策的又有多少。解封之后,对 Shaun 而言,最大快人心的就是不用再看看门大爷的嘴脸。黑色的眼睛可能是黑夜给的,也可能是白天给的,有人用它寻找光明,也有人用它寻找黑暗,西游记里描述的狮驼国从历史角度看,也不是什么神话传说,虽说如今的时代论惨烈规模没那么大,但苦难并没有完全消失,太阳底下也没有新鲜事。   「动物庄园」里有句话,“所有动物生来平等,但有些动物比其他动物更平等”,疫情三年,对这句话体会更加深刻,有的人生在罗马,也有的人生来就是骡马,人人平等的乌托邦世界或许只是个伪命题。连科学也只是为政治服务的工具而已,社会的科技发展也从不依赖于上层阶级的想法,往往只是下层某个灵光一现的思路,人类社会有个很奇怪的现象就是,本来一个人在下层成果迭出,当跃迁到上层时,思维就好像僵化了,深层原因可能有很多,但至少表面现象是这样。普罗大宗也很容易受到各种言论的影响,尤其是某些专业人士的公开发言,所以完全的言论自由也意味着完全混乱,需要管控,但也有知情权,不然就像一氧化二氢实验一样,隐瞒一面,重点宣传另一面,就很容易受到别有用心的一些误导。   未经他人苦,莫劝他人善,这三年,在网上见过太多的牛鬼蛇神,一把键盘走天下,自以为站在大义之上,实际上只是些吃着人血馒头既蠢又坏的看客,以自己幸福的生活站在道德制高点上去肆意抨击他人,并因此而洋洋得意。诚然无能是最大的罪恶,哀其不幸,怒其不争,但匹夫之怒,也能血溅五步,自救者天救,自助者天助,自弃者天弃。当感叹世事无常的时候,都可以去看看「活着」,有的人觉得蝼蚁尚且偷生,好死不如赖活着,也有的人觉得宁为玉碎不为瓦全,读书毕竟是个很私人的事,一千个人心中有一千个哈姆雷特。   生存还是生活,这是一个永恒的话题。苏轼曾感叹道,寄蜉蝣于天地,渺沧海之一粟,人这一辈子,说长也长,年轻的时候往前看,感叹还要这样过几十年,说短也短,回首往事,转瞬即逝。也曾踌躇满志,誓要走出山村,而今跨长江越黄河,问一句,有必要吗?行路难!行路难!多歧路,今安在?生活的方式有很多种,或许平淡才是真,人活一世,顺心而已。22 年,偶然发现北京有很多的户外徒步组织,Shaun 也参加了好几次活动,感觉确实很有意思。有的人把徒步当成一种极限运动,挑战自我,也有的人把徒步作为一种休闲运动,纯纯放松心情,每个人徒步的目的都各不相同,重点在于量力而行,对大自然有敬畏之心。周末去外面走走,听风观景阅人看故事,虽然身体上并不轻松,但能极大的消除一周心理上的劳累感。寄情于山水,逍遥于世间,打工人花点小钱,就能有心灵上的放松,整挺好。   至于对象,就感觉自己一直很佛系,或许得等到真正成为大魔法师的那一天,才会转变心态。爱情,Shaun 是从来不奢望的,爱情是咋样,相信一千个人心中有一千个哈姆雷特,古今中外有无数的作品的描述了作者心中认为的模样,共同点在于都有风花雪月,嘻嘻哈哈,哭哭啼啼,而生活,好像优秀的作品不多,一个可能的原因是爱情短暂,更有戏剧性,作品也能更有张力,而生活一般时间跨度较长,再惊天动地的事在时间的长河里或许也只是一朵小水花,看起来很平淡,贾科长或许是个很善于观察生活的人,22 年的「隐入尘烟」或许也描绘了生活的一部分,平平淡淡才是真,哪有那么多跌宕起伏,哪有那么多真善美。   『紫阳』中有句话,「男女之情并不深奥,感情的发生有两种诱因,一是源于阴阳交合本能的驱使,以阴阳交合为目的。还有一种是喜欢对方身上的优良本格,愿意与之长相厮守。这两种诱因都可以引发情感,没有高下清浊之分,两种诱因也往往彼此掺杂,很难明确区分。这两者唯一的不同就是后者更容易被世人传颂赞美,但后人传颂和赞美的其实也并不是情感本身,而是少数人身上的优良品格」。爱情或许值得被赞美,但更多的是恋爱过程中经历的事,爱情或许从来都是想象中的产物。「三体」中有描述,大部分人的爱情对象也只是存在于自己的想象之中。他们所爱的并不是现实中的 ta,而只是想象中的 ta,现实中的 ta 只是他们创造梦中情人的一个模板,他们迟早会发现梦中情人与模板之间的差异,如果适应这种差异他们就会走到一起,无法适应就分开,就这么简单。两个人真正在一起的时候,往往只看到对方坏的一面,分开的时候,回忆时却想着好的一面,人生就是这么反复无常。「疑犯追踪」有这样一句台词:有一天你嫁给了自己的灵魂伴侣,然后眼睁睁看着他们变为另一个人,一旦深爱这人曾经的样子,便无法接受他们改变后的样子。最爱的人或许只存在于想象中,毕竟,随环境变化最大的也是人。   有人说,两个人在一起就是为了分担风险,当一个人能够足够承担风险的时候,或许就不需要两个人。「在云端」中有讨论过结婚的意义:可以有依靠的人?但能真白头偕老的人又有多少,计划永远赶不上变化,就算能碰到自己的理想型,但你大概率不会是 ta 的理想型,况且人都是善变的;不会孤独终老?如今的时代养儿防老风险也不小,比父母更有能力的孩子基本不可能待在父母身边;或许正如男主后期的思想转变,结婚的意义或许就是找个能分享倾听理解陪伴的人。Shaun 理想的两人关系应该是一种战友之上的关系,相互信任理解尊重,虽有小分歧,但大目标一致。人与人之间的相处哪要那么多心思,顺其自然,求同存异,自尊自爱,人待以诚,待人以诚。庸人为了忘却烦恼,一般以智者不入爱河来自我安慰。人啊,简单又复杂,简单在合心意,复杂在人心难测,所以不需要强求,顺心不香吗?   回想起以前高中的时候,就有同学对 Shaun 说:“你这看样子以后就是要靠相亲找对象的”,现在想来,那同学看人真准。有人说相亲是让一个不懂人的去搞定一个难懂的人,其实哪有什么不懂或难懂,有的只是一群彷徨的可怜人罢了,很少有人能确切的明白自己真正想要的是什么。数学中有个最优停止理论,得出了一个 1/e 的数值。通俗来说就是在苏格拉底的麦穗故事里前 1/3 的路程,什么都不摘,只是用来估计自己的预期,在后 2/3 的路程里,一旦有接近甚至超越的预期的麦穗出现,立即摘下。满足 1/e 概率的前提之下是麦穗大小均匀分布,可惜现实世界很少有均匀分布,所以这个东西对于相亲虽说有一定的借鉴作用,但还是不要太盲从。相亲本来就带着强烈的不信任感,而信任的建立一般又是个长期过程,在不信任甚至有些防备的状态下进行交流,自然很难发现别人的闪光点,更多的是在不经意间暴露出的缺点(相对而言是缺点,毕竟善于发现美的人不多,挑刺的人会更多些),从这点而言,相亲和面试差不多了,更重要的是遇上对的人,幸运值很重要。   相亲的交流无非就两种方式,一种是直截了当,开始就问对方想找个啥样的;另一种迂回战术,从工作学习生活爱好方面按流程开始话题,虽然很平淡,但是一般也没太大的问题。交流是相互的,就光一个人问,这不是聊天,而是面试。当然如果感觉聊不到一块儿,确实话不投机半句多,就尽早结束;当犹豫要不要开启话题的时候,就可以长痛不如短痛了,当断不断,反受其乱。出来相亲的人都抱着不同的目的,有的人是被迫的,有的人只是想接触一下外人,有的人是想给别人一个机会,也有的人确实很实诚的想找个相守一生的对象。不同的目的造就了不同的人,有的人就是想出来玩玩,享受那种若即若离的朦胧感,Shaun 一贯的态度是抱着一颗平常心,观其行而知其心,得之我幸,失之我命,求而不得,何必强求,弃得失心,方可自在,若不成,那便不成,也不需要刨根问底,在没有结果的事情面前,真相都显得没有任何意义。   很多人对别人的看法在第一次交流的时候,就基本已经确定了,先入为主的思想根深蒂固,孰不知交流本身可以算是一件很随意的事,不同的交流方式,不同的语言文字,在不同的人生背景下,都有不同的含义,除非带着很强的目的性,非常明确的知道这次交流的主题是啥,不过这就和工作中开会没啥区别了。生活中的交流还是随意些比较好,一般都是想到啥就说啥,不用费脑子,巴适。 理财篇   关于理财的言论有很多,在经济上行的时候,网络上流传的话是,你不理财财不理你,经济下行的时候,说的更多的就是,你一理财财离开你。理财,其实是一个很私人的事,分享交流可以,但安利就算了,赚钱的时候只会觉得自己眼光不错,亏钱发泄情绪的时候就正好能找到一个出气口。理财的手段也有很多,最稳妥的当然是不理财,直接把现金都放家里,毕竟在这个银行都会爆雷的时代,钱放银行都不太保险。有人说,乱世买黄金,盛世买古董,灾荒屯余粮,这话说的也有一定的道理,毕竟黄金是整个人类文明的硬通货,钱或许不一定是钱,但黄金总还能代表钱,古董更多的是一种精神文明象征,当衣食无忧的时候,自然也会想有点更高层次的追求,当衣不蔽体食不果腹的时候,自然填饱肚子才是最高优先级。对应到国内市场,当银行爆雷的消息传来,黄金大涨,这三年疫情,食品消费行业涨,其他都跌,市场总是跟随人的需求变化而变化,不管是看衰还是看好,总有人能从中发现商机,毕竟资本总是会从一个地方转移到另一个地方,除非整个市场崩了。   理财本质上是一个买与卖的哲学,就算是存银行,为方便取用,就用活期,暂时不用,就用定期,这也是存款收益的一种买卖权衡,至于市场里的交易,就是更直接的买卖了。每个人都想从这种买卖中获益,不管是买方还是卖方,但是这种买卖不是简单的零和,有可能是正和,有时甚至会是负和,主要看买卖的东西的是啥。有些人一次两次的交易都获利了,就想着下次能不能获利更多,但随着等待的时间拉长,收益都变成亏损了,最后只能忍痛割肉。不管是盈利还是亏损,都有其背后的逻辑,不长记性,迟早哪天会栽沟里。在「赌城风云中」有类似这样一句台词:让人去赌场,并一直待在赌场,堵的越多总会输的越多,赌场总是赢家。赌场赢的关键在于如何让赌徒一直待在赌场,就算出去,也依然会回到赌场,股票投资市场虽然和赌场差别很大,但本质上有些地方是相通的,对于普通人,一直待在股市中,却不去学习证券金融市场运转规则,不去了解国家政策导向,迟早有翻车的一天,虽然对于整个市场来说不一定有赢家,但赌徒肯定会是输家。   有人说,人只能赚到自己认知范围内的钱,但没人说,会亏损到什么程度。所有的亏损都来自于赚钱的欲望,越想赚钱可能亏损就越大,对于理财,止损线和止盈线同等重要,这两条线每个人心里都应该有自己的尺子,当然也得根据当前的市场环境和自我认知进行动态调整。见好就收,见衰则退,说起来容易,但做起来何其艰难,早早退出的懊恼,越陷越深的后悔,这些事情只有亲身体验过,痛过之后长了记性,才能有自己的认知,光凭运气挣的钱,指不定哪天就会连本带息的还回去。亏损是个无底线的事情,短时间大亏,很多人可能就直接退场不玩了,怕就怕在钝刀子割肉,割几天又喂点好的养几天,亏麻了又还有点希望,碰到这种情况更加需要慎重,需要费心费力收集更多的信息做决策,所以与其在后期劳心劳力,不如前期就先做好各项调查准备工作,而且做事的心态也不一样,每一笔投资都应该有充分的理由,不然不投会更好。   在这个人人都想赚快钱的时代,国内市场都是很浮躁的,都堵的是自己不是最后一批入场的。很多人也知道快和慢都是相对的,投机倒把拼运气是快,但总归不能长久,除非完成资本的原始积累之后立即转型,不然总是要还回去的。在股市里遨游,不亏就是赚,能保住不亏,稳扎稳打,从长时间维度上来讲,不一定就会比“快”方法慢。Shaun 这三年的投资收益不能说一点没有,只能说和存银行差不多,相当于是玩了三年,也还算能接受,就当是白嫖了些股市经验吧 :P。这三年可以说是入市即巅峰了,经历过连续几个月的万亿交易量,也经历过上证 2800 以下,算是过了一波小牛熊,不说掌握了多少金融相关的专业知识,但一些常识性的经验还是积累了一些。Shaun 总结的经验主要有:1、万亿成交量的市场,需要慎入;2、当不确定甚至不知道买啥的时候,就不要动;3、当一个热点被广泛讨论的时候,可以考虑退出了;4、尽量不要去买大股东在减持的股票,买家不如卖家精;5、两会前一个月的市场,慎入,两会期间可以酌情考虑入场;6、10 月份可以考虑一波白酒,年后卖掉;7、最重要的是,拿来投资的钱一定得是可预见的未来三年内不会动的钱。 后记   难得写万字长篇,这次看完『纳瓦尔宝典』,又兼之正好工作三年(拖延症拖到快四年了 😅),算是跨过了人生的一道小槛,所以难免会有些想记下来的一些东西,也算是总结一下这三年来的工作生活思考。或许下一篇万字长文是十年回顾,也或许没有,毕竟自古苦难多诗文,天降大任于人,天不降大任于人,经历一样,唯一的区别在于是不是天命人。道阻且长,且行且顾。 22 年获得技能:接活达人 22 年获得成就:三年已到

2023/4/2
articleCard.readMore

2021 年小结

纵观宇宙史,生物史,人之一生,不过沧海一粟,弹指灰飞,若有重来,何必重来。人生一字,莫过于拼,为私欲者有之,为利他者有之,为后代者有之,为权利者有之,为名声者有之,为理想者有之,。。。不拼之人,难存于世,众生皆苦,苦中作乐。  ——鲁迅没说过文集 前言   21 年,工作上第一阶段的目标算是提前半年完成了,非常感谢前领导的赏识,至于生活上第一阶段的目标感觉还是遥遥无期。 工作篇   21 年,同样一直在学习,感觉全年都在用新事物完成工作,从学习 Scala,Go 到 OSS,K8S,再到熟悉 macOS,Vim。用这些新学的东西从 0 到 1 完成了一个半项目,一个项目是地图切片系统,将 GIS 数据以 S2 网格的形式进行重新分组管理,这个系统算是优化到了 Shaun 能优化的极致,内存和性能之间达成的 trade-off,单机版可以最大程度的利用多核 CPU,集群版同样可以充分发挥多台机器的作用,这个项目算是 Shaun 花大力气做的第二个项目了,同样的满意与自豪,希望能继续发光发热。至于那半个项目,只能说是开了个头,算是 shp 数据的版本管理系统,支持正常的 CRUD,空间查询以及分析能力,初步的属性和几何信息版本管理,这个项目没有做完,算是留下了一点小遗憾。不过 22 年再回过头去看,继续做下去的话,会碰到很多难点,有些问题,对 21 年的 Shaun 来说可能是无解的,甚至可能是导致项目做不下去的关键问题。   21 年,人生中第一次跳槽,要说原因,可能也就是想换个环境,接触不同的人,当然也有一部分钱的原因,更重要的还是想出来看看,看看其他的一些流程方案,加快自己的成长速度,正如 Shaun 在学生时代说的,换个环境能使人成长的更快。确实,跳槽了之后能明显感觉到自己做事的一些变化,每个环境对人的要求是不一样的,不谈孰高孰低,只是不同的方面而已,综合这些方面,才能更好的应对后续碰到的一些困难以及有更好的发展前途。   21 年,工作上最大的收获不是做了多少项目,学了多少新技术,更不是跳槽涨了多少薪,而是跳槽后心态和做事方面的一些转变。以前虽然嘴上说着社畜社畜,但总还是一种学生心态,年轻气盛(年轻人不气盛还叫年轻人吗 ๑乛◡乛๑ ),做事钻牛角尖,只想尽最大的努力做好一件事,一心多用就比较烦躁,有时也大手大脚的,还好是 toB 的行业,有足够的时间来打磨和优化,也是真的感谢前领导的赏识和放任。跳槽之后,感觉自己做事的心态一下就放开了。在新公司,学到了一个新词语——确定性。   向上管理又同时不唯上,却是不简单,保障确定性就是一种比较好的做法,所谓的确定性就是能够完全把控一件自己负责的事。确定性,说到底也就是数据,有些什么事,分别是什么,分别有多少,工作量多少,计划排期,现在的进度,成果如何,剩余情况,预期情况,风险情况,牵扯的上下游安排,碰到的问题与困难,可能的解决方案。并不是说一定要有阶段性的成果才算确定性,每次汇报,都能把上面这些问题说清楚,也是一种确定性,能够确定这些东西也是自己能力的一种体现。领导关心的也是这些数据,向上负责,同时也是对工作负责,对自己负责。 生活篇   对目前的 Shaun 来说,生活和工作基本没啥区别,工作在 Coding,生活有时也会 Coding,唯一的区别在于,工作是为了生存,生活是为了兴趣。生活算是工作之余的放松,所以关于生活能写的确实不多。   21 年,虽然疫情还在持续,但常年待在租房里还是有一些出去玩的冲动,遂去了一趟西湖,人确实很多,风景也没有让人耳目一新的感觉,有点名不副实了,还没有旁边的龙井村好玩,杭州的交通也是一场不太美好的出行体验。出去玩,主要是为了散心,这个目的算是达到了。   21 年,本来想去一趟黄山的,但由于自己懒得动,还是没去成,这不得不说是一种遗憾了,换了个城市,再想提起勇气去,就不知道是猴年马月了。换城市这件事,Shaun 也认真思考过,代价确实比较大,或许将来有一天,Shaun 会因为这个决定后悔,当然也或许不会,Shaun 一贯的认知就是有钱在哪都舒服,没钱在哪都难受,最终还是 follow my heart,决定趁着年轻,多出去看看,毅然决然的走出过去两年多舒适的工作和生活环境,来到这个陌生的环境重新开始,这件事,算是为平淡的生活增加了些许起伏。   刚来到新城市,虽然觉得一切都比较新鲜,但还是被新城市恶劣的天气环境给搞的很不爽,不过还算运气不错,只看了一家就找到了 Shaun 还算满意的房子,新城市的房租确实要高一些,而且中介费居然要一个月的房租,这着实是有些高。新城市的防疫政策对底层打工人没有丁点儿人文关怀,部分小区的看门大爷是真大爷,就像菜鸟程序员写的低级 robot,逻辑写的死死的。防疫软件也是垃圾中的战斗机,纳税人的血汗钱也不知道有多少进了个人口袋。   理财方面也开始接触一些更专业的知识,国内金融从业资格考试主要有四个:证券从业资格考试,基金从业资格考试,银行从业资格考试,期货从业资格考试,都有对应的统编教材,并不是说要一定通过这几个考试,而是可以从这几门考试中,对国内金融市场有一定的认识,不是完全的小白(真要参加考试的话可以看看这个 证券资格证考试要准备多久? ,刷题的话就找个 app 就行,刷题学习法 😅),一般看完前两个从业资格的备考资料就差不多了,有个基础的认识,后期的交易策略或计划就只能根据个人的情况慢慢摸索了。至于 21 年的理财成果就不是很好了,把 20 年赚的又亏回去了,主要是出于 20 年的乐观心态,觉得互联网还能再涨点,就一直没卖,没想到 21 年的国家政策对互联网这么不友好,想要挣钱,还是得跟着政策走 ¯\_(ツ)_/¯。 总结   前路漫漫,不问对错,不求利弊。每个公司做事的风格是不一样的,这种差异性才是需要学习的地方,也是能让人快速成长的基础。年纪越大,越觉得人生若只如初见是一种奢侈。独行备艰难,莫忘守初心。 21 年获得技能:学无止境 21 年获得成就:重新开始

2022/2/20
articleCard.readMore

M1 个人配置

前言   记录一下 Shaun 个人的 Mac 装机配置。 必备 AlDente:Mac 电池健康保护神器,默认 80% 就行,想充满就设置为 100%,需要禁掉自带的优化电池充电。 LuLu:防火墙,控制应用联网权限。 BetterDisplay:使外接显示器更清晰,需设置与笔记本同宽高比/同分辨率的 Dummy 以及将 Dummy 屏幕镜像到外接显示器。 MacZip:解压缩。 QuickRecorder:录屏。 BongoCat:桌面宠物。 Mac 黑魔法   有时 Mac 系统抽风,部分设置在界面上无法修改,需要通过终端命令强制修改。现记录部分命令: 1 2 3 4 5 # 允许安装任何来源的 app sudo spctl --master-disable # 设置时区为中国标准时间 sudo systemsetup -settimezone Asia/Shanghai iTerm2   下载安装 iTerm2,默认 shell 就是 zsh,所以不需要安装。   ※注:推荐用 Mac 系统自带的终端安装 Oh My Zsh 和 Powerlevel10k,之后才使用 iTerm2。   安装 Oh My Zsh,github 上的命令在国内可能无法顺利执行,先 clone 下来,手动执行 sh tools/install.sh。   安装 Powerlevel10k 之前,先安装 nerd font 字体,Shaun 个人还是比价喜欢 Fira Code 字体,所以就选择下载 Fira Code Nerd Font 字体,只需要安装 Fira Code Retina Nerd Font Complete.ttf 即可。设置 iTerm2 字体为 FiraCode Nerd Font。   随后开始安装 Powerlevel10k,安装完之后重启 iTerm2,会有 Powerlevel10k 的配置提问,依次回答(有推荐按推荐)完成即可配置好 Powerlevel10k,若后续想修改配置,可直接编辑 ~/.p10k.zsh 文件或使用 p10k configure 命令重新回答配置提问。最后在 zsh 的配置文件 ~/.zshrc 中设置 ZSH_THEME=powerlevel10k/powerlevel10k。   推荐安装 zsh 插件 zsh-syntax-highlighting 和 zsh-autosuggestions,在执行完 1 2 3 git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions 后修改 ~/.zshrc 的 plugins 值, 1 2 3 4 5 6 plugins=( git zsh-syntax-highlighting zsh-autosuggestions # other plugins... ) Vi   Vi 使用 SpaceVim,Mac 中如果无法使用 vim 命令,需要先安装 Vim。 VSCode   VSCode 同样需要设置终端字体为 FiraCode Nerd Font,在终端中进入 Downloads 目录执行 mv Visual\ Studio\ Code.app /Applications 命令,将 VSCode 放进 应用程序 中,再执行 sudo ln -s "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code" /usr/local/bin/code,之后可在终端使用命令(code .)直接打开 VSCode。若无法自动更新,需执行: 1 2 sudo chown -R $USER ~/Library/Caches/com.microsoft.VSCode.ShipIt xattr -dr com.apple.quarantine /Applications/Visual\ Studio\ Code.app VSC 插件 Hex Editor:编辑二进制文件,可替换和新增字节; Partial Diff:文本比较差分,支持选中的文本和剪贴板内容比较; MinifyAll:代码压缩,支持大部分常见格式(xml,json,html 等); Homebrew 20241112 更新: 可一键直接 安装 Homebrew,中科大源,清华大学源 以下命令无需执行。   直接执行: 1 2 3 4 /bin/bash -c "$(curl -fsSL https://cdn.jsdelivr.net/gh/ineo6/homebrew-install/install.sh)" echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile eval "$(/opt/homebrew/bin/brew shellenv)" 安装完成后先检查目录 /opt/homebrew/Library/Taps/homebrew/homebrew-cask 是否存在,若不存在,则执行: 1 2 cd /opt/homebrew/Library/Taps/homebrew/ git clone https://mirrors.ustc.edu.cn/homebrew-cask.git   最后设置中科大源: 1 2 3 4 5 6 7 git -C "$(brew --repo)" remote set-url origin https://mirrors.ustc.edu.cn/brew.git git -C "$(brew --repo homebrew/core)" remote set-url origin https://mirrors.ustc.edu.cn/homebrew-core.git git -C "$(brew --repo homebrew/cask)" remote set-url origin https://mirrors.ustc.edu.cn/homebrew-cask.git brew update echo 'export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles/bottles' >> ~/.zprofile source ~/.zprofile Aria2   直接使用命令 brew install aria2 安装,生成配置文件: 1 2 3 4 cd ~ mkdir .aria2 cd .aria2 touch aria2.conf   打开 Finder,通过 Shift+Cmd+G 进入路径:~/.aria2/,编辑文件 aria2.conf,添加以下内容: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 #用户名 #rpc-user=user #密码 #rpc-passwd=passwd #上面的认证方式不建议使用,建议使用下面的token方式 #设置加密的密钥 #rpc-secret=token #允许rpc enable-rpc=true #允许所有来源, web界面跨域权限需要 rpc-allow-origin-all=true #允许外部访问,false的话只监听本地端口 rpc-listen-all=true #RPC端口, 仅当默认端口被占用时修改 #rpc-listen-port=6800 #最大同时下载数(任务数), 路由建议值: 3 max-concurrent-downloads=5 #断点续传 continue=true #同服务器连接数 max-connection-per-server=5 #最小文件分片大小, 下载线程数上限取决于能分出多少片, 对于小文件重要 min-split-size=10M #单文件最大线程数, 路由建议值: 5 split=10 #下载速度限制 max-overall-download-limit=0 #单文件速度限制 max-download-limit=0 #上传速度限制 max-overall-upload-limit=0 #单文件速度限制 max-upload-limit=0 #断开速度过慢的连接 #lowest-speed-limit=0 #验证用,需要1.16.1之后的release版本 #referer=* #文件保存路径, 默认为当前启动位置 dir=/Users/yuanxu/Downloads #文件缓存, 使用内置的文件缓存, 如果你不相信Linux内核文件缓存和磁盘内置缓存时使用, 需要1.16及以上版本 #disk-cache=0 #另一种Linux文件缓存方式, 使用前确保您使用的内核支持此选项, 需要1.15及以上版本(?) #enable-mmap=true #文件预分配, 能有效降低文件碎片, 提高磁盘性能. 缺点是预分配时间较长 #所需时间 none < falloc ? trunc << prealloc, falloc和trunc需要文件系统和内核支持 file-allocation=prealloc bt-tracker=udp://tracker.opentrackr.org:1337/announce,udp://open.tracker.cl:1337/announce,udp://9.rarbg.com:2810/announce,udp://tracker.openbittorrent.com:6969/announce,udp://exodus.desync.com:6969/announce,udp://www.torrent.eu.org:451/announce,udp://vibe.sleepyinternetfun.xyz:1738/announce,udp://tracker1.bt.moack.co.kr:80/announce,udp://tracker.zerobytes.xyz:1337/announce,udp://tracker.torrent.eu.org:451/announce,udp://tracker.theoks.net:6969/announce,udp://tracker.srv00.com:6969/announce,udp://tracker.pomf.se:80/announce,udp://tracker.ololosh.space:6969/announce,udp://tracker.monitorit4.me:6969/announce,udp://tracker.moeking.me:6969/announce,udp://tracker.lelux.fi:6969/announce,udp://tracker.leech.ie:1337/announce,udp://tracker.jordan.im:6969/announce,udp://tracker.blacksparrowmedia.net:6969/announce 最后的 bt-tracker 可以从 trackerslist 获取,只用最好的 20 个即可(trackers_best (20 trackers) => link / mirror / mirror 2)。   接着启动 aria2:aria2c --conf-path="/Users/xxx/.aria2/aria2.conf" -D (xxx 为电脑用户名),在 ~/.zshrc 中加入 1 2 alias start-aria2='aria2c --conf-path="/Users/xxx/.aria2/aria2.conf" -D' start-aria2 将 start-aria2c 作为启动 aria2 的命令别名,顺便开机自启。   最后从 Aria2中文网 安装 Chrome 插件,打开 aria2 的 WebUI 界面。 expect   经常需要使用 ssh 远程登陆堡垒机再到远程服务器,输密码选机器都很麻烦,可以用 expect 写些脚本,自动填充密码和机器,一键直接进到远程服务器。首先安装 expect:brew install expect。在 /usr/local/bin 目录中新建脚本:sudo vi mysl.sh,填充相应内容: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 #!/usr/bin/expect -f set USER [用户名] set PWD [密码] set TERMSERVIP [堡垒机服务器ip] # 全部的远程服务器([remote_server_name] 需要修改为对应的服务器名 set RS1 [remote_server_name] set RS2 [remote_server_name] # help 命令,查看所有需要登录的远程服务器 if {[lindex $argv 0] == "help"} { puts "1: $RS1 [说明]" puts "2: $RS2 [说明]" send "exit\r" exit } # ===== 脚本正文 ===== # 默认登陆远程服务器1 set RS $RS1 set timeout 10 # 输入命令 1,则登陆第一台服务器 if {[lindex $argv 0] == "1"} { set RS $RS1 } if {[lindex $argv 0] == "2"} { set RS $RS2 } spawn ssh ${USER}@${TERMSERVIP} -p 22 expect { "yes/no" { send "yes\r"; exp_continue; } "*assword*" { send "$PWD\n"} } # 选择几号跳板机 expect "*num*" { send "0\n" } # 登陆远程服务器 expect "${USER}@" { send "ssh $RS\n" } # 退出 expect(保持在远程服务器终端 interact # 退出 expect(回到本地终端 # expect eof 为新建的脚本增加可执行权限:sudo chmod 777 mysl.sh,之后可直接使用 mysl.sh 1 登录到对应的远程服务器。 lrzsz   与 FTP 和 NFS 相比,使用 lrzsz 与远程 linux 服务器做文件上传和下载是最简单的,在 iTerm2 中使用 rz 和 sz 命令进行上传和下载文件需要一定的配置。※注: 使用 expect 自动登录的远程环境可能无法使用 sz rz 命令。   首先安装 lrzsz:brew install lrzsz。再跳转目录:cd /usr/local/bin,新建文件:sudo vi iterm2-recv-zmodem.sh,添加内容: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 #!/bin/bash # Author: Matt Mastracci (matthew@mastracci.com) # AppleScript from http://stackoverflow.com/questions/4309087/cancel-button-on-osascript-in-a-bash-script # licensed under cc-wiki with attribution required # Remainder of script public domain osascript -e 'tell application "iTerm2" to version' > /dev/null 2>&1 && NAME=iTerm2 || NAME=iTerm if [[ $NAME = "iTerm" ]]; then FILE=`osascript -e 'tell application "iTerm" to activate' -e 'tell application "iTerm" to set thefile to choose folder with prompt "Choose a folder to place received files in"' -e "do shell script (\"echo \"&(quoted form of POSIX path of thefile as Unicode text)&\"\")"` else FILE=`osascript -e 'tell application "iTerm2" to activate' -e 'tell application "iTerm2" to set thefile to choose folder with prompt "Choose a folder to place received files in"' -e "do shell script (\"echo \"&(quoted form of POSIX path of thefile as Unicode text)&\"\")"` fi if [[ $FILE = "" ]]; then echo Cancelled. # Send ZModem cancel echo -e \\x18\\x18\\x18\\x18\\x18 sleep 1 echo echo \# Cancelled transfer else cd "$FILE" /usr/local/bin/rz -E -e -b sleep 1 echo echo echo \# Sent \-\> $FILE fi 再新建文件:sudo vi iterm2-send-zmodem.sh,添加内容: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 #!/bin/bash # Author: Matt Mastracci (matthew@mastracci.com) # AppleScript from http://stackoverflow.com/questions/4309087/cancel-button-on-osascript-in-a-bash-script # licensed under cc-wiki with attribution required # Remainder of script public domain osascript -e 'tell application "iTerm2" to version' > /dev/null 2>&1 && NAME=iTerm2 || NAME=iTerm if [[ $NAME = "iTerm" ]]; then FILE=`osascript -e 'tell application "iTerm" to activate' -e 'tell application "iTerm" to set thefile to choose file with prompt "Choose a file to send"' -e "do shell script (\"echo \"&(quoted form of POSIX path of thefile as Unicode text)&\"\")"` else FILE=`osascript -e 'tell application "iTerm2" to activate' -e 'tell application "iTerm2" to set thefile to choose file with prompt "Choose a file to send"' -e "do shell script (\"echo \"&(quoted form of POSIX path of thefile as Unicode text)&\"\")"` fi if [[ $FILE = "" ]]; then echo Cancelled. # Send ZModem cancel echo -e \\x18\\x18\\x18\\x18\\x18 sleep 1 echo echo \# Cancelled transfer else /usr/local/bin/sz "$FILE" -e -b sleep 1 echo echo \# Received $FILE fi   为新建的两文件添加可执行权限:sudo chmod 777 iterm2-*。之后添加 rz sz 命令的软连接: 1 2 sudo ln -s /opt/homebrew/bin/rz /usr/local/bin/rz sudo ln -s /opt/homebrew/bin/sz /usr/local/bin/sz   最后配置 iTerm2,选择 Preference... -> Profiles -> Default -> Advanced -> Edit (in Triggers),添加下载触发器: 1 2 3 4 5 6 7 8 9 10 11 # 1. Regular expression 中填写 rz waiting to receive.\*\*B0100 # 2. Action 选择 Run Silent Coprocess... # 3. Parameters 中填写 /usr/local/bin/iterm2-send-zmodem.sh # 4. Instant 不勾选 # 5. Enabled 勾选 再添加上传触发器: 1 2 3 4 5 6 7 8 9 10 11 # 1. Regular expression 中填写 \*\*B00000000000000 # 2. Action 选择 Run Silent Coprocess... # 3. Parameters 中填写 /usr/local/bin/iterm2-recv-zmodem.sh # 4. Instant 不勾选 # 5. Enabled 勾选   至此 M1 中 iTerm2 rz sz 命令配置完成。 参考资料 iTerm2 + zsh + Oh My Zsh + Powerlevel10k 打造 Mac 下最强终端 Mac M1 iTerm2 配置rz sz 上传下载文件

2022/2/6
articleCard.readMore

Scala 多线程编程小结

前言   多线程的执行方式有两种:并发(Concurrent)和并行(Parallel),简单来说,并发就是两个线程轮流在一个 CPU 核上执行,而并行则是两个线程分别在两个 CPU 核上运行。一般而言,程序员无法直接控制线程是并发执行还是并行执行,线程的执行一般由操作系统直接控制,当然程序运行时也可以做简单调度。所以对于一般程序员来说,只需要熟练使用相关语言的多线程编程库即可,至于是并发执行还是并行执行,可能并不是那么重要,只要能达到预期效果就行。   Shaun 目前接触的 Scala 原生多线程编程语法就两个:Future 和 Parallel Collections。其中 Future 用的的最多,并且 Parallel Collections 语法非常简单,所以主要介绍 Future,附带提一下 Parallel Collections。 ExecutionContext 篇   ExecutionContext 是 Future 的执行上下文,相当于是 Java 的线程池,Java 的线程池主要有以下两类: ThreadPool:所有线程共用一个任务队列,当线程空闲时,从队列中取一个任务执行。 ForkJoinPool:每个线程各有一个任务队列,当线程空闲时,从其他线程的任务队列中取一批任务放进自己的队列中执行。   对于少量任务,这两个池子没啥区别,只是 ThreadPool 在某些情况下会死锁,比如在一个并行度为 2 (最多两个线程)的 ThreadPool 中执行两个线程,两个线程又分别提交一个子任务,并等到子任务执行完才退出,这时会触发相互等待的死锁条件,因为没有多余的空闲线程来执行子任务,而 ForkJoinPool 中每个线程产生的子任务会放在自己的任务队列中,ForkJoinPool 可以在线程耗尽时额外创建线程,也可以挂起当前任务,执行子任务,从而防止死锁。对于大量任务,ForkJoinPool 中的空闲线程会从其他线程的任务队列中一批一批的取任务执行,所以一般会更快,当然若各个任务执行时间比较均衡,则 ThreadPool 会更快。   根据线程池创建的参数不同,Executors 中提供了 5 种线程池:newSingleThreadExecutor(单线程线程池,可保证任务执行顺序),newFixedThreadPool(固定大小线程池,限制并行度),newCachedThreadPool(无限大小线程池,任务执行时间小采用),newScheduledThreadPool(同样无限大小,用来处理延时或定时任务),newWorkStealingPool(ForkJoinPool 线程池)。前四种都属于 ThreadPool,根据阿里的 Java 的编程规范,不推荐直接使用 Executors 创建线程池,不过对于计算密集型任务,一般使用 newFixedThreadPool 或 newWorkStealingPool 即可,线程数设置当前 CPU 数即可(Runtime.getRuntime.availableProcessors()),多了反而增加线程上下文切换次数,对CPU 的利用率不增反减。   Scala 提供了一个默认的 ExecutionContext:scala.concurrent.ExecutionContext.Implicits.global,其本质也是一个 ForkJoinPool,并行度默认设置为当前可用 CPU 数,当然也会根据需要(比如当前全部线程被阻塞)额外创建更多线程。一般做计算密集型任务就用默认线程池即可,特殊情况也可以自己创建 ExecutionContext.fromExecutor(Executors.newFixedThreadPool(8)),下面的代码就可以创建一个同步阻塞的 ExecutionContext: 1 2 3 4 5 val currentThreadExecutionContext = ExecutionContext.fromExecutor( new Executor { // Do not do this! def execute(runnable: Runnable) { runnable.run() } }) 原因是 runnable.run() 并不会新开一个线程,而是直接在主线程上执行,和调用普通函数一样。 Future 篇   先上一个简单的 Future 并发编程 Demo: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 ////import scala.concurrent.ExecutionContext.Implicits.global //val pool = Executors.newFixedThreadPool(Runtime.getRuntime.availableProcessors()) val pool = Executors.newWorkStealingPool() implicit val ec = ExecutionContext.fromExecutorService(pool) val futures = Array.range(0, 10000).map(i => Future { println(i) Thread.sleep(100) i }) val futureSequence = Future.sequence(futures) futureSequence.onComplete({ case Success(results) => { println(results.mkString("Array(", ", ", ")")) println(s"Success") ec.shutdown() pool.shutdownNow() } case Failure(e) => println(s"Error processing future operations, error = ${e.getMessage}") }) Await.result(futureSequence, Duration.Inf)   如果计算机 CPU 核数为 8 核,则程序运行成功后将会从 VisualVM 中看到有 8 个线程数在运行,控制台中会每次打印 8 条记录,最后打印出完整数组。   onComplete 是 Future 的回调函数,可对 Success 和 Failure 分别处理,Await 是为了阻塞主线程,当 futureSequence 执行完成后,才继续执行下面的任务。当然,主线程的阻塞也可以使用 Java 中的 CountDownLatch 来实现,只需要在每个 Future 执行完成后调用一次 countDown() 即可,或者直接在 onComplete 的回调函数中调用一次也行。(题外话:CountDownLatch 和 Golang 中的 sync.WaitGroup 感觉区别不大)。   如果不想让程序并发执行,则将 Future.sequence(futures) 改为 Future.traverse(futures)(x => x) 即可,此时就会一条条打印,但不保证打印顺序与数组一致。   如果使用 ExecutionContext.Implicits.global,并将上面创建 futures 的代码改为: 1 2 3 4 5 6 7 val futures = Array.range(0, 10000).map(i => Future { blocking { println(i) Thread.sleep(100) i } })   则控制台会马上将数组全部打印出来,从 VisualVM 中看会有非常多的线程在运行,远远超过 8 个,这是因为 ForkJoinPool 检测到当前线程以全部阻塞,所以需要另开线程继续执行,如果将线程池改为 Executors.newFixedThreadPool(8),则不会马上将数组全部打印,而是恢复原样,每次打印 8 条。blocking 需要慎用,如果 ForkJoinPool 中线程数太多,同样会 OOM,一般在大量运行时间短内存小的并发任务中使用。   Parallel Collections 并发编程就很简单了,demo 如下: 1 2 3 4 Array.range(0, 10000).par.foreach(i => { println(i) Thread.sleep(100) })   关键字为 par,调用该方法即可轻松进行并发计算,不过需要注意的是并发操作的副作用(side-effects)和“乱序”(out of order)语义,副作用就是去写函数外的变量,不仅仅只读写并发操作函数内部声明的变量,乱序语义是指并发操作不会严格按照数组顺序执行,所以如果并发操作会同时操作两个数组元素(eg:reduce),则需要慎重使用,有的操作结果不变,而有的操作会导致结果不唯一。 经验篇   Shaun 目前使用 Scala 进行多线程编程主要碰到过以下几个问题: 数据竞争问题 任务拆分问题 内存占用问题   数据竞争问题算是多线程编程中最常见的问题,简单来说就是两个线程同时写同一个变量,导致变量值不确定,引发后续问题,解决该问题有很多方法,性能由高到底有:Atomic,volatile,线程安全数据结构(eg:ConcurrentHashMap),Lock,synchronized,前两个方法性能最高,但局限性也很大,如果有现成的线程安全对象使用是最好的,没有的只能用 Lock 和 synchronized,这两种各有优缺点,synchronized 用法简单,能应付绝大部分问题,但对读也会加锁并且无法中断等待线程,Lock 是个接口,有比较多的派生对象(ReentrantLock,ReadWriteLock,ReentrantReadWriteLock 等),能更灵活的控制锁,不过使用起来相对复杂,需要显式地加锁解锁。   任务拆分问题,这个问题发生在任务量非常多(千万级以上)的时候,当需要对千万级数据进行并发处理时,单纯的生成相应的千万级 Future 在默认的 ExecutionContext 中执行会比较慢,甚至出现程序运行一段时间卡一段时间的现象(可能是内存不足,GC 卡了),此时需要人为对千万级任务进行合并。Shaun 这里有两种方案:一种是使用 grouped 将千万级任务划分为 16 组,从而降级为 16 个任务,生成 16 个Future,这时执行速度会快很多,且不会有卡的现象出现;另一种方案就是,每次只生成 10 万个 Future 放进 ExecutionContext 中执行,如此将千万级任务拆分成每次 10 万并发执行,同样能解决问题。   内存占用问题,这个问题发生在单个任务需要占用大量内存(1G 以上)的时候,当单个任务需要 1G 以上内存,8 个任务并行则需要 8G 以上内存,内存占用过高,提高 JVM 的内存,但也只是治标不治本。Shaun 的解决方案是对单个任务进行进一步拆分,将单个任务继续拆分为 16 个子任务,再将 16 个子任务的结果进行合并,作为单个大任务的结果,8 个大任务串行执行,如此内存占用极大减少,只需要单个任务的内存即可完成全部任务,且 CPU 利用率不变,执行速度甚至会更快(Full GC 次数变少)。   Shaun 在写大文件的时候会用到 newSingleThreadExecutor 和 Future.traverse,将写文件的操作放在 Future 里面,每次只写一个大文件(不用多线程写是因为机械硬盘的顺序读写肯定比随机读写快),而生产大文件内容的操作由默认的 ExecutionContext 执行,从而使生产与消费互不干扰,写大文件操作不会阻塞生产操作。   一个用 Future 实现的生产者消费者 demo: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 val poolProducer = Executors.newWorkStealingPool() implicit val ecProducer = ExecutionContext.fromExecutorService(poolProducer) val poolConsumer = Executors.newSingleThreadExecutor() val ecConsumer = ExecutionContext.fromExecutorService(poolConsumer) val futures = Array.range(0, 1000).map(i => Future { val x = produce(i) // produce something... x }(ecProducer).andThen { case Success(x) => consume(x) // consume something... }(ecConsumer)) val futureSequence = Future.sequence(futures) futureSequence.onComplete({ case Success(results) => { println("Success.") ecProducer.shutdown() poolProducer.shutdownNow() ecConsumer.shutdown() poolConsumer.shutdownNow() } case Failure(e) => println(s"Error processing future operations, error = ${e.getMessage}") }) Await.result(futureSequence, Duration.Inf) 后记   Shaun 这里写的 Scala 多线程编程主要是针对计算密集型任务,而 IO 密集型任务一般会用专门的一些框架,计算密集型考虑的是如何最大化利用 CPU,加快任务执行速度,线程数一般比较固定。Scala 的 Future 多线程编程相比 Java 的多线程编程要简洁了很多,唯一需要控制的就是并行度和任务拆分,Shaun 自己在用时也对 Future 做了简单封装,进一步简化了 Scala 的多线程编程,对 Iterable 的并发计算会更方便。 参考资料 [1] Futures and Promises [2] scala.concurrent.blocking - what does it actually do? [3] Parallel Collections [4] Java并发编程:Lock

2021/10/10
articleCard.readMore

Google S2 Geometry 浅解

前言   Google S2 Geometry(以下简称 S2) 是 Google 发明的基于单位球的一种地图投影和空间索引算法,该算法可快速进行覆盖以及邻域计算。更多详见 S2Geometry,Google’s S2, geometry on the sphere, cells and Hilbert curve,halfrost 的空间索引系列文章。虽然使用 S2 已有一年的时间,但确实没有比较系统的看过其源码,这次借着这段空闲时间,将 Shaun 常用的功能系统的看看其具体实现,下文将结合 S2 的 C++,Java,Go 的版本一起看,由于 Java 和 Go 的都算是 C++ 的衍生版,所以以 C++ 为主,捎带写写这三种语言实现上的一些区别,Java 版本时隔 10 年更新了 2.0 版本,喜大普奔。 坐标篇 s2 projection   S2 的投影方式可简单想象为一个单位球外接一个立方体,从球心发出一条射线得到球面上的点到立方体上 6 个面的投影,即将球面投影为立方体,当然中间为了使面积分布更为均匀,还做了些其他坐标变换。 S2LatLng 坐标   首先是经纬度坐标,默认用弧度(Radians)构造,取值范围为经度 [-π,+π],纬度 [-π/2,+π/2],当然也可使用 S1Angle 将角度(Degrees)转成弧度来构造。 S2Point 坐标   然后球面笛卡尔坐标,这是个三维坐标,由 S2LatLng 到 S2Point 相当于将单位球的极坐标表示法转换为笛卡尔坐标表示法,具体公式为 \(x=\cos(lat)cos(lng); y=cos(lat)sin(lng); z=sin(lat)\)。 FaceUV 坐标   这个坐标并没实际的类与其对应,face 指的是立方体的面,值域为 [0,5],而 uv 坐标是指面上的点,值域为 [-1,1]。首先需要知道 S2Point 会投影到哪个面上,可以知道 S2 的笛卡尔坐标 X 轴正向指向 0 面,Y 轴正向指向 1 面,Z 轴正向指向 2 面,X 轴负向指向 3 面,Y 轴负向指向 4 面,Z 轴负向指向 5 面,所以 S2Point xyz 哪个分量的绝对值最大,就会投影到哪个轴指向的面,若该分量为正值,则取正向指的面,若该分量为负值,则取负向指的面。至于 uv 的计算方式就是直线与平面的交点了,之前的一篇「计算几何基础」中写过,但这里的平面和直线都比较特殊,所以有快速算法,就直接贴 Go 的代码吧: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // validFaceXYZToUV given a valid face for the given point r (meaning that // dot product of r with the face normal is positive), returns // the corresponding u and v values, which may lie outside the range [-1,1]. func validFaceXYZToUV(face int, r r3.Vector) (float64, float64) { switch face { case 0: return r.Y / r.X, r.Z / r.X case 1: return -r.X / r.Y, r.Z / r.Y case 2: return -r.X / r.Z, -r.Y / r.Z case 3: return r.Z / r.X, r.Y / r.X case 4: return r.Z / r.Y, -r.X / r.Y } return -r.Y / r.Z, -r.X / r.Z }   这里需要注意的是 S2Point xyz 三分量构成的向量与平面法向量的点积必须是正数时 uv 才算正确有效,Go 在计算时没做校验,C++ 和 Java 都有校验,使用时需要注意。 FaceST 坐标   之所以引入 ST 坐标是因为同样的球面面积映射到 UV 坐标面积大小不一,大小差距比较大(离坐标轴越近越小,越远越大),所以再做一次 ST 变换,将面积大的变小,小的变大,使面积更均匀,利于后面在立方体面上取均匀格网(cell)时,每个 cell 对应球面面积差距不大。S2 的 ST 变换有三种:1、线性变换,基本没做任何变形,只是简单将 ST 坐标的值域变换为 [0, 1],cell 对应面积最大与最小比大约为 5.2;2、二次变换,一种非线性变换,能起到使 ST 空间面积更均匀的作用,cell 对应面积最大与最小比大约为 2.1;3、正切变换,同样能使 ST 空间面积更均匀,且 cell 对应面积最大与最小比大约为 1.4,不过其计算速度相较于二次变换要慢 3 倍,所以 S2 权衡考虑,最终采用了二次变换作为默认的 UV 到 ST 之间的变换。二次变换公式为: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public double stToUV(double s) { if (s >= 0.5) { return (1 / 3.) * (4 * s * s - 1); } else { return (1 / 3.) * (1 - 4 * (1 - s) * (1 - s)); } } public double uvToST(double u) { if (u >= 0) { return 0.5 * Math.sqrt(1 + 3 * u); } else { return 1 - 0.5 * Math.sqrt(1 - 3 * u); } } FaceIJ 坐标   IJ 坐标是离散化后的 ST 坐标,将 ST 空间的平面划分为 \(2^{30}×2^{30}\) 个网格,取网格所在的横纵坐标得到 IJ 坐标,所以由 ST 到 IJ 坐标的变换就比较简单了: 1 2 3 4 5 public static int stToIj(double s) { return Math.max( 0, Math.min(1073741824 - 1, (int) Math.round(1073741824 * s - 0.5)) ); } S2CellId   这个 id 其实是个一维坐标,而是利用希尔伯特空间填充曲线将 IJ 坐标从二维变换为一维,该 id 用一个 64 位整型表示,高 3 位用来表示 face(0~5),后面 61 位来保存不同的 level(0~30) 对应的希尔伯特曲线位置,每增加一个 level 增加两位,后面紧跟一个 1,最后的位数都补 0。注:Java 版本的 id 是有符号 64 位整型,而 C++ 和 Go 的是无符号 64 位整型,所以在跨语言传递 id 的时候,在南极洲所属的最后一个面(即 face = 5)需要小心处理。 HilbertCurve hilbert_curve_subdivision_ruleshilbert_curve   上面两张图很明了的展示了希尔伯特曲线的构造过程,该曲线的构造基本元素由 ABCD 4 种“U”形构成,而 BCD 又可由 A 依次逆时针旋转 90 度得到,所以也可以认为只有一种“U”形,每个 U 占 4 个格子,以特定方式进行 1 分 4 得到下一阶曲线形状。 每个 U 坐标与希尔伯特位置(用二进制表示)对应关系如下: A:00 -> (0,0); 01 -> (0,1); 10 -> (1,1); 11 -> (1,0); B:00 -> (1,1); 01 -> (0,1); 10 -> (0,0); 11 -> (1,0); C:00 -> (1,1); 01 -> (1,0); 10 -> (0,0); 11 -> (0,1); D:00 -> (0,0); 01 -> (1,0); 10 -> (1,1); 11 -> (0,1); 每个 U 一分四对应关系如下: A:D -> A -> A -> B B:C -> B -> B -> A C:B -> C -> C -> D D:A -> D -> D -> C   根据以上两个对应关系就能找到右手坐标系任意阶数的希尔伯特位置及坐标对应关系。以初始 1 阶曲线 A 为例,占据四个格子,然后进行一分四操作,四个格子分成 16 个格子,A 分为 DAAB 四个“U”形,连接起来即为 2 阶曲线,位置与坐标对应关系为(都用二进制表示): 0000 -> (00, 00); 0001 -> (01, 00); 0010 -> (01, 01); 0011 -> (00, 01); 0100 -> (00, 10); 0101 -> (00, 11); 0110 -> (01, 11); 0111 -> (01, 10); 1000 -> (10, 10); 1001 -> (10, 11); 1010 -> (11, 11); 1011 -> (11, 10); 1100 -> (11, 01); 1101 -> (10, 01); 1110 -> (10, 00); 1111 -> (11, 00);   从二进制中很容易看出随着阶数的增加,位置与坐标的对应关系:每增加一阶,位置往后增加两位,坐标分量各增加一位,位置增加的两位根据一分四对应关系拼接,坐标各分量增加的一位需先找到一分四对应关系,再找对应位置与坐标对应关系,将得到的坐标分量对应拼接。以一阶的 01 -> (0,1) 到二阶的 0110 -> (01, 11) 为例,首先根据 01 得到当前所属一阶第二块,查找一分四对应关系知道,下一阶这块还是 A,根据 0110 后两位 10 可知这块属于 A 的第三个位置,查找坐标得到是 (1,1),结合一阶的 (0,1),对应分量拼接得到坐标 (01,11),即 (1, 3),同理可根据第二阶的坐标反查第二阶的位置。有了这些关系,就能生成希尔伯特曲线了,下面就看看 S2 是怎么生成 id 的。 S2Id   首先 S2 中用了两个二维数组分别保存位置到坐标以及坐标到位置的对应的关系: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // kIJtoPos[orientation][ij] -> pos const int kIJtoPos[4][4] = { // (0,0) (0,1) (1,0) (1,1) { 0, 1, 3, 2 }, // canonical order { 0, 3, 1, 2 }, // axes swapped { 2, 3, 1, 0 }, // bits inverted { 2, 1, 3, 0 }, // swapped & inverted }; // kPosToIJ[orientation][pos] -> ij const int kPosToIJ[4][4] = { // 0 1 2 3 { 0, 1, 3, 2 }, // canonical order: (0,0), (0,1), (1,1), (1,0) { 0, 2, 3, 1 }, // axes swapped: (0,0), (1,0), (1,1), (0,1) { 3, 2, 0, 1 }, // bits inverted: (1,1), (1,0), (0,0), (0,1) { 3, 1, 0, 2 }, // swapped & inverted: (1,1), (0,1), (0,0), (1,0) }; // kPosToOrientation[pos] -> orientation_modifier const int kPosToOrientation[4] = {1, 0, 0, 3};   方向 0(canonical order)相当于上文中 A,方向 1(axes swapped)相当于上文中 D,方向 2(bits inverted)相当于上文中 C,方向 3(swapped & inverted)相当于上文中 B,kPosToOrientation 代表 S2 中方向 0 一分四的对应关系,而 方向 1,2,3 的对应关系可由该值推出,计算公式为 orientation ^ kPosToOrientation,eg:1 -> 1^kPosToOrientation=[0, 1, 1, 2]; 3 -> 3^kPosToOrientation=[2, 3, 3, 0],与上文中一分四对应关系一致。   随后 S2 初始化了一个 4 阶希尔伯特曲线位置与坐标的对应关系查找表,见 C++ 版的 MaybeInit() 方法, 1 2 3 int ij = (i << 4) + j; lookup_pos[(ij << 2) + orig_orientation] = (pos << 2) + orientation; lookup_ij[(pos << 2) + orig_orientation] = (ij << 2) + orientation;   orig_orientation 代表 4 个初始方向,orientation 代表该位置或坐标下一阶一分四的方向,数组中每个元素是 16 位数,2 个字节,一个四阶希尔伯特曲线是 \(2^4×2^4=256\) 个位置,一个初始方向对应一个四阶希尔伯特曲线,所以一个查找表共占内存 \(2×256×4=2048=2KB\),正好一级缓存能放下,再大的话,一级缓存可能放不下,反而会降低查找速度。这两个查找表就相当于 4 个超“U”形的位置与坐标对应关系,同时一分四对应关系保持不变,以超“U”作为基本元素做下一阶希尔伯特曲线,每增加一阶位置往后增加 8 位,IJ 坐标各往后增加 4 位,如此,以更快的速度迭代到 S2 想要的 30 阶希尔伯特曲线。C++ 的这份代码就很精妙了: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 S2CellId S2CellId::FromFaceIJ(int face, int i, int j) { // 初始化超“U”形查找表 MaybeInit(); // face 向左移 60 位 uint64 n = absl::implicit_cast<uint64>(face) << (kPosBits - 1); // 确定每个面的初始“U”形方向,使每个面都保持相同的右手坐标系,6 个面生成的希尔伯特曲线可以依次相连 uint64 bits = (face & kSwapMask); // 基于超“U”形得到 30 阶希尔伯特曲线 IJ 坐标对应位置 #define GET_BITS(k) do { \ const int mask = (1 << kLookupBits) - 1; \ bits += ((i >> (k * kLookupBits)) & mask) << (kLookupBits + 2); \ bits += ((j >> (k * kLookupBits)) & mask) << 2; \ bits = lookup_pos[bits]; \ n |= (bits >> 2) << (k * 2 * kLookupBits); \ bits &= (kSwapMask | kInvertMask); \ } while (0) // IJ 只有 30 位,7 这个调用只会导致位置移 4 位,后续调用都移 8 位,得到 4 + 8 * 7 = 60 位 GET_BITS(7); GET_BITS(6); GET_BITS(5); GET_BITS(4); GET_BITS(3); GET_BITS(2); GET_BITS(1); GET_BITS(0); #undef GET_BITS // 整个 n 向右移一位,再以 1 结尾 return S2CellId(n * 2 + 1); } 再来看看根据 id 反算 IJ 坐标: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 int S2CellId::ToFaceIJOrientation(int* pi, int* pj, int* orientation) const { // 与上面一样 MaybeInit(); int i = 0, j = 0; int face = this->face(); int bits = (face & kSwapMask); // 反算 IJ 坐标,k == 7 时,取希尔伯特曲线位置高 4 位,IJ 各前 2 位,其余依次取位置 8 位, IJ 各 4 位 #define GET_BITS(k) do { \ const int nbits = (k == 7) ? (kMaxLevel - 7 * kLookupBits) : kLookupBits; \ bits += (static_cast<int>(id_ >> (k * 2 * kLookupBits + 1)) \ & ((1 << (2 * nbits)) - 1)) << 2; \ bits = lookup_ij[bits]; \ i += (bits >> (kLookupBits + 2)) << (k * kLookupBits); \ j += ((bits >> 2) & ((1 << kLookupBits) - 1)) << (k * kLookupBits); \ bits &= (kSwapMask | kInvertMask); \ } while (0) GET_BITS(7); GET_BITS(6); GET_BITS(5); GET_BITS(4); GET_BITS(3); GET_BITS(2); GET_BITS(1); GET_BITS(0); #undef GET_BITS *pi = i; *pj = j; if (orientation != nullptr) { S2_DCHECK_EQ(0, kPosToOrientation[2]); S2_DCHECK_EQ(kSwapMask, kPosToOrientation[0]); // 0x1111111111111111ULL may be better? if (lsb() & 0x1111111111111110ULL) { bits ^= kSwapMask; } *orientation = bits; } return face; }   这里的 orientation 实际是指当前位置的方向,即其周围必有 3 个位置与其方向相同,最后一行注释 Shaun 之所以认为应该是 0x1111111111111111ULL,是因为第 30 阶希尔伯特曲线位置(leaf cell)按理说同样需要做异或操作得到方向,不过整个 S2 库都没有需要用到 leaf cell 的方向,所以这就倒无关紧要了。之所以需要做异或操作,是因为 bits 是该位置下一阶一分四的方向,而对于同一个希尔伯特曲线位置,奇数阶与奇数阶下一阶一分四方向相同,偶数阶与偶数阶下一阶一分四方向相同,lsb() 表示二进制 id 从右往左数第一个 1 所代表的数, 所以有 0x1111111111111110ULL 这一魔术数,而异或操作正好能将下一阶一分四方向调整为当前阶方向。   如此 S2 的坐标以及 id 的生成以及反算就很明了了,下面就是 S2 如何使用 id 做计算了。 FaceSiTi 坐标   这个是 S2 内部计算使用的坐标,一般用来计算 cell 的中心坐标,以及根据当前 s 和 t 坐标的精度(小数点后几位)判断对应的级别(level)。由于 S2 本身并不显式存储 ST 坐标(有存 UV 坐标),所以 ST 坐标只能计算出来,每个 cell 的中心点同样如此。计算公式为 \(Si=s*2^{31};Ti=t*2^{31}\)。至于为啥是 \(2^{31}\),是因为该坐标是用来描述从 0~ 31 阶希尔伯特曲线网格的中心坐标,0 阶中心以 \(1/2^1\) 递增,1 阶中心以 \(1/2^2\) 递增,2 阶中心以 \(1/2^3\) 递增,……,30 阶中心以 \(1/2^{31}\) 递增。S2 计算 id 对应的格子中心坐标,首先就会计算 SiTi 坐标,再将 SiTi 转成 ST 坐标。 算法篇 邻域算法   S2 计算邻域,最关键的是计算不同面相邻的 leaf cell id,即: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 S2CellId S2CellId::FromFaceIJWrap(int face, int i, int j) { // 限制 IJ 最大最小取值为 -1~2^30, 刚好能超出 IJ 正常表示范围 0~2^30-1 i = max(-1, min(kMaxSize, i)); j = max(-1, min(kMaxSize, j)); static const double kScale = 1.0 / kMaxSize; static const double kLimit = 1.0 + DBL_EPSILON; S2_DCHECK_EQ(0, kMaxSize % 2); // IJ -> SiTi -> ST -> UV double u = max(-kLimit, min(kLimit, kScale * (2 * (i - kMaxSize / 2) + 1))); double v = max(-kLimit, min(kLimit, kScale * (2 * (j - kMaxSize / 2) + 1))); face = S2::XYZtoFaceUV(S2::FaceUVtoXYZ(face, u, v), &u, &v); return FromFaceIJ(face, S2::STtoIJ(0.5*(u+1)), S2::STtoIJ(0.5*(v+1))); }   这个算法主要用来计算超出范围(0~2^30-1)的 IJ 对应的 id,核心思想是先将 FaceIJ 转为 XYZ,再使用 XYZ 反算得到正常的 FaceIJ,进而得到正常的 id。中间 IJ -> UV 中坐标实际经过了 3 步,对于 leaf cell,IJ -> SiTi 的公式为 \(Si=2×I+1\),而对于 ST -> UV,这里没有采用二次变换,就是线性变换 \(u=2*s-1\),官方注释上说明用哪个变换效果都一样,所以采用最简单的就行。 边邻域   边邻域代码很简单,也很好理解: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void S2CellId::GetEdgeNeighbors(S2CellId neighbors[4]) const { int i, j; int level = this->level(); // 计算当前 level 一行或一列对应多少个 30 级的 cell(leaf cell) 2^(30-level) int size = GetSizeIJ(level); int face = ToFaceIJOrientation(&i, &j, nullptr); // Edges 0, 1, 2, 3 are in the down, right, up, left directions. neighbors[0] = FromFaceIJSame(face, i, j - size, j - size >= 0) .parent(level); neighbors[1] = FromFaceIJSame(face, i + size, j, i + size < kMaxSize) .parent(level); neighbors[2] = FromFaceIJSame(face, i, j + size, j + size < kMaxSize) .parent(level); neighbors[3] = FromFaceIJSame(face, i - size, j, i - size >= 0) .parent(level); }   分别计算当前 IJ 坐标下右上左坐标对应 id,FromFaceIJSame 表示若邻域在相同面,则走 FromFaceIJ,否则走 FromFaceIJWrap,由于这两个函数得到都是 leaf cell,要上升到指定 level,需要用到 parent 方法,即将希尔伯特曲线位置去掉右 \(2*(30-level)\) 位,再组合成新的 id,位运算也很有意思: 1 2 3 4 5 6 7 8 9 static uint64 lsb_for_level(int level) { return uint64{1} << (2 * (kMaxLevel - level)); } inline S2CellId S2CellId::parent(int level) const { uint64 new_lsb = lsb_for_level(level); // 取反加一实际是取负数 return S2CellId((id_ & (~new_lsb + 1)) | new_lsb); } 点邻域   S2 的点邻域并不是指常规意义上 4 个顶点相邻左上右上右下左下的 id,而是一种比较特殊的相邻关系,以直角坐标系 (0,0),(0,1),(1,1),(1,0) 为例,(0,0) 的点邻域为 (0,0),(0,-1),(-1,-1),(-1,0),(0,1) 的点邻域为 (0,1),(0,2),(-1,2),(-1,1),(1,1) 的点邻域为 (1,1),(1,2),(2,2),(2,1),(1,0) 的点邻域为 (1,0),(1,-1),(2,-1),(2,0)。具体代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 void S2CellId::AppendVertexNeighbors(int level, vector<S2CellId>* output) const { // level < this->level() S2_DCHECK_LT(level, this->level()); int i, j; int face = ToFaceIJOrientation(&i, &j, nullptr); // 判断 IJ 落在 level 对应 cell 的哪个方位?(左下左上右上右下,对应上文的(0,0),(0,1),(1,1),(1,0)坐标) int halfsize = GetSizeIJ(level + 1); int size = halfsize << 1; bool isame, jsame; int ioffset, joffset; if (i & halfsize) { ioffset = size; isame = (i + size) < kMaxSize; } else { ioffset = -size; isame = (i - size) >= 0; } if (j & halfsize) { joffset = size; jsame = (j + size) < kMaxSize; } else { joffset = -size; jsame = (j - size) >= 0; } output->push_back(parent(level)); output->push_back(FromFaceIJSame(face, i + ioffset, j, isame).parent(level)); output->push_back(FromFaceIJSame(face, i, j + joffset, jsame).parent(level)); // 则邻域的 IJ 与当前 cell 都不在同一个面,则说明只有三个点邻域 if (isame || jsame) { output->push_back(FromFaceIJSame(face, i + ioffset, j + joffset, isame && jsame).parent(level)); } }   上面的代码算是比较清晰了,3 个点邻域的情况一般出现在当前 id 位于立方体 6 个面的角落,该方法的参数 level 必须比当前 id 的 level 要小。 全邻域   所谓全邻域,即为当前 id 对应 cell 周围一圈 cell 对应的 id,若周围一圈 cell 的 level 与 当前 id 的 level 一样,则所求即为正常的 9 邻域。具体代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 void S2CellId::AppendAllNeighbors(int nbr_level, vector<S2CellId>* output) const { // nbr_level >= level S2_DCHECK_GE(nbr_level, level()); int i, j; int face = ToFaceIJOrientation(&i, &j, nullptr); // 先归一 IJ 坐标,将 IJ 坐标调整为当前 cell 左下角 leaf cell 的坐标 int size = GetSizeIJ(); i &= -size; j &= -size; int nbr_size = GetSizeIJ(nbr_level); S2_DCHECK_LE(nbr_size, size); for (int k = -nbr_size; ; k += nbr_size) { bool same_face; if (k < 0) { same_face = (j + k >= 0); } else if (k >= size) { same_face = (j + k < kMaxSize); } else { same_face = true; // 生成外包围圈下上两边的 id, 顺序为从左往右 output->push_back(FromFaceIJSame(face, i + k, j - nbr_size, j - size >= 0).parent(nbr_level)); output->push_back(FromFaceIJSame(face, i + k, j + size, j + size < kMaxSize).parent(nbr_level)); } // 生成外包围圈左右两边以及四个边角的 id, 顺序为从下往上 output->push_back(FromFaceIJSame(face, i - nbr_size, j + k, same_face && i - size >= 0) .parent(nbr_level)); output->push_back(FromFaceIJSame(face, i + size, j + k, same_face && i + size < kMaxSize) .parent(nbr_level)); if (k >= size) break; } }   知道这个函数的作用,再看代码就很明了了,这个方法的参数 nbr_level 必须大于或等于当前 id 的 level,因为一旦外包围圈的 cell 面积比当前 cell 还大,就无法得到正确的外包围圈。 覆盖算法   S2 的覆盖,是指给定一块区域,能用多少 id 对应的 cell 完全覆盖该区域(GetCovering),当然也有尽量覆盖的算法(GetInteriorCovering),下面主要解析 GetCovering,因为 GetInteriorCovering 也差不多,就是覆盖策略略有不同。 GetCovering 的区域入参是 S2Region,比较典型的 S2Region 有以下几种: S2Cell:S2 id 对应的网格,会保存左下右上两个 UV 坐标,也是覆盖算法使用的基本元素; S2CellUnion:多个 S2Cell 集合体,GetCovering 的返回值; S2LatLngRect:经纬度矩形区域; S2Cap:球帽区域,类比于二维圆的圆弧,球帽的构造比较奇怪,球帽的中心 S2Point 是需要,但另一个变量不是球帽的圆弧角,而是半个圆弧角(S2 代码库对应的 S1Angle 弧度,90 度代表半球,180 度代表全球)所对应弦长的平方,最大值为 4,之所以采用弦长的平方作为默认构造,是因为这就是 3 维中距离,在进行距离比较的场景时会更方便,比如测试是否包含一个 S2Point,计算覆盖多边形时,就不用再比较角度,毕竟角度计算代价比较大; S2Loop:多边形的基本组成元素,第一个点与最后一个点隐式连接,逆时针代表封闭,顺时针代表开孔取外围区域,不允许自相交; S2Polygon:非常正常的复杂多边形,由多个 S2Loop 构成,S2Loop 之间不能相交; S2Polyline:一条折线,同样不能自相交; 还有些其它不常用的:S2R2Rect(S2Point 矩形区域),S2RegionIntersection(集合相交区域),S2RegionUnion(集合合并区域),……等。   S2 覆盖算法的本质是一种启发式算法,先取满足当前条件最基本的元素,再依照条件进行迭代优化,所以该算法得到的只是一个近似最优解。GetCovering 需要依次满足以下条件: 生成的 S2Cell level 不能比指定的 minLevel 小;(必须满足) 生成的 S2Cell 的个数不能比指定的 maxCells 多;(可以满足,当满足 1 时,数目已经 maxCells 多,迭代停止) 生成的 S2Cell level 不能比指定的 maxLevel 大;(必须满足)   以上 3 个条件对应 GetCovering 的其他三个参数,当然还有一个参数是 levelModel,表示从 minLevel 向下分到 maxLevel 时,是 1 分 4,还是 1 分 16,还是 1 分 64,对应一次升 1 阶曲线,还是一次升 2 阶,或是一次升 3 阶。下面就来具体看看 GetCovering 的算法流程(代码就不贴了,太多了): 首先获取候选种子 S2Cell。先构造一个临时覆盖器,设置 maxCells 为 4,minLevel 为 0,以快速得到初始覆盖结果,做法为:先得到覆盖输入区域的 S2Cap,再用 S2CellUnion 覆盖该 S2Cap,根据 S2Cap 圆弧度计算 S2Cell 的 level,若最终 level < 0,则说明 S2Cap 非常大,需要取 6 个面对应的 S2Cell,否则只需要取 S2Cap 中心点对应 S2Cell 的 level 级的点邻域 4 个 S2Cell 作为初始候选 S2Cell。 然后标准化候选种子。第一步,如果候选 S2Cell level 比 maxLevel 大或者候选 S2Cell 的 level 不符合 levelModel,则调整候选 S2Cell 的 level,用指定父级 S2Cell 来代替;第二步,归一化候选 S2Cell,先对 S2Cell 按 id 排序,去除被包含的 id,以及对 id 剪枝(若连续 4 个 S2Cell 共有同一个 parent,则用 parent 代替这 4 个 S2Cell);第三步,反归一化候选 S2Cell,若候选 S2Cell level 比 minLevel 小或不满足 levelModel,则需要将 S2Cell 分裂,用指定级别的孩子来取代该 S2Cell;第四步,检查是否满足全部条件,若满足,则标准化完成,若不满足,则看候选 S2Cell 的数目是否足够多,若足够多,则需要迭代进行 GetCovering,这样会极大降低算法性能,若不是很多,则迭代合并相同祖先的两个 S2Cell(当然祖先的 level 不能比 minLevel 小),最后再次检查所有候选 S2Cell 是否达到标准化要求,并调整 S2Cell level。 构造优先级队列。将符合条件(与入参区域相交)的候选 S2Cell 放进一个优先级队列中,优先级会依次根据三个参数进行判断,1、S2Cell 的大小(level 越大,S2Cell 越小),越大的优先级越高;2、入参区域与候选 S2Cell 孩子相交(这里的相交是指相交但不完全包含)的个数,越少优先级越高;3、入参区域完全包含候选 S2Cell 孩子和与无法再细分的孩子的个数,同样是越少优先级越高。在构造这个优先级队列的同时,会输出一些候选 S2Cell 作为覆盖算法的正式结果,这些 S2Cell 满足任意以下条件:1、被入参区域完全覆盖;2、与入参区域相交但不可再细分;3、入参区域包含或相交全部孩子。如此留在优先级队列中的,就都是些与入参区域边界相交的 S2Cell,这些就是真正的候选 S2Cell。 最后,处理优先级队列中的 S2Cell。处理方式也比较简单粗暴,继续细分并入队,满足上面3个出队条件的任意一个,即可出队作为正式结果,当然,若分到后面可能正式的 S2Cell 太多,甚至超过 maxCells,这时不再细分强行出队作为正式结果。最后,再对正式结果做一次标准化处理,即进行第 2 步,得到最终的覆盖结果。   以上就是 S2 覆盖算法的大致流程,更加细节的东西,还是得看代码,文字有些不是很好描述,代码里面计算候选 S2Cell 的优先级就很有意思。   当然 S2 中还有很多其他算法(凸包,相交,距离),这里就不做太多介绍了,Shaun 平常用的最多的就是覆盖算法,之前一直没有细看,就简单用用 api,同时为了对一块大的 S2Cell 做多线程处理,需要了解 S2Cell 一分四的方向,经过这次对 S2 的了解,发现之前的用法存在一些问题,可见调包侠同样需要对包有一定的了解才能调好包 ╮(╯▽╰)╭。 后记   正如许多经典的算法一样,看完之后总有种我上我也行的感觉,但实际完全不行,S2 全程看下来有些地方确实比较晦涩,而且这一连串的想法也很精妙(单位球立方体投影,ST 空间面积优化,64 位 id 生成等),Shaun 或许能有部分想法,但这么多奇思妙想组合起来,就完全不行。 附录 HilbertCurve 绘制   在网上随便找了三种实现方式,并用 threejs 简单绘制了一下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 import * as THREE from "three"; export default class HilbertCurve { order = 3; // 阶数 size = 1 << this.order; // 行列数 totalSize = this.size * this.size; // 总网格数,希尔伯特长度 // https://www.youtube.com/watch?v=dSK-MW-zuAc getPath_V1() { let path = []; let origOrientation = [ [0, 0], [0, 1], [1, 1], [1, 0], ]; // 倒 U 形 for (let i = 0; i < this.totalSize; i++) { path.push(hilbertToXY(i, this.order)); } return path; function hilbertToXY(i: number, order: number) { let index = i & 3; let curCoord = origOrientation[index].slice(); for (let ord = 1; ord < order; ord++) { i = i >>> 2; index = i & 3; let delta = 1 << ord; if (index === 0) { // 顺时针旋转 90° let tmp = curCoord[0]; curCoord[0] = curCoord[1]; curCoord[1] = tmp; } else if (index === 1) { curCoord[1] += delta; } else if (index === 2) { curCoord[0] += delta; curCoord[1] += delta; } else if (index === 3) { // 逆时针旋转 90° let tmp = delta - 1 - curCoord[0]; curCoord[0] = delta - 1 - curCoord[1]; curCoord[1] = tmp; curCoord[0] += delta; } } return curCoord; } } // Hacker's Delight getPath_V2() { let path: number[][] = []; let x = -1, y = 0; let s = 0; // along the curve step(0); hilbert(0, 1, this.order); return path; function step(dir: number) { switch (dir & 3) { case 0: x += 1; break; case 1: y += 1; break; case 2: x -= 1; break; case 3: y -= 1; break; } path[s] = [x, y]; s += 1; } function hilbert(dir: number, rot: number, order: number) { if (order === 0) return; dir += rot; hilbert(dir, -rot, order - 1); step(dir); dir -= rot; hilbert(dir, rot, order - 1); step(dir); hilbert(dir, rot, order - 1); dir -= rot; step(dir); hilbert(dir, -rot, order - 1); } } // https://en.wikipedia.org/wiki/Hilbert_curve getPath_V3() { let path: number[][] = []; // for (let i = 0; i < this.totalSize; i++) { // path.push(hilbertToXY(this.size, i)); // } for (let y = 0; y < this.size; y++) { for (let x = 0; x < this.size; x++) { path[xyToHilbert(this.size, x, y)] = [x, y]; } } return path; function rot(N: number, rx: number, ry: number, xy: number[]) { if (ry === 0) { if (rx === 1) { xy[0] = N - 1 - xy[0]; xy[1] = N - 1 - xy[1]; } let t = xy[0]; xy[0] = xy[1]; xy[1] = t; } } function hilbertToXY(N: number, h: number) { let t = h; let xy = [0, 0]; for (let s = 1; s < N; s *= 2) { let rx = 1 & (t / 2); let ry = 1 & (t ^ rx); rot(s, rx, ry, xy); xy[0] += s * rx; xy[1] += s * ry; t /= 4; } return xy; } function xyToHilbert(N: number, x: number, y: number) { let h = 0; let xy = [x, y]; for (let s = N / 2; s > 0; s /= 2) { let rx = (xy[0] & s) > 0 ? 1 : 0; let ry = (xy[1] & s) > 0 ? 1 : 0; h += s * s * ((3 * rx) ^ ry); rot(N, rx, ry, xy); } return h; } } draw() { let lineGeometry = new THREE.Geometry(); this.getPath_V3().forEach((vertice) => { let vecot = new THREE.Vector3().fromArray(vertice); vecot.setZ(0); lineGeometry.vertices.push(vecot); }); let lineMaterial = new THREE.LineBasicMaterial({ color: 0x00ffff, linewidth: 1 }); let line = new THREE.Line(lineGeometry, lineMaterial); return line; } }

2021/9/19
articleCard.readMore

K8S 应用开发指北

前言   在周志明的『凤凰架构』中需要思考这样一个问题,如何用不可靠的部件来构造一个可靠的系统?对于程序员来说,写的代码从某种程度上来说都是不可靠的,但这些代码组成的一些系统却可以是可靠的。程序员对于错误的处理可以分为两派,一派是必须对错误进行处理,以保证系统的稳定行;另一派不对错误进行处理,任由程序 crash,只要有兜底方案,后面再不断完善。这两派并无孰优孰劣,只是两种不同的思维方式,甚至在同一个程序中,有些错误会处理,有些错误不会处理,这都是可能的。K8S 作为事实上的云原生操作系统,其目的就是为了将程序员写的各个程序组装成一个稳定的系统,并减少运维成本。 基础篇   K8S 调度的基本单元是 Pod,Pod 也是 K8S 自带的一个资源对象,其可以简单理解为是一个容器集合体,程序员可控的容器有两类(Pause 容器除外),一类是 InitContainer,另一类是普通业务容器,InitContainer 按数组顺序创建,顺序执行,若一个失败,则整个 Pod 创建失败,普通业务容器同样按数组顺序创建,但异步执行,所以执行顺序不可控(可以通过 postStart Hook 简单控制一下)。由于 InitContainer 先于 Pod 其他容器执行,所以一般用来做普通业务容器执行前置条件的一些事情,比如:下载文件,初始化配置,状态消息通知等。   同一 Pod 中存储卷和网络可以共享。存储卷共享是指 Pod 内各容器可以挂载相同存储卷,从而数据共享。K8S 目前支持的存储卷共有三种:第一种是 emptyDir,这种存储是临时的,只能在 Pod 内使用,当 Pod 被销毁时,该存储的内容也会消失,只能在同一 Pod 内共享数据;第二种是 hostPath,这种存储会直接和集群中物理机存储相关联,是一种跨 Pod 持久化存储,但仅限该物理机,当 pod 被调度到其他物理机时就无法实现跨 Pod 共享数据;最后一种是外部存储(NFS,Ceph,GlusterFS,AWS EBS 等),这种方式可以真正实现数据持久化并共享,而且可以支持存储与计算分离,对系统会更友好一些,当然运维的成本也会更大。当然除了 K8S 自身提供的存储卷挂载可以实现数据共享,从程序的角度上,使用传统的方式一样也能数据共享,如数据库,DFS,OSS 等。   而网络共享是指 Pod 内各容器直接可以使用 localhost 以及容器暴露的端口进行相互通信,K8S 的端口有三种,分别为:容器端口(containerPort,容器中对外暴露的端口),集群内端口(port,集群内 pod 相互通信的端口),集群外端口(nodePort,集群外请求集群内的端口),其中容器端口和集群内是正常的动态端口,取值范围为 [1024, 65535],集群外端口只能设置为 [30000, 32767],若集群中服务不与集群外通信,则只需要设置集群内端口就行。K8S 中 IP 也同样有三种,分别为:Pod IP(两不同 Pod 资源对象相互通信的地址,集群外不可访问),Cluster IP(Service 资源对象的通信地址,集群外不可访问),Node IP(K8S 物理节点的 IP 地址,是真实的物理网络,集群外配合 nodePort 即可访问)。集群内端口和集群外端口由 K8S 的 Service 资源提供设置。在创建 Service 时需要注意,一个 Pod 资源对应一个 Service 资源,不要想着一个 Service 管理两个 Pod 暴露的端口,这样做会使 Service 提供服务的能力异常,经常会接口超时。   K8S 编程可以简单称之为面向 config 编程,一切需要动态变化的程序初始化变量,都应该以 config 的形式提供,然后交给运维就行,这样可以避免程序员频繁的修改程序,减少运维负担,K8S 的 config 有三种形式,第一种是程序启动参数,通过创建容器时的 args 参数配置;第二种是系统环境变量,通过创建容器时的 env 参数配置;最后一种是 K8S 提供的 ConfigMap 资源,该资源可以从文件,目录或 key-value 字符串创建,创建后的 ConfinMap 被全集群同命名空间所共享,可以通过 volumes 参数挂载到 pod 中,进而 mount 进容器中,被程序读取。前两种 config 方式对于配置变量少的可以使用,当配置变量很多或配置参数很长时,还是使用 ConfigMap 比较合适。 调度篇   调度,广义上的调度可指一切管理安排,CPU 的指令执行就涉及到三级缓存的调度,程序运行时的 GC 可认为是运行时对内存资源的调度,操作系统的进程轮转可认为是系统对进程的调度,而 K8S 中的调度可简单理解为是对操作系统的调度。   K8S 的调度可简单分为两个层面上的调度,最底层的调度自然是 K8S 自身的调度策略,根据不同的资源用度和调度策略将 Pod 分配到不同的物理节点之上执行,根据指定的重启或恢复策略启动相应的 Pod,这个层面上的调度,K8S 有一套默认的调度器,对于特殊的调度需求,K8S 也支持自定义调度器,使用外部调度器代替默认调度器,这个层面的调度器 Shaun 没做太多研究,所以在这篇里对这层面的调度器不做过多描述。Shaun 接触过的是更上层的调度器,业务层面的调度服务,业务调度服务一般与业务紧密相关,但最核心的一点就是能够从业务入手,负责 Pod 的创建和销毁,并能掌握其运行状态,就算是完成了一个基础的业务调度服务器。   在设计业务调度服务时,有一种通用的模式,可以称之为 master-worker 模式,与同名的并发模式细节上有所不同,这里的 master 是指调度服务本体,只负责对外服务,资源监控,以及任务分发,任务状态感知等,不负责做具体的任务,一般也不关心任务的输入输出。在部署 master 时,一般会创建一个 Service 资源对象,毕竟其主要功能就是对外服务,master 一般由运维进行部署创建销毁。而 worker 是指真正做任务的 Pod,该 Pod 中可能会有多个容器,主容器负责真正执行任务,其他一些容器可能会负责保障任务的前置条件(输入,配置等),以及向 master 汇报任务执行状态信息(执行任务的主容器可能并不知道 master 的存在)等。worker 对应的 Pod 一般由 master 进行创建销毁,worker 的一些配置信息则可能会由运维管理。   由于 K8S 并没有在整个集群物理资源之上抽象出一层集群资源,所以 K8S 分配的节点实际还是在物理机上,若所有物理机剩余资源(是单个剩余资源,而不是所有剩余资源之和)都不满足 Pod 所需资源,则该 Pod 无法调度,类比内存碎片化,可以称之为资源碎片化。所以在创建 Pod 时,所需资源最好不要太多,以免调度失败。 实践篇   Shaun 目前在 K8S 上开发的主要就是重计算(单机计算时间以小时计)调度服务。这类调度服务其实也分两种,一种是并发调度,一种是流水线(pipeline)式的串行调度,当然也可以将这两种混合起来,串行中有并行。在设计这类调度服务时,需要考虑集群上的资源(内存,CPU)是否足够,若不足,则可以考虑加入一个简单的等待机制,将任务放进一个队列中,当然加入这样一个等待机制,又会增加系统复杂性,需要考虑队列容量,队列优先级等。所以可执行的最小任务消耗的资源越少约好,否则集群中可能完全无法执行相关任务。   由于 Shaun 是独立开发,能完全控制 master 和 worker 的编写,所以 worker 设计的比较简单,一个主容器即完成了前置数据处理,主任务执行,执行状态汇报等全部事情,这是从时间和性能上以及系统复杂度上等多方面权衡的结果,当然在时间足够人手够的情况,是应该把现有的 worker 进一步分离的,而 master 就是比较通用的设计,资源监控,任务队列,任务 Pod 创建与销毁,任务状态信息保存,服务接口等,其中常规的服务接口应该有添加任务,开始任务,停止任务,恢复任务,删除任务,任务状态查询,任务日志查询,任务状态汇报等接口,如果任务是并行且无依赖的,还应该支持开始指定子任务等接口。   在工作中,Shaun 也接触到一个 pipeline 式的任务调度服务,pipeline 式的工作流有个特点就是下一个子任务的输入必定依赖上一个子任务的输出,在这个任务调度服务中,其子任务的输入输出都是文件态,并且 master 不关心子任务的输入输出,子任务的执行程序也不知道 master 的存在,尽量低耦合。在云上,文件态的存储载体比较好的自然是 OSS,但原本的子任务执行程序只支持本地读取文件,而且在原来的程序中引入 OSS 的读写逻辑并不十分合适,所以在 K8S 中引入了 NFS,由 master 负责将 NFS 挂载到各子任务的 Pod 中,并在挂载到主容器时使用 SubPath 完成 pipeline 之间的资源隔离,使用 emptyDir 完成各子任务之间的资源隔离,每条 pipeline 开始的子任务是从 OSS 中拉取文件到 NFS 中对应的 SubPath 目录中,结束的子任务是将 NFS 中对应的 SubPath 目录中约定好的生成物上传到 OSS 中,并清空该 SubPath 目录,从而使原来的程序在 IO 这块完全不用改动。在监听任务运行状态方面,有两种方案:一种是利用 K8S 的 InitContainer,另一种是借助 K8S 的 shareProcessNamespace。InitContainer 的方案比较简单,InitContainer 第一个容器只做汇报子任务开始这一件事, 第二个容器则是真正执行子任务的容器,而业务容器只做汇报子任务结束这一件事,该方案利用 InitContainer 顺序且先于业务容器执行这两特点,并且若执行子任务的容器失败,则 Pod 也会创建失败,查询 Pod 状态即可知道子任务是否正常运行。而 shareProcessNamespace 的方案稍微复杂一些,同样使用一个 InitContainer 做汇报子任务开始这件事,而业务容器中放两个容器:一个主容器和一个 sidecar 容器(希望 K8S 原生支持的 SideCar 早日做好 ╯△╰),sidecar 容器中以轮询的方式监听主容器的运行状态(查询是否存在主进程)以及是否正常退出(获取容器退出码),并向 master 推送状态信息,该方案借助进程空间共享,使 sidecar 容器能直接查询主容器中的进程,从而达到监听主容器运行状态的目的,该方案的执行还需要一个小 trick,就是要让主容器先执行,由两种方案:一种是借助 postStart Hook,另一种是直接让 sidecar 容器先休眠个 10s 钟。关于 sidecar 容器的另外一种应用方案可参考 Nginx容器配置文件如何热更新? 。   虽然分布式任务调度框架有很多,eg:Airflow、Luigi 以及 DolphinScheduler 等,但目前与 K8S 联系最紧密的应该就是 Argo 了,其利用 K8S 的自定义资源对 K8S 已有功能进行扩展,仅使用 YAML 即可完成整个 pipeline 的任务调度和部署,虽然在并发任务调度时有一定的缺陷,但仅使用 YAML 表示其对 K8S 运维的足够友好性,对于常规 pipeline 式任务,Argo 已足以应付,除特殊需求外,程序员可少写很多代码。 附录   对于 Spring 编写的程序,在 K8S 中运行,在导出日志时可参考 k8s:获取pod的ip,通过 valueFrom 使用 Pod 的 metadata 作为环境变量,以区分日志的来源,不过挂载存储时最好还是用外部存储,用 hostPath 的话就需要保证每个物理节点都有相同的日志存储目录。 后记   K8S 作为云原生时代的操作系统,不要求人人都完全掌握,但至少需要了解,知道什么该开发干,什么该运维干,这样才能充分发挥各个角色(包括 K8S)的价值。

2021/8/28
articleCard.readMore

OpenGL坐标系统与渲染管线

前言   图形学中最基础的东西就是坐标系统,三维的东西如何在二维中显示,这中间经历了数次坐标变换,同时坐标变换也贯穿了整个计算机图形渲染管线。 坐标篇 coordinate_systems   在计算机图形世界中,为更灵活的控制三维物体显示在二维中,将变换的过程大致分为 5 个空间:1、局部空间(Local Space,或者称为物体空间(Object Space));2、世界空间(World Space);3、观察空间(View Space,或者称为视觉空间(Eye Space));4、裁剪空间(Clip Space);5、屏幕空间(Screen Space)。局部空间中是物体相对于坐标原点的坐标,也是物体的固有坐标,在依次经历过缩放旋转平移,也即模型矩阵(Model Matrix)变换后,物体局部坐标变换为世界坐标,世界坐标中即定义了物体所在的位置,以及产生的旋转和缩放。在世界空间中加入相机,以相机的视角看世界中的物体,即通过观察矩阵(View Matrix,也称视图矩阵)变换后,将世界坐标转换为观察坐标,由于一张屏幕能显示的东西是有限的,而三维世界中的物体是无限,所以需要通过投影矩阵(Projection Matrix)对三维空间进行裁剪,以决定哪些物体能显示在屏幕上,为方便的计算机判断,处于裁剪空间内的坐标会被转换为 [-1, 1],为顺利在屏幕上显示,又需要通过视窗变换(Viewport Transform)将 [-1, 1] 映射为 viewport 中的图元坐标,再通过渲染管线的其他流程输出为屏幕上的像素点。 变换篇   矩阵相乘一般有左乘和右乘之分,左乘和右乘的区别在于坐标是按列还是按行排列(OpenGL 中是按列,所以是左乘,DX 中按行,所以是右乘,同一种变换,传入 DX 中的矩阵与传入 OpenGL 中的矩阵互为转置),坐标与矩阵相乘越靠近坐标的矩阵表示该坐标越先做相应矩阵变换。   模型矩阵,视图矩阵,投影矩阵,在简单的顶点着色器编程中,这三个矩阵一般会合并成一个 MVP 矩阵传入 GPU 中。 模型矩阵   模型矩阵一般定义了物体的缩放旋转平移状态,缩放矩阵的构造很简单,若物体在 \((x,y,z)\) 方向上缩放尺度分别为 \((S_x, S_y, S_z)\),则缩放矩阵为: \[M_{scaling} = \begin{bmatrix} S_x & 0 & 0 & 0 \\ 0 & S_y & 0 & 0 \\ 0 & 0 & S_z & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}\]   旋转矩阵就非常麻烦了,这里暂且不讨论其如何计算,只给出矩阵,物体绕任意轴 \((R_X, R_y, R_z)\) 旋转 θ 角的矩阵为: \[M_{rotation} = \begin{bmatrix} cos\theta+R_x^2(1-cos\theta) & R_xR_y(1-cos\theta)-R_zsin\theta & R_xR_z(1-cos\theta)+R_ysin\theta & 0 \\ R_yR_x(1-cos\theta)+R_zsin\theta & cos\theta+R_y^2(1-cos\theta) & R_yR_z(1-cos\theta)-R_xsin\theta & 0 \\ R_zR_x(1-cos\theta)-R_ysin\theta & R_zR_y(1-cos\theta)+R_xsin\theta & cos\theta+R_z^2(1-cos\theta) & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}\]   当然,由于万向节锁的存在,一般不会直接使用欧拉角和旋转轴计算旋转矩阵,而是会通过四元数得到旋转矩阵,这样既高效又能避免万向节锁,详情可看「LearnOpenGL」译者的教程。   至于平移矩阵也非常简单,若物体在 \((x,y,z)\) 方向上平移量分别为 \((T_x, T_y, T_z)\),则平移矩阵为: \[M_{translation} = \begin{bmatrix} 1 & 0 & 0 & T_x \\ 0 & 1 & 0 & T_y \\ 0 & 0 & 1 & T_z \\ 0 & 0 & 0 & 1 \end{bmatrix}\]   前面的缩放和旋转矩阵其实只需要用到 3×3 的矩阵,而之所以用 4×4 的表示也是因为平移矩阵,普通的 3 维坐标必须增加一维 \(w\) 构成齐次坐标才能进行平移操作,\(w\) 一般都是 1.0,而从齐次坐标\((x,y,z,w)\) 变为普通的 3 维坐标需要每个分量除以 \(w\),即 \((x/w, y/w, z/w)\) 。 则模型矩阵 \(M_{model} = M_{translation} \cdot M_{rotation} \cdot M_{scaling}\)。 视图矩阵   视图矩阵描述的是三维场景中模拟相机的状态,根据模拟相机的状态确定一套以相机为原点的相机坐标系,从而使用视图矩阵进行坐标变换,至于为啥是模拟相机,是因为 OpenGL 本身并没有相机的概念,通过模拟相机来实现在三维场景中的漫游。 camera_axes   模拟相机有三个关键点,分别为相机位置(cameraPos),相机朝向点(cameraTarget),相机上向量(top),根据相机位置和相机朝向点可确定相机坐标系的 z 轴正向向量 \(cameraDirection = (cameraPos - cameraTarget).normalize\),叉乘相机上向量和相机 z 轴正向向量可得到相机坐标系 x 轴正向向量 \(cameraRight = top.cross(cameraDirection).normalize\),最后将相机 z 轴正向向量与 x 轴正向向量叉乘得到 y 轴正向向量 \(cameraUp = cameraDirection.cross(cameraRight)\),如此即可建立完整的相机坐标系,从而得到变换矩阵,即视图矩阵: \[M_{view} = \begin{bmatrix} R_x & R_y & R_z & 0 \\ U_x & U_y & U_z & 0 \\ D_x & D_y & D_z & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} 1 & 0 & 0 & -P_x \\ 0 & 1 & 0 & -P_y \\ 0 & 0 & 1 & -P_z \\ 0 & 0 & 0 & 1 \end{bmatrix}\] 其中 \(R\) 是相机 x 轴正向向量,\(U\) 是相机 y 轴正向向量,\(D\) 是相机 z 轴正向向量, \(P\) 是相机位置向量。 投影矩阵   投影矩阵描述的是摄像机前的可视区域(Frustum),根据可视区域的形状可分为正射投影(Orthographic Projection)和透视投影(Perspective Projection)。   对于这两种投影,都有远(far)近(near)参数,不同的是,正射投影是个立方体,所以有左(left)右(right)上(top)下(bottom)四个参数,而透视投影是个类梯形台,所以还有垂直方向视野(Field of View,fov),以及一个宽高比(aspect)两个参数。远近两个参数决定摄像机能看到多近和多远的物体,太近和太远都会看不见,一般可设 near = 0.1,far = 1000;若渲染视窗(viewport)宽为 W,高为 H,则一般 \(left=-W/2, right=W/2, top=H/2, bottom=-H/2\) ;透视投影的 fov 是角度,一般设为 45.0,而 \(aspect = W/H\) 。这两种投影的矩阵分别为: \[M_{orth} = \begin{bmatrix} \frac{2}{right-left} & 0 & 0 & -\frac{right+left}{right-left} \\ 0 & \frac{2}{top-bottom} & 0 & -\frac{top+bottom}{top-bottom} \\ 0 & 0 & \frac{-2}{far-near} & -\frac{far+near}{far-near} \\ 0 & 0 & 0 & 1 \end{bmatrix} \\M_{pers} = \begin{bmatrix} \frac{2near}{right-left} & 0 & \frac{right+left}{right-left} & 0 \\ 0 & \frac{2near}{top-bottom} & \frac{top+bottom}{top-bottom} & 0 \\ 0 & 0 & \frac{-(far+near)}{far-near} & \frac{-2far*near}{far-near} \\ 0 & 0 & -1 & 0 \end{bmatrix}\]   在 three.js 中,对于透视投影矩阵中 left, right, top, bottom 计算方式为: 1 2 3 4 5 6 let top = near * Math.tan( _Math.DEG2RAD * 0.5 * this.fov ) / this.zoom; let height = 2 * top; let width = this.aspect * height; let left = - 0.5 * width; let right = left + width; let bottom = top - height;   对于透视投影,由于计算出的齐次坐标 w 分量显然不为 1.0,所以必须进行透视除法(x,y,z 各分量分别除以 w),得到真正的 3 维坐标。   正射投影一般用来模拟 2D 空间,透视投影用来模拟 3D 空间,当透视投影 near 和 far 设置的相差太大时,很容易引发 z-fighting 现象,原因是离近平面越远时,计算出的深度精度越低,three.js 中为解决这一问题,引入了一个 logarithmicDepthBuffer 参数来决定是否开启使用对数函数优化深度计算,具体可看源码中的 logdepthbuf_vertex.glsl.js 和 logdepthbuf_fragment.glsl.js 文件,开启该参数会造成渲染性能下降。 小结   \(M_{mvp} = M_{projection}M_{view}M_{model}\),一个局部坐标 \(V_{local}\) 在经过 MVP 矩阵变换之后可得到裁剪坐标 \(V_{clip} = M_{mvp}V_{local}\) ,在 OpenGL 中,\(V_{clip}\) 会被赋值到顶点着色器中的 gl_Position,并且 OpenGL 会自动进行透视除法和裁剪。   3 维中的相机一般可分为两种,第一人称相机(常规 FPS 游戏)和第三人称相机(常规 ARPG 游戏),第一人称相机的特点是灵活,相机往往可以任意改变位置和朝向,所以会对某些人造成一种 “晕 3D” 的现象,而第三人称相机虽然可以改变相机朝向点和位置,但当朝向点和到朝向点的距离一旦固定,则相机只能沿着以朝向点为球心,以到朝向点的距离为半径的球面上运动,这两种相机一般看具体业务需求进行选择。   缩放操作是很常规的一种操作,镜头拉近代表放大,拉远代表缩小。在使用透视投影的 3 维场景中,只需要改变相机到朝向点的距离即可简单实现缩放操作,而在使用正射投影的场景中,改变距离并不能实现缩放,而是需要改变 左右上下 四个参数,所以在相机中往往会在引入一个 zoom 的参数,用 左右上下 四个参数分别除以 zoom 得到真正的 左右上下,从而改变 zoom,就可以改变相机参数,进而实现正射投影的缩放。 管线篇 顶点着色器 图元装配 光栅器 顶点缓冲区 片元着色器 归属测试 模板测试 深度测试 融合 抖动 颜色缓冲区 纹理缓冲区 深度缓冲区 uniform数据 uniform数据   渲染管线,图形学中最重要的概念之一,既然称之为管线,自然有像流水线一样的步骤,各个步骤具体做的事情如下: 顶点着色器:负责将顶点数据进行坐标变换,该着色器中一般存在 MVP 矩阵,负责将三维坐标变换为二维坐标,该阶段也可以优化每个点的深度值,以便管线后续进行深度测试,也可以利用光照简单优化每个顶点的颜色; 图元装配:将输入的顶点数据进行组装,形成图元,常见的图元包括:点(GL_POINTS)、线(GL_LINES)、线条(GL_LINE_STRIP)、三角面(GL_TRIANGLES),在该过程中,一般 GPU 会做一些裁剪和背面剔除等操作,以减少图元的数量,同时完成透视除法以进行屏幕映射; 光栅化:负责计算每个图元到屏幕像素点的映射。光栅化会计算每个图元所覆盖的片元,同时利用顶点属性插值计算每个片元的属性,片元可认为是候选像素,经过后续管线阶段即可变为真正的像素。 片元着色器:将光栅化得到的片元进行颜色计算。图形学中几乎所有的高级特效都会在这一步完成,光照计算,阴影处理,纹理,材质,统统在这一步进行处理; 归属测试:即测试片元所在位置是否位于当前上下文视窗内,若一个显示帧缓冲区视窗被另一个视窗所遮蔽,则剔除该部分片元。 模板测试:即测试片元是否满足一定条件(可大于或小于某个值等),若测试不满足,则剔除该该片元, OpenGL 可自行选择开启或关闭模板测试。 深度测试:用来测试片元的远近,远的片元被遮挡。在深度测试,若两片元深度值接近,则可能会引起 Z-fighting 现象,即像素闪烁,这是因为此时 GPU 无法确定该剔除哪个片元,导致这一帧可能绘制这个片元,下一帧绘制另一个片元。若开启 Alpha 测试,即启用透明度,则会在下一阶段进行 Alpha 混合,从而达到透明效果。 混合:将新生成的片元颜色和帧缓冲区中对应位置的颜色进行混合,得到像素颜色。 抖动:一种以牺牲分辨率为代价来增加颜色表示范围技术,从视觉效果上来看就是颜色过度更平滑。   以上这些阶段中,能完全被编程控制的也就顶点着色器和片元着色器两个阶段,其余阶段要么完全无法控制,要么只能通过已有的参数进行设置,当然也可以通过顶点着色器和片元着色器影响余下阶段,顶点着色器和片元着色器也统称 Shader 编程。   有时候为了做更好看的特效,需要进行多次渲染,将上一次渲染的结果作为下一次渲染的输入,此时可以将颜色缓冲区作为一张纹理,并构造新的帧缓冲区,将该纹理作为输入,重新放进渲染管线中,这种操作方式也叫后期处理(Post Processing),虽然好看,但对 GPU 的负载很大,需要合理使用。   对于渲染管线,Shaun 的理解也就到此为止了,非常粗浅,Shader 也只是刚入门的水平,Shaun 在图形学方面做的更多是降低 Draw-Call 和 CPU 层面的 Tessellation,以及 Geometry 上的事,对纹理材质颜色光照阴影等方面涉及的较少。 后记   虽然目前 OpenGL 已停止更新,但学习图形学编程,OpenGL 总是绕不过去(至少暂时以及未来很长一段时间都会是这样),而且图形学基础知识本质都是相同的,不管是 DirectX 还是 Vulkan,变的只是写法形式而已,数学知识总是在那里,两种 shader 也同样需要,所以了解这些东西还是有必要的。 附录 二维图像的图像透视投影变换   图像的透视投影变换常用于图像的矫正,OpenCV 中就有现成的 api(getPerspectiveTransform 和 warpPerspective),用于将不规整的四边形区域变换为规整的矩形区域。其基本的数学原理为,先构造一个投影变换等式: \[\begin{bmatrix} XW \\ YW \\ W \end{bmatrix} = \begin{bmatrix} a & b & c \\ d & e & f \\ g & h & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix}\] 设四边形中四个点分别为 \((X_1, Y_1),(X_2, Y_2),(X_3, Y_3),(X_4, Y_4)\) ,对应矩形中四个点为 \((x_1, y_1),(x_2, y_2),(x_3, y_3),(x_4, y_4)\)。则可构造齐次线性方程组: \[\begin{bmatrix} x_1 & y_1 & 1 & 0 & 0 & 0 & -X_1x_1 & -X_1y_1 \\ 0 & 0 & 0 & x_1 & y_1 & 1 & -Y_1x_1 & -Y_1y_1 \\ x_2 & y_2 & 1 & 0 & 0 & 0 & -X_2x_2 & -X_2y_2 \\ 0 & 0 & 0 & x_2 & y_2 & 1 & -Y_2x_2 & -Y_2y_2 \\ \vdots & \vdots & \vdots & \vdots & \vdots & \vdots & \vdots & \vdots \\ x_n & y_n & 1 & 0 & 0 & 0 & -X_nx_n & -X_ny_n \\ 0 & 0 & 0 & x_n & y_n & 1 & -Y_nx_n & -Y_ny_n \end{bmatrix} \begin{bmatrix} a \\ b \\ c \\ d \\ e \\ f \\ g \\ h \end{bmatrix} = \begin{bmatrix} X_1 \\ Y_1 \\ X_2 \\ Y_2 \\ \vdots \\ X_n \\ Y_n \end{bmatrix}\] 解这个方程组得到 abcdefg ,使用上面的投影变换等式可计算 \(X = XW / W, Y = YW / W\) ,从而使用插值得到规整矩形图形的各个像素值。 Shader 学习资料 shader 入门书:https://thebookofshaders.com,在线编写 shader :https://thebookofshaders.com/edit.php glslsandbox 网站:http://glslsandbox.com/ shadertoy 网站:https://www.shadertoy.com/ threejs shader 系列教程:https://www.cnblogs.com/heymar/category/2432299.html 参考资料 [1] 坐标系统(https://learnopengl-cn.github.io) [2] WebGL图形系统、渲染管线_郭隆邦技术博客 [3] OpenGL Projection Matrix [4] WebGL着色器32位浮点数精度损失问题 [5] Transform quadrilateral into a rectangle?

2021/5/28
articleCard.readMore

Scala 学习小结

前言   最近要改行做大数据相关的东西了,经调研大数据开发的语言还是用 Scala 好,当然 Java 也可以,毕竟都运行在 JVM 上,不过 Java 也有很长时间没用过了,所以对于 Shaun 来说用 Scala 和 Java 的代价是一样的,都需要学习一下,所以决定用对大数据更友好的 Scala。   以 Martin Odersky 14 年写的「Scala By Example」为参考,虽然是 14 年的,但 Scala 的基本语法还是没变的,就学习本身而言没问题,毕竟不兼容的只是更上层的 API,Shaun 学习用的 Scala 版本为 2.12.12。Alvin Alexander 的「Scala Cookbook, 2nd Edition」预计今年 8 月会出版,到时可能这本书用来入门更好,但 Shaun 不需要系统的学,就简单的能上手写出比较理想的 Scala 代码就行了。 学习篇 第一章:入门基础 HelloWorld   由于「Scala By Example」第一章没啥内容,也为了在正式写 Scala 之前简单熟悉一下,这里先用「A Scala Tutorial for Java Programmers」简单上手一下,首先写个 HelloWorld,具体代码如下: 1 2 3 4 5 object HelloWorld { def main(args: Array[String]) { println("Hello, world!") } }   和 C 语言类似,程序唯一入口函数都是 main 函数,但 Scala 的变量在前,声明的类型在后,相比常规的语言是有点奇怪了,但这种语法规则和 Typescript 一样,所以很容易接受,但其模板的表示就有点奇怪了,Array[String] 表示一个 String 类型的数组,即表示方法为 Array[T],常规的模板方式为 Array<T> 或 T[],def 关键字用来定义一个函数,object 用来表示一个单例类,即在定义类的同时,又创建了一个类的实例。Scala 中没有 static 关键字,需要用 static 修饰的都放在 object 中即可。 调用 Java Scala 中默认已导入 java.lang 中的全部类,但其它类需要显式导入,以格式化输出本地日期为例: 1 2 3 4 5 6 7 8 9 10 import java.util.{Date, Locale} import java.text.DateFormat._ object LocalDate { def main(args: Array[String]) { val now = new Date val df = getDateInstance(LONG, Locale.CHINA) println(df format now) // df format(now) } }   Scala 中的导入和 java 中 import 基本一样,但功能更强大,可以使用 {} 导入部分,也使用 _ 导入全部(java 导入全部为 *,这不一样),当一个函数只有一个参数,可以通过 空格+参数 的形式调用,而不需要使用 括号包裹 的形式。这里采用 val 关键字声明的是常量,而要声明变量需要用 var。 对象 Scala 中万物皆对象,一个数字也是一个对象,一个函数也是一个对象,具体如下图: enter image description here 以简单计时器函数为例: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 object Timer { def oncePerSecond(callback: () => Unit) { while (true) { callback(); Thread sleep 1000; } } def timeFiles() { println("time files like an arrow..."); } def main(args: Array[String]) { // oncePerSecond(timeFiles); oncePerSecond(() => { println("time files like an arrow..."); }); } }   这个和 Typescript 函数式编程的用法基本差不多,唯一不同这里声明的函数返回的是 Unit ,这个 Unit 可认为是无返回的函数,大部分情况等同于 void,在 Scala 中真正的没有值指的是 Nothing。 类 Scala 中同样有类,具体代码示例如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Complex(real: Double, imaginary: Double) { // def re() = real; // def im() = imaginary; def re = real; def im = imaginary; override def toString(): String = "" + re + (if (im < 0) "" else "+") + im + "i"; } object ComplexNumbers { def main(args: Array[String]) { val c = new Complex(1.2, -3.4); // println("real part: " + c.re() + " imaginary part: " + c.im()); println(c.toString()); } }   在 Scala 中所有类都会继承某个父类,若没有显式声明父类,则默认继承 scala.AnyRef 类,如上面的 Complex 类,若需要覆盖父类的函数,则需要在函数声明前加上 override 关键字。当函数没有参数时,可以不用加括号,在调用时也不用加括号,如上面示例的注释和非注释的代码。 模式匹配与条件类   接下来用 Scala 来写一个树结构表示表达式的示例代码,树的非叶节点表示操作符,叶子节点表示数值(这里为常量或变量),具体代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 abstract class Tree case class Sum(l: Tree, r: Tree) extends Tree case class Var(n: String) extends Tree case class Const(v: Int) extends Tree object Expression { type Environment = String => Int def eval(t: Tree, env: Environment): Int = t match { case Sum(l, r) => eval(l, env) + eval(r, env) case Var(n) => env(n) case Const(v) => v } def derive(t: Tree, v: String): Tree = t match { case Sum(l, r) => Sum(derive(l, v), derive(r, v)) case Var(n) if (v == n) => Const(1) case _ => Const(0) } def main(args: Array[String]) { val exp: Tree = Sum(Sum(Var("x"), Var("x")), Sum(Const(7), Var("y"))) val env: Environment = {case "x" => 5 case "y" => 7} println("Expression: " + exp) println("Evalution with x=5, y=7: " + eval(exp, env)) println("Derivative relative to x:\n" + derive(exp, "x")) println("Derivative relative to y:\n" + derive(exp, "y")) } }   该示例主要用来说明两种 case 关键字,分别为:case class 和 ... match case ...,前者可认为是一个结构体,实例化时可以省略 new 关键字,参数有默认的 getter 函数,整个 case class 有默认的 equals 和 hashCode 方法实现,通过这两个方式可实现根据值判断类的两个实例是否相等,而不是通过引用,条件类同样有默认的 toString 方法实现;后者可认为是一种特殊的 switch case ,只不过 case 的判定和执行是函数式的,case class 可直接参与 match case 的判定(判定是不是属于该类)。第 7 行中有个 type 关键字,可认为是定义了一种新的类型(不是数据类型),示例中是函数类型,通过这个 type ,可直接将字符串映射为整型,23 行中将这个 type 与 case 结合使用,定义多个字符串映射多个整型的变量。第 18 行中有个 _ ,这是 scala 中的通配符,不同的语义下表示的含义不同,这里的含义是指,当上面的模式都不匹配时,将执行这个,相当于 switch case 中的 default。 Scala 中的 trait   简单理解就是 Java 中的 Interface(接口),Scala 中没有 interface 关键字,但是 trait 比 Interface 的功能更多,其中可直接定义属性和方法的实现,Scala 中可通过 trait 来实现多重继承。下面的示例用 trait 简单实现了一个比较接口: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 trait Ord { def <(that: Any): Boolean def <=(that: Any): Boolean = (this < that) || (this == that) def >(that: Any): Boolean = !(this <= that) def >=(that: Any): Boolean = !(this < that) } class Date(y: Int, m: Int, d: Int) extends Ord { def year = y def month = m def day = d override def toString(): String = year + "-" + month + "-" + day override def equals(that: Any): Boolean = { that.isInstanceOf[Date] && { val o = that.asInstanceOf[Date] o.day == day && o.month == month && o.year == year } } def <(that: Any): Boolean = { if (!that.isInstanceOf[Date]) { sys.error("cannot compare " + that + " and a Date") } val o = that.asInstanceOf[Date] (year < o.year) || (year == o.year && (month < o.month || (month == o.month && day < o.day))) } } object Comparable { def main(args: Array[String]) { val d1 = new Date(2021, 1, 3); val d2 = new Date(2021, 1, 3); println(d1 < d2) println(d1 <= d2) } }   比较关系一般只需要确定 小于 和 等于 关系即可,其它关系都可由这两关系推出来,由于等于方法默认存在于所有对象中,所以只需要重写小于即可, 其它的比较方法都可以在 trait 中定义好。在上面的示例中有两个函数 isInstanceOf 和 asInstanceOf,前者用来判断对象是否是指定类型,后者用来将对象转换为指定类型,一般用在将父类转为子类时,在使用 asInstanceOf 之前一般需要先使用 isInstanceOf。 泛型   这东西没啥好说的,基本有编程经验的或见过或用过,只是 Scala 的泛型语法确实有点奇怪就是了,可能也是为了函数式那些乱七八糟的操作符,具体示例代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class Reference[T] { private var contents: T = _ def set(value: T) { contents = value } def get: T = contents } object IntegerReference { def main(args: Array[String]) { val cell = new Reference[Int] cell.set(13) println("Reference contains the half of " + (cell.get * 2)) } }   这里同样有个 _,这里表示的是默认值,对于数字类型来说是 0,对于 boolean 来说是 false,对于 Unit(函数签名)来说是()(无参数无返回),对于其他来说是 null。 简单的了解 Scala 就到这里了。 第二章:快排 开场就是一个快排,示例代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 object QuickSort { def qSort(xs: Array[Int]) { def swap(i: Int, j: Int) { val t = xs(i); xs(i) = xs(j); xs(j) = t; } def sort(l: Int, r: Int) { val pivot = xs(l); var i = l+1; var j = r; while (i < j) { while (i <= r && xs(i) < pivot) i += 1; while (j > l && xs(j) > pivot) j -= 1; if (i < j) { swap(i, j); i += 1; j -= 1; } if (i > j) { i = j; } } while (i > l && xs(i) > pivot) { i -= 1; j -= 1; } swap(i, l); if (l < j-1) sort(l, j-1); if (j+1 < r) sort(j+1, r); } sort(0, xs.length-1); } def main(args: Array[String]) { // val xs = Array(4, 1, 2, 5, 6); // val xs = Array(1, 2, 4, 4, 55, 5, 6); // val xs = Array(55, 6, 6); val xs = Array(4, 1, 5, 7,7,7,7, 2, 6); qSort(xs); println(xs.mkString(" ")) } }   从这段快排代码可看出,Scala 支持函数嵌套和闭包,即在函数内部定义子函数,子函数可直接使用父函数的变量,同时,这里也简单说明一下 Scala 中数组的一些使用方法,用下标取数组元素时使用的是小括号 (),而不是其它语言常见的中括号 []。当然 Scala 作为一种函数式语言,提供了非常多的函数式操作符,这篇也只会简单介绍。 第三章:Actor   Actor,Scala 中的多线程编程模型,下方的示例代码在 Scala 2.11 及之后的版本无法运行,因为 Actor 已从 Scala 库独立出来,见 object-actors-is-not-a-member-of-package-scala。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 import scala.actors.Actor abstract class AuctionMessage case class Offer(bin: Int, client: Actor) extends AuctionMessage case class Inquire(client: Actor) extends AuctionMessage abstract class AuctionReply case class Status(asked: Int, expire: Date) extends AuctionReply case object BestOffer extends AuctionReply case class BeatenOffer(maxBid: Int) extends AuctionReply case class AuctionConCluded(seller: Actor, client: Actor) extends AuctionReply case object AuctionFailed extends AuctionReply case object AuctionOver extends AuctionReply class Auction(seller: Actor, minBid: Int, closing: Date) extends Actor { val timeToShutdown = 36000000 // msec val bidIncrement = 10 def act() { var maxBid = minBid - bidIncrement var maxBidder: Actor = null var running = true while (running) { receiveWithin ((closing.getTime() - new Date().getTime())) { case Offer(bid, client) => { if (bid >= maxBid + bidIncrement) { if (maxBid >= minBid) maxBidder ! BeatenOffer(bid) maxBid = bid; maxBidder = client; client ! BestOffer } else { client ! BeatenOffer(maxBid) } } case Inquire(client) => { client ! BeatenOffer(maxBid) } case TIMEOUT => { if (maxBid >= minBid) { val reply = AuctionConCluded(seller, maxBidder) maxBidder ! reply; seller ! reply } else { seller ! AuctionFailed } receiveWithin(timeToShutdown) { case Offer(_, client) => client ! AuctionOver case TIMEOUT => running = false } } } } } } class HelloActor extends Actor { def act() { while (true) { receive { case name: String => println("Hello, " + name) } } } } object AuctionService { def main(args: Array[String]) { val seller: Actor = new HelloActor val client: Actor = new HelloActor val minBid = 10 val closing = new Date() val helloActor = new HelloActor helloActor.start() helloActor ! "leo" } }   通过重写 Actor 中的 act 方法即可简单的实现多线程编程,Actor 中有个特殊的标识符 !,该符号其实是是一种缩写,即可将 helloActor.!("leo") 缩写为 helloActor ! "leo",代表将数据传递给 Actor,由 Actor 内部的 receive case 接受数据并处理,当然也可通过 receiveWithin 控制数据传递时间,若超时,则默认触发 TIMEOUT 处理模式。 第四章:表达式与简单函数 该章主要有两个例子:1、牛顿法求平方根;2、尾递归,具体如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 object Sqrt { def sqrt(x: Double): Double = { def sqrtIter(guess: Double, x: Double): Double = { if (isGoodEnough(guess, x)) guess else sqrtIter(improve(guess, x), x) } def improve(guess: Double, x: Double) = { (guess + x / guess) / 2 } def isGoodEnough(guess: Double, x: Double) = (guess * guess - x).abs < 0.001 // guess * guess == x sqrtIter(1.0, x) } } object TailRecursion { def gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b) def facorial(n: Int): Int = if (n == 0) 1 else n * facorial(n-1) def facorialTail(n: Int): Int = { def facorialIter(n: Int, res: Int): Int = { if (n == 0) res else facorialIter(n-1, res * n) } facorialIter(n, 1) } } object SimpleFunc { def main(args: Array[String]) { val sqrtValue = Sqrt.sqrt(0.01) println(sqrtValue) val gcdValue = TailRecursion.gcd(14,21) println(gcdValue) val facorialValue = TailRecursion.facorial(5) println(facorialValue) val facorialTailValue = TailRecursion.facorialTail(5) println(facorialTailValue) } }   由于并没有引入新的语法,就简单聊聊这两个例子吧。牛顿法求平方根主要在于构造一个特殊的二分函数 \(y_{i+1} = (y_i + x / y_i)/2, i=0,1,2,3,..., y_0=1\) ,如此迭代,直到 \(|y_i^2-x| < \epsilon\) ,得到 \(y_i\) 即为 x 的平方根,更朴素一点的求多次方根就是利用二分法,分 [0, 1] 和 [1, +∞] 两个区间即可,对应从 [x, 1] 和 [1, x] 开始二分取值。至于尾递归,以前简单的写过一点,即最后递归调用原函数时,原函数不会再参与任何计算表达式。尾递归的好处在于当编译器或解释器支持尾递归时,将不会产生普通递归时的压栈操作,即不用担心递归层次太深,尾递归将类似循环迭代处理。 第五章:高阶函数   高阶函数(First-Class Functions),支持以函数作为参数或返回值,也可将函数赋值给其它变量,由此也可引出闭包和柯里化,闭包是指将内嵌函数作为返回值,而柯里化是指将多个参数分解为独立参数传递给函数,如:\(f(args_1,args_2,...,args_n)=f(args_1)(args_2)(...)(args_n)\)。下面以求函数的不动点为例: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 object FirstClassFunctions { val tolerance = 0.0001 def isCloseEnough(x: Double, y: Double) = ((x-y) / x).abs < tolerance def fixedPoint(f: Double => Double)(firstGuess: Double) = { def iterate(guess: Double): Double = { val next = f(guess) if (isCloseEnough(guess, next)) next else iterate(next) } iterate(firstGuess) } def averageDamp(f: Double => Double)(x: Double) = (x + f(x)) / 2 def sqrt(x: Double) = fixedPoint(averageDamp(y => x/y))(1.0) def main(args: Array[String]) { println(sqrt(0.01)); } }   该示例简单明了的展示了 Scala 中匿名函数,函数柯里化以及闭包。 第六章:类和对象 直接看下面的有理数示例吧, 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 // 主构造函数 class Rational(n: Int, d: Int) extends AnyRef { private def gcd(x: Int, y: Int): Int = { if (x == 0) y else if (x < 0) gcd(-x, y) else if (y < 0) -gcd(x, -y) else gcd(y % x, x) } private val g = gcd(n, d) // 构造函数重载(辅助构造函数) def this() { this(0, 0) // 调用主构造函数 } val number: Int = if (g != 0) n / g else 0 val denom: Int = if (g != 0) d / g else 0 def +(that: Rational) = new Rational(number * that.denom + that.number * denom, denom * that.denom) def -(that: Rational) = new Rational(number * that.denom - that.number * denom, denom * that.denom) def *(that: Rational) = new Rational(number * that.number, denom * that.denom) def /(that: Rational) = new Rational(number * that.denom, denom * that.number) def toNumber: Double = if (denom != 0) number.toDouble / denom else 0.0 override def toString = "" + number + "/" + denom } object Rational { def main(args: Array[String]) { val rational = new Rational(2,1) / new Rational() println(rational.toNumber); println(rational.toString); } }   从有理数这个示例可以看出,Scala 的类支持操作符重载,也支持构造函数重载,同样支持继承,多继承也是支持的,每个父类用 with 关键字分隔就行。 第七章:条件类和模式匹配 大致和第一章内容差不多,就不重复写了。 第八章:泛型   大致也和第一章内容差不多,值得一提的书中实现的泛型栈本质是一个链表,实现方法挺有意思的。通过 <: 标识符可约束泛型的类型,如 [T <: P[T]] 表明泛型 T 必须类型 P 的子类型。而标识符 <% 比 <: 约束性弱一点,只要 T 能够通过隐式类型变换为 P 即可。若想约束为父类型,则需使用 >: 标识符。   Scala 中有一种特殊的泛型,就是变化型注解,trait List[+T] 代表协变,表示当 B 类型是 A 类型子类时,List[B] 也可认为是 List[A] 的子类;trait List[-T] 代表逆变,当 B 类型是 A 类型子类时,List[B] 可认为是 List[A] 的父类。   Scala 中同样有元组,使用时也很方便,简单使用直接用括号声明即可,如 def divmod(x: Int, y: Int): (Int, Int) = (x / y, x % y),该函数即返回一个元组,也可声明一个元组 case class Tuple2[A, B](_1: A, _2: B),若需要取元组的元素可通过 _i 的方式,如 val xy = divmod(3, 4); xy._1; xy._2;,也可通过 match-case 语句取,如 xy match { case (n, d) => println("quotient: " + n + ", rest: " + d) }。 第九章:List   Scala 中的 List 其实是数组结构,并且是不可变的,可认为是 C++ 里的静态数组,不能往其中添加或删除元素,下面用数组排序示例下 List 的用法: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 object Sort { def insertSort(xsl: List[Int]): List[Int] = { def insert(x: Int, xs: List[Int]): List[Int] = { xs match { // case Nil => List(x) case List() => List(x) case y :: ys => if (x <= y) x :: xs else y :: insert(x, ys) } } if (xsl.isEmpty) Nil else insert(xsl.head, insertSort(xsl.tail)) } def mergeSort[A](less: (A, A) => Boolean)(xs: List[A]): List[A] = { def merge(xs1: List[A], xs2: List[A]): List[A] = { if (xs1.isEmpty) xs2 else if (xs2.isEmpty) xs1 else if (less(xs1.head, xs2.head)) xs1.head :: merge(xs1.tail, xs2) else xs2.head :: merge(xs1, xs2.tail) } val n = xs.length / 2 if (n == 0) xs else merge(mergeSort(less)(xs take n), mergeSort(less)(xs drop n)) } def main(args: Array[String]) { val xs = List(4, 1, 5, 7,7,7,7, 2, 6); // val xs = 3::2::1::1::Nil; println(xs(0), xs(1), xs(xs.length-1)) // (4,1,6) // val ys = insertSort(xs); val ys = mergeSort((x: Int, y: Int) => x > y)(xs); println(ys.mkString(" ")) } }   List 中有两个操作符非常类似,即 :: 和 :::, 前者用于 List 中的元素和 List 连接,即创建一个新 List,新 List 为原 List 头插入元素后的 List,后者用于连接两个 List,即创建一个新 List ,新 List 为将第二个 List 的元素全部放入第一个 List 尾部的 List。字符 Nil 代表空 List 和 List() 等效,head 方法返回 List 的第一个元素,tail 方法返回除第一个元素之外的其它所有元素,还是一个 List,isEmpty 方法当 List 为空时返回 true。List 的 case-match 方法中,case y :: ys 其中 y 代表 xs.head,ys 代表 xs.tail。(xs take n) 表示取 List 前 n 个元素,(xs drop n) 表示取 List 前 n 个元素之外的元素,即与 (xs take n) 取得元素正好互补,而 (xs split n) 返回一个元组,元组中第一个元素为 (xs take n),第二个元素为 (xs drop n)。关于 List 还有些更高阶得方法:filter,map, flatMap, reduceRight, foldRight 等方法就不继续写了。至于动态 List 可用 ListBuffer 结构,当然 Scala 中直接用 Seq 作为返回值和参数一般会更好些。 第十章:序列理解   Scala 中用来做序列理解的表达式是 For-Comprehensions,具体示例如下:for (p <persons if p.age > 20) yield p.name 相当于 persons filter (p => p.age > 20) map (p => p.name),可以简单认为 for-yield 方法是 filter 和 map 的集合体。下面具体用个 N-皇后(特例是 8 皇后)的示例来具体说明: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 object NQueen { def queens(n: Int): List[List[Int]] = { def isSafe(col: Int, queenList: List[Int], delta: Int): Boolean = { val curRow = queenList.length-1 + delta for (row <- List.range(0, queenList.length)) { val queenCol = queenList(row) val queenRow = queenList.length-1 - row if (queenCol == col) return false if (queenRow == curRow) return false if ((queenCol - col).abs == (queenRow - curRow).abs) return false } true } def placeQueens(k: Int): List[List[Int]] = { if (k == 0) List(List()) else for { queens <- placeQueens(k-1); column <- List.range(0, n); if isSafe(column, queens, 1) } yield column :: queens } placeQueens(n) } def main(args: Array[String]) { val queenList = queens(8); println("queenCount: " + queenList.length) // 92 } } for-yield 表达式中 for 中可以写多条语句,代表多重循环,第 5 行的 for 代表 for 循环,<- 表示取 List 中的元素。   剩下的几章就没啥特别要写的,重点就两个特性,一个是 Stream ,一个 Lazy,Stream 和 List 有点类似,主要区别在于 Stream 是即时返回的,算一个返回一个,而 List 一般是全部计算完再返回一个 List;Lazy 一般用作常量的修饰符,主要作用是只用该常量被用到时才赋值,否则一直为空,有点类似常见的先判空再取值的封装。 后记   曾看到过通过刷题去学习新语言的方式,一直都以为很粗暴,但这次照着「Scala By Example」敲下来,感觉还挺有效的,同时也巩固了一下基本的算法知识,后续再把 twitter 的 「Effective Scala」再看一下应该就差不多了。

2021/2/17
articleCard.readMore

2020年小结

社畜不易,行者多难,披荆斩棘,前路莫测,步履不停。 前言   20 年,也算是正式步入社畜生活的第一年,新鲜感自然少不了,但也没持续很长时间。这一年中基本都在学习,工作中生活中都在熟悉新事物新模式。 技术篇   开始独立负责项目,从无到有写完了一个产品,做了半个地图可视化项目,图形学相关知识从完全不会到熟练使用 Shader 做简单特效,学习新语言使用新工具,这就是 Shaun 过去一年在工作中的写照。   在产品中,Shaun 基本独立完成了调研设计编码的全过程,这款网页版的 OpenDrive 路网编辑器,让 Shaun 基本熟悉的前端开发的主流框架和打包流程,甚至基于这款编辑器继续引申出两个 SDK,虽然开发模式和真正的前端有所区别(Shaun 是把 Typescript 当 C# 用的,将网页程序当客户端程序开发),但感觉现在的浏览器完全能撑的住,完全可以将更多的计算和存储任务直接在前端全部做完,但同时也感到了纯前端的无力,没有后端,前端网页能呈现的数据和效果确实有限,网页的内存有限制,webgl 渲染的三角形也有限制,只能做些小东西,大场景就很难施展。路网编辑器中涉及的前端技术栈也有很多,主要是现在无论开发一个什么应用,都不可能从语言最底层的 api 写起,总会用到别人写好的库,熟悉,吸收,再修改,用着用着就需要自己写了,从用轮子到造轮子,从而产生更多的轮子,也算是一种良性循环。   半个地图可视化项目,主要用的 mapbox-gl + geoserver 显示地图,做完这个项目,同时也基本了解了国内的百度和高德两家的地图突然变好看了的原因,其背后的技术也同样源自于 mapbox,一家真正小而美的公司,定义了一套前端渲染地图的数据标准(Vector Tile),在非 3D 地图上,这套标准就是业内通用的标准了,如今的导航地图用的都是这套前端渲染技术,美观又高效。 生活篇   整个 20 年出去玩的时间也不多,工作地所在能玩的地方基本也玩的差不多了,大部分时间都是宅在屋里看电影,学技术,感觉就非常平淡,也没啥特别好说的。20年,开始学习理财,锻炼买入卖出的感觉,由于整个 20 年股市一片良好,以至于 Shaun 这个新手也赚了些钱,但由于本钱不多,赚的也非常有限,赚大钱的机会,要么拿不住,要么下不去手,最终都失之交臂,这样一来,赚的就更少了,不过,股市中赚到的钱终究只是个数字,到手的才是赚到的,没到手是赚是亏还不好说,作为新手而言,Shaun 也就当玩玩而已,亏也不多,主要是锻炼自己的感觉或承受能力,反正理财是一辈子的事,不急于这一时。 总结   生活一年如一日的平淡如水,依旧独自前行,由于疫情的原因,出去看看都嫌太麻烦,只能周边走走,着实无聊,好在工作上的东西对 Shaun 来说是新的知识,稍微有点挑战,每解决一个问题,总会带来一些成就感,冲淡些许无聊,可这成就感越来越少了,或许哪天成就感完全消失,就是 Shaun 换个新环境的时候。 20 年获得技能:触类旁通 20 年获得成就:独挡一面

2021/2/12
articleCard.readMore

Linux服务器运维文档

前言   记录一下服务器问题排查常用的一些命令。 常用篇 Linux 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 # 只列出含 XXX 的文件 ll | grep "XXX" # 按列显示文件名 ls -1 ls -l | grep ^[^d] | awk '{print $9}' # 返回进入当前目录之前的目录 cd - # 在文件中查找带 XXX 的行,并输出到 /tmp/99 fgrep "XXX" a.txt > /tmp/99 # 在当前文件夹中查找带 XXX 的行,并输出到 /tmp/99 fgrep "XXX" -r ./* > /tmp/99 # 显示前5行 head -n 5 a.txt # 显示倒数第5行 tail -n 5 a.txt # 显示第5行至末尾 tail -n +5 a.txt # 提取第二行 [linux系统中sed命令输出指定的行](https://www.cnblogs.com/superbaby11/p/16556602.html) sed -n '2p' a.txt # 以;分隔每一行,并提取第一列和第三列 awk -F ';' '{print $1,$3}' a.txt # 以:分隔每一行,并提取第一列和第三列 awk -F '[:]' '{print $1,$3}' a.txt # 查看 8080 端口占用 lsof -i:8080 netstat -tnlp | grep :8080 # 查看系统运行状态 top # 查看一定时间内进程cpu占用情况 pidstat # 查看运行进程 ps -ef # 查看postgres数据库连接状态,并按cpu使用率排序 ps -aux | grep postgres | sort -nrk 3,3 # 查看磁盘占用大小 du -sh * # 查看磁盘剩余空间 df -h # 查看程序被 killed 的原因 dmesg -T | egrep -i -B100 'killed process' # dmesg 的时间可能不对,可以结合 /var/log/messages 一起看,[dmesg 时间误差现象](https://www.cnblogs.com/edisonfish/p/17283958.html) cat /var/log/messages | grep "Killed process" # 查看 url 请求时间 curl -o /dev/null -s -w %{time_namelookup}:%{time_connect}:%{time_starttransfer}:%{time_total} [url] # 查看硬盘序列号 sudo lshw -class disk | grep serial 正则表达式 常用正则:i Hate Regex 1 2 3 4 5 6 7 8 9 10 11 // 匹配 hello 之前的字符 (.+(?=hello)) // 匹配其他数字和英文字母但不匹配结尾的 2 ([a-zA-Z_0-9]+[^2]) // 提取包含test以及time后的数字 test[a-zA-Z0-9\-\_\=\|\ ]*time=([\d+]) // 提取中括号里的内容 [\[](.*?)[\]] 工具 crontab:设置定时任务工具; Socat:网络工具(透明代理,端口转发,文件传输等),新版瑞士军刀:socat 服务器之间文件传输 参考资料:Linux下的SCP指令详解 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 本地主机传输文件到远程主机 scp [本地文件路径] [用户名]@[远程主机IP地址]:[目标路径] # eg: scp file.txt user@example.com:/home/user/ # 远程主机传输文件到本地主机 scp [用户名]@[远程主机IP地址]:[远程文件路径] [本地目标路径] # eg: scp user@example.com:/home/user/file.txt /path/to/local/ # 传输本地主机整个目录到远程主机 scp -r [本地目录路径] [用户名]@[远程主机IP地址]:[目标路径] # eg: scp -r directory/ user@example.com:/home/user/ # 若远程主机的SSH服务器端口不是默认的22端口,则需要指定端口号 scp -P [端口号] [本地文件路径] [用户名]@[远程主机IP地址]:[目标路径] PostgreSQL 编译安装 参考自:【CentOS7】PostgreSQL-10.3的安装 安装编译工具: 1 yum install -y vim lrzsz tree wget gcc gcc-c++ readline-devel zlib-devel 进入/usr/local/目录下:cd /usr/local 下载 tar 包:curl -O https://ftp.postgresql.org/pub/source/v16.2/postgresql-16.2.tar.gz 解压:tar -xzvf postgresql-16.2.tar.gz 编译安装: 1 2 3 4 5 cd /usr/local/postgresql-16.2 ./configure --prefix=/usr/local/pgsql-16.2 # /usr/local/pgsql-16.2 为安装目录 make && make install # Two thousand years later,出现「PostgreSQL installation complete.」代表安装成功 配置系统环境变量:vi /etc/profile 1 2 3 4 5 6 ... # /etc/profile 文件末尾添加 export PGHOME=/usr/local/pgsql-16.2 export PGDATA=$PGHOME/data export LD_LIBRARY_PATH=$PGHOME/lib:$LD_LIBRARY_PATH export PATH=$PGHOME/bin:$PATH 使配置文件立即生效:source /etc/profile 创建数据库用户:useradd -m -d /home/postgres postgres 切换到数据库用户:su postgres 初始化数据库:pg_ctl init -D /home/postgres/db_data 启动数据库:pg_ctl start -D /home/postgres/db_data 自启动设置 复制 PostgreSQL 自启动文件:cp /usr/local/postgresql-16.2/contrib/start-scripts/linux /etc/init.d/postgresql 修改自启动文件:vi /etc/init.d/postgresql, 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 #! /bin/sh # chkconfig: 2345 98 02 # description: PostgreSQL RDBMS # This is an example of a start/stop script for SysV-style init, such # as is used on Linux systems. You should edit some of the variables # and maybe the 'echo' commands. # # Place this file at /etc/init.d/postgresql (or # /etc/rc.d/init.d/postgresql) and make symlinks to # /etc/rc.d/rc0.d/K02postgresql # /etc/rc.d/rc1.d/K02postgresql # /etc/rc.d/rc2.d/K02postgresql # /etc/rc.d/rc3.d/S98postgresql # /etc/rc.d/rc4.d/S98postgresql # /etc/rc.d/rc5.d/S98postgresql # Or, if you have chkconfig, simply: # chkconfig --add postgresql # # Proper init scripts on Linux systems normally require setting lock # and pid files under /var/run as well as reacting to network # settings, so you should treat this with care. # Original author: Ryan Kirkpatrick <pgsql@rkirkpat.net> # contrib/start-scripts/linux ## EDIT FROM HERE ###### 上面不改 ##################### # Installation prefix prefix=/usr/local/pgsql-16.2 # Data directory PGDATA="/home/postgres/db_data" ###### 下面不改 ##################### # Who to run postgres as, usually "postgres". (NOT "root") PGUSER=postgres # Where to keep a log file PGLOG="$PGDATA/serverlog" # It's often a good idea to protect the postmaster from being killed by the # OOM killer (which will tend to preferentially kill the postmaster because # of the way it accounts for shared memory). To do that, uncomment these # three lines: #PG_OOM_ADJUST_FILE=/proc/self/oom_score_adj #PG_MASTER_OOM_SCORE_ADJ=-1000 #PG_CHILD_OOM_SCORE_ADJ=0 # Older Linux kernels may not have /proc/self/oom_score_adj, but instead # /proc/self/oom_adj, which works similarly except for having a different # range of scores. For such a system, uncomment these three lines instead: #PG_OOM_ADJUST_FILE=/proc/self/oom_adj #PG_MASTER_OOM_SCORE_ADJ=-17 #PG_CHILD_OOM_SCORE_ADJ=0 ## STOP EDITING HERE # The path that is to be used for the script PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin # What to use to start up postgres. (If you want the script to wait # until the server has started, you could use "pg_ctl start" here.) DAEMON="$prefix/bin/postgres" # What to use to shut down postgres PGCTL="$prefix/bin/pg_ctl" set -e # Only start if we can find postgres. test -x $DAEMON || { echo "$DAEMON not found" if [ "$1" = "stop" ] then exit 0 else exit 5 fi } # If we want to tell child processes to adjust their OOM scores, set up the # necessary environment variables. Can't just export them through the "su". if [ -e "$PG_OOM_ADJUST_FILE" -a -n "$PG_CHILD_OOM_SCORE_ADJ" ] then DAEMON_ENV="PG_OOM_ADJUST_FILE=$PG_OOM_ADJUST_FILE PG_OOM_ADJUST_VALUE=$PG_CHILD_OOM_SCORE_ADJ" fi # Parse command line parameters. case $1 in start) echo -n "Starting PostgreSQL: " test -e "$PG_OOM_ADJUST_FILE" && echo "$PG_MASTER_OOM_SCORE_ADJ" > "$PG_OOM_ADJUST_FILE" su - $PGUSER -c "$DAEMON_ENV $DAEMON -D '$PGDATA' >>$PGLOG 2>&1 &" echo "ok" ;; stop) echo -n "Stopping PostgreSQL: " su - $PGUSER -c "$PGCTL stop -D '$PGDATA' -s" echo "ok" ;; restart) echo -n "Restarting PostgreSQL: " su - $PGUSER -c "$PGCTL stop -D '$PGDATA' -s" test -e "$PG_OOM_ADJUST_FILE" && echo "$PG_MASTER_OOM_SCORE_ADJ" > "$PG_OOM_ADJUST_FILE" su - $PGUSER -c "$DAEMON_ENV $DAEMON -D '$PGDATA' >>$PGLOG 2>&1 &" echo "ok" ;; reload) echo -n "Reload PostgreSQL: " su - $PGUSER -c "$PGCTL reload -D '$PGDATA' -s" echo "ok" ;; status) su - $PGUSER -c "$PGCTL status -D '$PGDATA'" ;; *) # Print help echo "Usage: $0 {start|stop|restart|reload|status}" 1>&2 exit 1 ;; esac exit 0 接下来有两种方式: 一种是直接执行:cd /etc/rc.d/init.d/ && chkconfig --add postgresql; 一种是修改 /etc/rc.d/rc.local 文件:vi /etc/rc.d/rc.local, 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #!/bin/bash # THIS FILE IS ADDED FOR COMPATIBILITY PURPOSES # # It is highly advisable to create own systemd services or udev rules # to run scripts during boot instead of using this file. # # In contrast to previous versions due to parallel execution during boot # this script will NOT be run after all other services. # # Please note that you must run 'chmod +x /etc/rc.d/rc.local' to ensure # that this script will be executed during boot. exec 2> /tmp/rc.local.log # send stderr from rc.local to a log file exec 1>&2 # send stdout to the same log file echo "rc.local starting..." # show start of execution set -x touch /var/lock/subsys/local cd /etc/rc.d/init.d/ sudo sh postgresql start & # 以root执行,不然可能会出现权限错误,&表示后台执行 # 脚本执行完后也给个日志 echo "rc.local completed" 添加可执行权限:chmod a+x /etc/rc.d/rc.local,最后查看一下 rc.local 服务是否启动: 1 2 3 4 5 6 7 8 systemctl status rc-local.serives # 启动命令 systemctl enable rc-local.service systemctl start rc-local.service # 查看数据库服务 ps -ef | grep postgres 若要在容器中设置自启动,在没给容器提权的情况下,则需要第三种方式:将 /etc/rc.d/init.d/postgresql 放进 /root/.bashrc 中启动,vi /root/.bashrc, 1 2 3 4 5 ... # /root/.bashrc 文件末尾添加 if [ -f /etc/rc.d/init.d/postgresql ]; then sh /etc/rc.d/init.d/postgresql start > /tmp/postgresql.start.log 2>&1 fi 原理是:docker 容器在启动时,会自动执行 ~/.bashrc 文件,加载环境变量,当有其他命令在该文件时,也会一起执行。 当然,容器中自启动更普遍的方式应该是在镜像/容器中通过 CMD 或者 ENTRYPOINT 直接指定 shell 脚本启动执行。 配置文件设置 PG 电子书:PostgreSQL 14 Internals 配置参数解析文档:PostgresqlCO.NF: 人类的PostgreSQL配置 自动化参数调优:PGTune PG13 一个推荐的配置解析(SSD,48 核,128GB 内存,机器资源独占,混布相当于降低内存和 cpu) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 # 允许任何机器连接。默认只允许本地连接 listen_addresses = '*' # 数据库连接端口。默认为5432 port = 5432 # 最大允许512个连接。默认为100 max_connections = 512 # 锁超时20s。默认为0,不超时 lock_timeout = 20000 # sql超时60s。默认为0,不超时 statement_timeout = 60000 # 数据库用于缓存数据的使用内存大小,一般设置为系统内存的 25%~30%,不宜过大,最多不超过40%。默认为128MB shared_buffers = 64GB # 查询优化器可用的内存大小,只是预估,不实际使用,值越大,越倾向于索引扫描,一般设置为系统内存的30%~50%,最大不超过90%。默认为4GB effective_cache_size = 96GB # 数据库维护性操作使用的内存(eg:vacuum, create index等),若需加快维护速度,可临时增大该参数 set maintenance_work_mem = 2GB;。默认为64MB maintenance_work_mem = 64MB # 尚未写入磁盘的WAL数据的共享内存量,增大该值有利于提高写入性能,不建议太大,最多不超过 128MB。默认与shared_buffers一致 wal_buffers = 16MB # 查询优化器中统计信息的详细程度,越大越详细,查询优化器的决策越好,但会增加 ANALYZE 耗时。默认为100 default_statistics_target = 100 # 查询优化器获取一个随机页的cost(相比于一个顺序扫描页(seq_page_cost=1)的cost为1),该值相对seq_page_cost越小,越倾向于索引扫描,但不可低于 seq_page_cost。默认为4 # 默认值可以被想成把随机访问建模为比顺序访问慢 40 倍,而期望 90% 的随机读取会被内存缓存。 random_page_cost = 1.1 # 顺序扫描时并行 I/O 操作的最大数量。默认为1 effective_io_concurrency = 8 # 每个排序操作、哈希表等操作所能使用的内存大小,增大该值可以提高某些查询性能,但设置过高可能会导致内存耗尽。默认为4MB work_mem = 4MB # 是否为主共享内存区域请求巨型页,巨型页面的使用会导致更小的页面表以及花费在内存管理上的 CPU 时间更少,从而提高性能。默认为try huge_pages = try # 最大工作进程数,增加该值可以增加数据库并行处理能力,过大可能导致资源消耗过多,一般可以设置为CPU核数。默认为8 max_worker_processes = 16 # 并行查询的最大并行数。默认为2 max_parallel_workers_per_gather = 4 # 与 max_worker_processes 相同。默认为8 max_parallel_workers = 16 # 数据库维护性操作的最大并行数。默认为2 max_parallel_maintenance_workers = 4 # WAL级别,minimal<replica<logical,级别越高记录的WAL越详细,replica用于物理复制,logical用于逻辑复制。默认为replica wal_level = replica # 启用文件系统同步,确保即使系统发生崩溃或断电等异常情况,数据也不会丢失,在高写入负载下,会导致性能下降。默认为on fsync = on # 最小的 WAL 文件大小,WAL 文件用于确保数据的持久性和恢复能力。默认为80MB min_wal_size = 128MB # 最大的 WAL 文件大小,过小会导致频繁的 checkpoint,从而影响性能,过大则可能会占用过多存储空间,该参数仅为软限制,特殊情况会超出,该参数最好略大于 min_wal_size+wal_keep_size。默认为1GB max_wal_size = 10GB # 控制checkpoint(用来保证内存数据和磁盘数据一致性和完整性)分散写入,值越大,越分散,写入耗时越长,系统负载越小,一般设置为0.7~0.9,对于写入较大的数据库,该值越大越好。默认为0.5 checkpoint_completion_target = 0.9 ### --- 主从同步相关参数 --- ## 主库设置 ## 确保 wal_level 为 replica或logical # 最大的从库连接数,需大于当前从库数。默认为10 max_wal_senders = 10 # WAL文件保留的最小磁盘空间,调大该参数可防止从库同步失败。默认为0,不保留 wal_keep_size = 8GB # 主库等待从库接收WAL文件后响应的超时时间。默认为60s wal_sender_timeout = 300s # 最大复制槽数量,和 max_wal_senders 相同。默认为10 max_replication_slots = 10 ## 从库设置 # 连接主库的信息 primary_conninfo = "host=master-db-host port=5432 user=replicator password=pwd" # 指定主库的复制槽名称 primary_slot_name = 'xxx' # 允许从库进行只读查询。默认为on hot_standby = on # 从库向主库发送状态信息的时间间隔(状态信息包括 WAL 接收器的状态、当前接收进度等数据,主数据库可以使用这些信息监控复制的健康状况和同步延迟)。默认为10s wal_receiver_status_interval = 10s # 允许从库向主库发送反馈信息,以减少查询延迟和 WAL 日志的删除(启用该配置需要确保有足够的磁盘空间,并定期监控主库的 WAL 文件状态,同时需要注意观察主从同步的延迟)。默认为off hot_standby_feedback = on # 从库等待 WAL 发送的超时时间。默认为60s wal_receiver_timeout = 300s ### --- log 相关参数 --- # 将日志输出到标准错误输出 log_destination = 'stderr' # 启用日志收集器(按照 log_directory 和 log_filename 指定的路径保存)。默认为off logging_collector = on # 日志文件的存储目录 log_directory = 'log' # 日志文件的命名格式 log_filename = 'pgsql-%Y%m%d_%H%M%S.log' # 日志文件切分周期 log_rotation_age = 1d # 不根据文件大小切分 log_rotation_size = 0 # 日志记录的最低级别 log_min_messages = warning # 记录 SQL 语句的最低错误级别 log_min_error_statement = error # 记录慢查询时间,单位毫秒,超过该值会记录到日志中。默认不记录 log_min_duration_statement = 5000 # 日志格式。默认只记录时间和进程id log_line_prefix = '<%m [%p] %r %u@%d> ' # 记录等待锁时间超过deadlock_timeout的日志。默认为off,不记录 log_lock_waits = on ### --- autovacuum 相关参数(需根据表大小,表数据更新频率调整,系统资源) --- # 启用自动清理,需同时开启track_counts。默认为on autovacuum = on # 执行自动清理的最大并发数。默认为3 autovacuum_max_workers = 3 # 每分钟启动一次自动清理进程。默认为1min autovacuum_naptime = 1min # 当表中的死行数超过该阈值时,触发 VACUUM 操作。默认为50 autovacuum_vacuum_threshold = 10000 # 在表中插入的行数超过此阈值时,触发 VACUUM 操作。默认为1000 autovacuum_vacuum_insert_threshold = 10000 # 当表中有足够的变化(如插入、更新、删除)且行数超过该阈值时,触发 ANALYZE 操作以更新统计信息。默认为50 autovacuum_analyze_threshold = 5000 # 当表中死行数达到表行数的5%时触发 VACUUM。默认为0.2 autovacuum_vacuum_scale_factor = 0.05 # 在表中插入的行数超过5%时,触发 VACUUM 操作。默认为0.2 autovacuum_vacuum_insert_scale_factor = 0.05 # 当数据变化超过表大小的 5% 时,触发 ANALYZE 操作,更新表的统计信息。默认为0.1 autovacuum_analyze_scale_factor = 0.05 # 每次vacuum操作执行一定量的 I/O 操作后休眠的时间(毫秒),目的是限制自动清理操作对磁盘 I/O 的影响,避免过多的 I/O 操作导致系统性能下降,可增加该值以减少对系统性能的影响。默认是2ms,需与autovacuum_vacuum_cost_limit配合使用 autovacuum_vacuum_cost_delay = 20ms # 每次vacuum操作的最大 I/O 成本。默认是 -1(即使用 vacuum_cost_limit),可降低该值以减少对系统性能的影响 autovacuum_vacuum_cost_limit = 200 可单独针对表设置vacuum参数: ALTER TABLE large_table SET ( autovacuum_vacuum_threshold = 10000, autovacuum_vacuum_scale_factor = 0.05, autovacuum_analyze_threshold = 5000, autovacuum_analyze_scale_factor = 0.05 ); psql 1 2 3 4 5 6 7 8 9 10 11 nohup psql postgresql://user:password@host:port/dbname -f update.sql > update.sql 2>&1 & # 刷库命令,update.sql 文件以 begin; 开始,commit; 结束 \q # 退出数据库 \c exampledb # 切换数据库 \l+ # 查看全部数据库 \du+ # 查看全部用户 \d+ # 查看全部表 \dt+ [table_name] # 查看表大小 \di+ [index_name] # 查看索引大小 \dn+ # 查看全部schema \dp [table_name] # 查看表的权限详情 \x # 竖式显示记录 sql 查看锁等待状态 pg中关于AccessShareLock和ExclusiveLock的问题: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 -- 1. 先用一个函数来将锁转换为数字, create function f_lock_level(i_mode text) returns int as $$ declare begin case i_mode when 'INVALID' then return 0; when 'AccessShareLock' then return 1; when 'RowShareLock' then return 2; when 'RowExclusiveLock' then return 3; when 'ShareUpdateExclusiveLock' then return 4; when 'ShareLock' then return 5; when 'ShareRowExclusiveLock' then return 6; when 'ExclusiveLock' then return 7; when 'AccessExclusiveLock' then return 8; else return 0; end case; end; $$ language plpgsql strict; -- 2. 修改查询语句,按锁级别排序: with t_wait as (select a.mode,a.locktype,a.database,a.relation,a.page,a.tuple,a.classid,a.objid,a.objsubid, a.pid,a.virtualtransaction,a.virtualxid,a,transactionid,b.query,b.xact_start,b.query_start, b.usename,b.datname from pg_locks a,pg_stat_activity b where a.pid=b.pid and not a.granted), t_run as (select a.mode,a.locktype,a.database,a.relation,a.page,a.tuple,a.classid,a.objid,a.objsubid, a.pid,a.virtualtransaction,a.virtualxid,a,transactionid,b.query,b.xact_start,b.query_start, b.usename,b.datname from pg_locks a,pg_stat_activity b where a.pid=b.pid and a.granted) select r.locktype,r.mode r_mode,r.usename r_user,r.datname r_db,r.relation::regclass,r.pid r_pid, r.page r_page,r.tuple r_tuple,r.xact_start r_xact_start,r.query_start r_query_start, now()-r.query_start r_locktime,r.query r_query,w.mode w_mode,w.pid w_pid,w.page w_page, w.tuple w_tuple,w.xact_start w_xact_start,w.query_start w_query_start, now()-w.query_start w_locktime,w.query w_query from t_wait w,t_run r where r.locktype is not distinct from w.locktype and r.database is not distinct from w.database and r.relation is not distinct from w.relation and r.page is not distinct from w.page and r.tuple is not distinct from w.tuple and r.classid is not distinct from w.classid and r.objid is not distinct from w.objid and r.objsubid is not distinct from w.objsubid and r.transactionid is not distinct from w.transactionid and r.pid <> w.pid order by f_lock_level(w.mode)+f_lock_level(r.mode) desc,r.xact_start; 现在可以排在前面的就是锁级别高的等待,优先干掉这个。 -[ RECORD 1 ]-+---------------------------------------------------------- locktype | relation -- 冲突类型 r_mode | ShareUpdateExclusiveLock -- 持锁模式 r_user | postgres -- 持锁用户 r_db | postgres -- 持锁数据库 relation | tbl -- 持锁对象 r_pid | 25656 -- 持锁进程 r_xact_start | 2015-05-10 14:11:16.08318+08 -- 持锁事务开始时间 r_query_start | 2015-05-10 14:11:16.08318+08 -- 持锁SQL开始时间 r_locktime | 00:01:49.460779 -- 持锁时长 r_query | vacuum freeze tbl; -- 持锁SQL,注意不一定是这个SQL带来的锁,也有可能是这个事务在之前执行的SQL加的锁 w_mode | AccessExclusiveLock -- 等待锁模式 w_pid | 26731 -- 等待锁进程 w_xact_start | 2015-05-10 14:11:17.987362+08 -- 等待锁事务开始时间 w_query_start | 2015-05-10 14:11:17.987362+08 -- 等待锁SQL开始时间 w_locktime | 00:01:47.556597 -- 等待锁时长 w_query | truncate tbl; -- 等待锁SQL -[ RECORD 2 ]-+---------------------------------------------------------- locktype | relation r_mode | ShareUpdateExclusiveLock r_user | postgres r_db | postgres relation | tbl r_pid | 25656 r_xact_start | 2015-05-10 14:11:16.08318+08 r_query_start | 2015-05-10 14:11:16.08318+08 r_locktime | 00:01:49.460779 r_query | vacuum freeze tbl; w_mode | RowExclusiveLock w_pid | 25582 w_xact_start | 2015-05-10 14:11:22.845+08 w_query_start | 2015-05-10 14:11:22.845+08 w_locktime | 00:01:42.698959 w_query | insert into tbl(crt_time) select now() from generate_series(1,1000); -- 这个SQL其实等待的是truncate tbl的锁; ...... 统计数据库表以及索引存储空间 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 -- 按从大到小排序输出数据库每个索引大小 select indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) as size from pg_stat_user_indexes where schemaname='public' order by pg_relation_size('public'||'.'||indexrelname) desc; -- [PostgreSQL中查询 每个表的总大小、索引大小和数据大小,并按总大小降序排序](https://blog.csdn.net/sunny_day_day/article/details/131455635) SELECT pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size, pg_size_pretty(pg_indexes_size(c.oid)) AS index_size, pg_size_pretty(pg_total_relation_size(c.oid) - pg_indexes_size(c.oid)) AS data_size, nspname AS schema_name, relname AS table_name FROM pg_class c LEFT JOIN pg_namespace n ON n.oid = c.relnamespace WHERE relkind = 'r' AND nspname NOT LIKE 'pg_%' AND nspname != 'information_schema' ORDER BY pg_total_relation_size(c.oid) DESC; 常用sql语句 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 -- 查找超过1小时的长事务 select count(*) from pg_stat_activity where state <> 'idle' and (backend_xid is not null or backend_xmin is not null) and now()-xact_start > interval '3600 sec'::interval; -- 查看处于等待锁状态 select * from pg_locks where not granted; -- 查看等待锁的关系(表,索引,序列等) select * from pg_class where oid=[上面查出来的relation]; -- 查看等待锁的数据库 select * from pg_database where oid=[上面查出来的database]; -- 锁表状态 select oid from pg_class where relname='可能锁表了的表'; -- 查询出结果则被锁 select pid from pg_locks where relation='上面查出的oid'; -- 关闭事务并回滚 select pg_cancel_backend(pid); -- 若无法关闭,则强制杀死进程连接 select pg_terminate_backend(pid); -- 查看连接信息,重点关注state处于idle in transaction select * from pg_stat_activity; -- 替换数据库名称 update pg_database set datname = 'destniationDb' where datname = 'sourceDb'; -- 清除数据库所有连接 SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname='test_db' AND pid<>pg_backend_pid(); -- 复制数据库,需断开sourceDb的全部连接 CREATE DATABASE destniationDb TEMPLATE sourceDb OWNER test_user; -- 清空表并重置自增序列 truncate table table1,table2 RESTART IDENTITY; -- 导出数据库中数据,HEADER 可不带 \COPY (select * from table1) TO '/tmp/sql_output.csv' WITH CSV HEADER; -- 输出删除全部表的sql \COPY (SELECT 'DROP TABLE IF EXISTS "' || tablename || '" CASCADE;' from pg_tables WHERE schemaname = 'public') TO '/tmp/sql_output.sql'; -- 添加部分索引(满足条件才建立索引), where 和 select 语句的一致 create index [XXX] where [XXX] -- 查看当前连接事务执行超时时间 show statement_timeout; -- 设置数据库事务执行超时时间为 60 秒 AlTER DATABASE mydatabse SET statement_timeout='60s'; -- 设置用户事务执行超时时间为 5 分钟 ALTER ROLE guest SET statement_timeout='5min'; 子查询优化 PG 的子查询实际有两种,分为子连接(Sublink)和子查询(SubQuery),按子句的位置不同,出现在 from 关键字后的是子查询,出现在 where/on 等约束条件中或投影中的子句是子连接。 子查询:select a.* from table_a a, (select a_id from table_b where id=1) b where b.a_id = a.id; 子连接:select * from table_a where id in(select a_id from table_b where id=1); 在简单的子连接查询下,PG 数据库查询优化器一般会将其转化为内连接的方式:select a.* from table_a a, table_b b where a.id=b.a_id and b.id=1;,正常索引没问题情况下这两种方式都能得一样的结果,最终执行的都是索引内连接结果。但在某些情况下,PG 查询优化器在子连接的 SQL 下,子连接的查询会走索引,而主查询会顺序扫描(Seq Scan),原因是当 table_a 的数据量很大时,索引值又有很多重复的,同时查询优化器也不知道子连接返回的具体数据,这时查询优化器可能会认为顺序扫描更快,从而不走索引,导致耗时增加,所以为减少查询优化器的不确定性,最好是直接使用内连接的方式代替 in 语句。 当然,对于特别复杂的查询业务,还是开启事务,分多次查询,在代码层做一些业务逻辑处理更合适,别让数据库把事情全做了,这也能减轻数据库的压力。 PG 查询计划执行路径可以看看: PostgreSQL 查询语句优化,postgresql通过索引优化查询速度操作 tricks 由于 pg 无法强制使用索引,所以只能通过一些其他方法来引导查询优化器使用索引,比如调整查询条件; 获取分组中最大值对应的一行数据; 临时禁用索引 table_col1_idx:update pg_index set indisvalid = false where indexrelid = 'table_col1_idx'::regclass; 查看哪些索引长期未使用,查询 pg_stat_user_indexes 表; 权限配置,PostgreSQL权限管理详解: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 -- 创建只读组 create role readonly_group; -- 设置只读模式 ALTER ROLE readonly_group SET default_transaction_read_only TO 'on'; -- 创建只读用户继承只读组 create user reader with password 'reader' in role readonly_group; -- 删除用户 drop user reader; -- 将只读组权限赋给只读用户 grant readonly_group to reader; -- 读权限 GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_group; GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO readonly_group; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO readonly_group; -- 写权限 GRANT INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO write_group; GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO write_group; 主从&备份 参考资料:postgresql流式复制(Streaming Replication)、【PostgreSQL】PostgreSQL复制的监控、【PostgreSQL】导出数据库表(或序列)的结构和数据、pg_ctl、pg_basebackup 1 2 3 4 5 6 7 8 -- 创建流复制备份用户(主从) create user replicator replication login encrypted password 'replicator' -- 在主库创建一个物理复制槽(PG9.4引入,一个从库一个复制槽) select pg_create_physical_replication_slot('phy_repl_slot_1'); -- 查看复制槽状态 select * from pg_replication_slots; 相关命令: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # 冷备数据库(结合刷库命令可恢复数据库),加 -s 参数只导出数据库表结构 nohup pg_dump postgresql://user:password@host:port/dbname -f db_dump.20240107.sql > dump.log 2>&1 & # 新建一个数据库 pg_ctl init -D /home/postgres/db_data_dir # 修改配置后重新加载配置 pg_ctl reload -D /home/postgres/db_data_dir # 或者重启数据库 pg_ctl restart -D /home/postgres/db_data_dir # 设置数据库默认连接密码 export PGPASSWORD=test_pswd # 完整复制数据库(作为从库) nohup pg_basebackup -h localhost -p port -U replicator -D /home/postgres/db_data1_dir -v -P -R -Xs > ./backup.log 2>&1 & # 从库提升为主库 pg_ctl promote -D /home/postgres/db_data_dir 设置主库:postgresql.conf 1 2 3 4 5 6 7 8 9 10 wal_level = hot_standby # PG12 之后,wal_level = replica # 主备机不同步时,re_wind恢复结点 wal_log_hints = on # 设置最大流复制数(从库数) max_wal_senders = 3 wal_keep_segments = 64 # 支持从库读,以及从库再拉从库 hot_standby = on 设置主库:pg_hba.conf 1 2 3 4 5 6 # Allow replication connections from localhost, by a user with the # replication privilege. local replication all trust host replication all 127.0.0.1/32 trust host replication all ::1/128 trust host replication all 0.0.0.0/0 md5 设置从库 recovery.conf(自 Postgresql 12 起,recovery.conf 并入 postgresql.conf): 1 2 3 standby_mode = 'on' # PG12之后,删除该配置项 primary_conninfo = 'host=db_addr port=db_port user=replicator password=<password>' primary_slot_name = 'phy_repl_slot_1' 区分主库从库 主要方式:从库的根目录下存在 recovery.conf 文件(PG12 之后无该文件,而是存在一个 0KB 的 standby.signal 文件)。 SELECT * FROM pg_stat_replication; 如果有结果(显示所有连接到该节点的从库),则表示当前节点为主库。 主库一般配置参数: PG12 之后,wal_level = replica或logical; max_wal_senders 一般设置较大,允许多个从库; hot_standby,主库一般为 off; 从库一般配置参数: hot_standby,从库为 on; primary_conninfo,有连接到主库的相关配置信息; 并发 dump&restore 数据库 导出数据库全部表结构 1 pg_dump -d postgresql://user:pswd@host:port/db_name --schema-only -f db_name_schema.sql 导出外键约束 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 psql -d postgresql://owner_user:pswd@host:port/db_name -t -A -F"," -c " SELECT DISTINCT 'ALTER TABLE ' || quote_ident(nsp.nspname) || '.' || quote_ident(cls.relname) || ' ADD CONSTRAINT ' || quote_ident(con.conname) || ' FOREIGN KEY (' || array_to_string(ARRAY( SELECT quote_ident(att.attname) FROM pg_attribute att WHERE att.attnum = ANY(con.conkey) AND att.attrelid = cls.oid), ', ') || ') REFERENCES ' || quote_ident(f_nsp.nspname) || '.' || quote_ident(f_cls.relname) || ' (' || array_to_string(ARRAY( SELECT quote_ident(att.attname) FROM pg_attribute att WHERE att.attnum = ANY(con.confkey) AND att.attrelid = f_cls.oid), ', ') || ') ON DELETE ' || CASE con.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END || ' ON UPDATE ' || CASE con.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END || ';' FROM pg_constraint con JOIN pg_class cls ON con.conrelid = cls.oid JOIN pg_namespace nsp ON cls.relnamespace = nsp.oid JOIN pg_class f_cls ON con.confrelid = f_cls.oid JOIN pg_namespace f_nsp ON f_cls.relnamespace = f_nsp.oid WHERE con.contype = 'f';" > db_name_fkeys.sql 导出数据库全局用户/权限 1 pg_dumpall -d postgresql://superuser:pswd@host:port --globals-only -f db_name_user.sql 4个并行任务导出全部数据 1 pg_dump -d postgresql://user:pswd@host:port/db_name --data-only -F d -j 4 -f ./db_name_data_dir 新建数据库实例 1 pg_ctl init -D ~/new_db_data 导入数据库全局用户/权限 1 psql -U superuser -p port -f db_name_user.sql 新建数据库 1 create database new_db_name owner owner_user 导入数据库全部表结构 1 psql -U superuser -p port -f db_name_schema.sql 移除新库外键约束 1 2 3 4 5 6 7 8 9 10 11 12 psql -d postgresql://owner_user:pswd@host:port/db_name <<EOF DO \$\$ DECLARE r RECORD; BEGIN FOR r IN (SELECT conname, conrelid::regclass FROM pg_constraint WHERE contype = 'f') LOOP EXECUTE 'ALTER TABLE ' || r.conrelid || ' DROP CONSTRAINT ' || r.conname; END LOOP; END \$\$; EOF 4个并行任务导入数据 1 pg_restore -d postgresql://owner_user:pswd@host:port/db_name -j 4 ./db_name_data_dir 恢复新库外键约束 1 psql -d postgresql://owner_user:pswd@host:port/db_name -f db_name_fkeys.sql MVCC/数据碎片/索引膨胀/FREEZE 参考自:PostgreSQL | 空间又告警了,先从整理索引碎片开始,正确的评估postgres index膨胀,PostgreSQL VACUUM 之深入浅出 (一),深入理解 PostgreSQL 中的 MVCC(多版本并发控制)机制,硬核-深度剖析PostgreSQL数据库“冻结炸弹”原理机制 简单总结一下: mvcc 主要通过锁或乐观并发控制机制来解决冲突,通过事务号实现多版本及查询可见性(当前事务只能看到当前事务启动前已提交的数据,即只可能大事务号看到小事务号的数据),当事务号达到设定值时,事务号会发生回卷,此时需要以单用户模式执行 vacuum freeze 操作,将所有事务号置为2,代表冻结事务,对所有事务可见,当然可通过设置参数实现自动 freeze,减少人工介入维护时间; 由于 postgres 的 mvcc 机制,更新和删除以及新增的回滚都会造成数据碎片,虽然有 vacuum,但仍然存在部分数据碎片无法再被重复利用(连续空间释放中间一部分,再重新分配后,可能导致少许剩余空间太小无法再利用,实时清理或合并这些小空间的代价又太大),且索引的膨胀不可避免(当数据被删除标记为死元组时,被删除数据的索引仍然存在,而 vacuum 不会清理无效索引),所以当发现索引碎片率超过 30% 时,需要进行重建索引 REINDEX,但常规的 REINDEX 会锁表,在 pg12 之后才有 REINDEX CONCURRENTLY,可在线重建,不会锁表,重建完之后需要执行 ANALYZE 更新一下统计信息使索引立即生效。 空间清理   由于标准的 vacuum 无法释放空间归还给操作系统,只是在数据库内部清理/释放/使用(所以 vacuum 只对于未造成空间膨胀的数据库有效,而且当存在大量更新/删除操作时,vacuum 也不一定能及时控制数据库大小,导致数据库空间一步步变大)。而 VACUUM FULL 或者 CLUSTER 在清理磁盘时会进行锁表(SELECT、INSERT、UPDATE 和 DELETE 等操作都无法正常进行,基本可认为是需要停机维护),对于已经占用大量存储空间的数据库,可以使用 pg_repack 进行在线清理/释放表空间,相比 CLUSTER 或 VACUUM FULL,pg_repack 无需获取排它锁,更轻量。   针对 vacuum 不及时导致一直新申请磁盘空间膨胀的问题,PG 支持设置 autovacuum,根据系统资源调整相关参数后,可以使用 pg_stat_user_tables 视图监控表的膨胀情况,关注 n_dead_tup(死元组数量)和 last_autovacuum(上次vacuum时间):SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;,以及使用 pg_stat_activity 视图检查 vacuum 进程的执行情况和影响:SELECT datname, pid, usename, query_start, state, query FROM pg_stat_activity WHERE query LIKE '%vacuum%';。   对于既成事实占用存储空间超大的数据库,缩减空间一个可能的方案是先 dump 数据,同时开始记录原数据库增量的 dml sql(log_statement=mod),新建一个数据库,用 dump sql 文件写入,记录 dump 最新的节点(时间或者啥 id,再将原数据库节点之外的数据迁移到新数据库中(用之前记录的增量 dml sql,需过滤回滚的事务),再用新数据库替换原数据库,如此达到释放空间的目的(该方案同样适用于数据库版本升级)。(当然也可以用时间字段过滤出增量数据) 并发创建索引   在迁移数据库数据时,建表导入数据再创建索引 比 建表创建索引再导入数据 耗时要短。在创建索引时可以并行创建,这样速度更快。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 #!/bin/bash export PGPASSWORD=pwd function create_idx() { sql_cmd=$1 pid=$! echo "$pid [`date '+%Y-%m-%d_%H:%M:%S'`] start: $sql_cmd" psql -p 5432 -d db_name -c "$sql_cmd" echo "$pid [`date '+%Y-%m-%d_%H:%M:%S'`] end: $sql_cmd" } table1_idx_arr=( "create index concurrently table1_column1_idx on table1 (column1);" "create index concurrently table1_column2_idx on table1 (column2);" ) { for table1_idx in "${table1_idx_arr[@]}"; do create_idx "$table1_idx" done } > ./logs/table1_idx.log 2>&1 & echo "$! creating table1_idx..." pids+=($!) table2_idx_arr=( "create index concurrently table2_column1_idx on table2 (column1);" "create index concurrently table2_column2_idx on table2 (column2);" ) { for table2_idx in "${table2_idx_arr[@]}"; do create_idx "$table2_idx" done } > ./logs/table2_idx.log 2>&1 & echo "$! creating table2_idx..." pids+=($!) for pid in "${pids[@]}";do wait $pid ret=$? if [ $ret != 0 ];then echo "[`date '+%Y-%m-%d %H:%M:%S'`] ERROR [$0:$LINENO], pids[${pids[*]}], pid[$pid] exit with ret[$ret]" else echo "[`date '+%Y-%m-%d %H:%M:%S'`] INFO [$0:$LINENO], pids[${pids[*]}], pid[$pid] exit with ret[$ret]" fi done 如果要控制并行数,可以增加等待机制,每次调用 create_idx "$table1_idx" 前,先调用 wait_create_idx。 1 2 3 4 5 6 7 8 9 10 proc_name="create index" function wait_create_idx() { while true; do proc_cnt=`ps aux | grep "$proc_name" | wc -l` if [ $proc_cnt -le 10 ]; then # 10个并发进程 break fi sleep 60 # 休眠60s done } 常见问题: 当自增主键报 duplicate key value violates unique constraint 主键冲突时,一般是因为存在手动分配 id 的数据(复制表或着手动插入分配了 id),自增主键 seqence TABLE_COLUMN_seq 没有更新,新插入一个值自增 id 和数据库已插入的分配 id 冲突,此时需要执行 SELECT setval('TABLE_COLUMN_seq', (SELECT max(COLUMN) FROM "TABLE")) 更新自增主键; 分析 sql 性能时,可在 sql 语句前增加 EXPLAIN 关键字,查看执行计划,EXPLAIN 一般不会实际执行 sql,但 sql 中带有子语句时,子语句可能会执行,所以为保险起见,最好是在事务中使用 EXPLAIN;eg: 1 2 3 begin; EXPLAIN select * from table1 where id=1; rollback; 若要分析实际执行时间,可以使用 EXPLAIN ANALYZE,该选项会实际执行 SQL,也可以组合参数一起分析执行命令 explain (analyze,verbose,costs,buffers,timing) select * from table1 where id=1;。 如果业务数据无法直接使用批量写入数据库,就最好在一个事务中写入(当然也得看数据量),在同一个事务中写入,不仅能利用事务本身的 ACID 特性,而且比单独分次执行 sql 效率更高; PG 数据库中,如果要使用 order 排序查询时,一般带主键的复合索引比单个字段索引更有效,因为 PG 数据在数据更新后,一般会乱序存储,导致单字段索引在查询时需要访问的页面会更多; PG 刚创建/删除索引后,不一定会及时生效,需要数据库运行一段时间后才会开始生效,如需要立即生效,可执行 ANALYZE VERBOSE table_name;命令,离线或者低负载的时候可以执行 VACUUM VERBOSE ANALYZE table_name,清理表的同时更新统计信息,得到更好的 SQL 执行计划。 后记   后面持续更新。。。

2021/2/9
articleCard.readMore

时空查询之ECQL

前言   ECQL 是 CQL 的扩展,CQL 是 OGC 标准查询语言,而 ECQL 是 GeoTools 为更好的方便查询,在编程实现时扩展了 CQL,主要扩展在于其移除了 CQL 的一些限制(属性必须在比较运算符的左边,不能创建 Id Filter 进行查询等限制),也和 SQL 更相似。所以可简单认为 CQL 是书面上的标准,而 ECQL 是事实上的标准。 谓词篇 时间查询主要有以下几个查询谓词: 谓词作用 T TEQUALS Time测试 T 和给定时间相等,相当于 T == Time。 T BEFORE Time测试 T 在给定时间之前,相当于 T < Time。 T BEFORE OR DURING Time Period测试 T 在给定时间段之前或其中,相当于 T <= TimePeriod[1]。 T DURING Time Period测试 T 在给定时间段其中,相当于 TimePeriod[0] <= T <= TimePeriod[1]。 T DURING OR AFTER Time Period测试 T 在给定时间段其中或之后,相当于 TimePeriod[0] <= T。 T AFTER Time测试 T 在给定时间之后,相当于 T > Time。 时间段以 / 分隔符区分前后两个时间,时间格式一般为 yyyy-MM-dd'T'HH:mm:ss.SSS'Z'。 空间查询主要有以下几个查询谓词: 谓词作用 INTERSECTS(A: Geometry, B: Geometry)测试 A 与 B 相交,与 DISJOINT 相反。 DISJOINT(A: Geometry, B: Geometry)测试 A 与 B 不相交,与 INTERSECTS 相反。 CONTAINS(A: Geometry, B: Geometry)测试 A 包含 B,与 WITHIN 相反。 WITHIN(A: Geometry, B: Geometry)测试 B 包含 A,即 A 在 B 中,与 CONTAINS 相反。 TOUCHES(A: Geometry, B: Geometry)测试 A 的边界是否与 B 的边界接触,但内部不相交。 CROSSES(A: Geometry, B: Geometry)测试 A 与 B 是否相交,但不存在包含关系。 OVERLAPS(A: Geometry, B: Geometry)测试 A 与 B 是否重叠,需满足 A 与 B 是同一类型(如都是 POLYGON),并且相交区域同样是 A 和 B 的类型(只能是 POLYGON,不能是 POINT)。 EQUALS(A: Geometry, B: Geometry)测试 A 与 B 完全相等。 RELATE(A: Geometry, B: Geometry, nineIntersectionModel: String)测试 A 与 B 是否满足 DE-9IM 模型,该模型可模拟上述所有情况。 DWITHIN(A: Geometry, B: Geometry, distance: double, units: String)测试 A 与 B 的最短距离是否不超过多少距离,单位有(feet, meters, statute miles, nautical miles, kilometers)。 BEYOND(A: Geometry, B: Geometry, distance: Double, units: String)测试 A 与 B 的最短距离是否超过多少距离。 BBOX(A: Geometry, leftBottomLng: Double, leftBottomLat: Double, rightTopLng: Double, rightTopLat: Double, crs="EPSG:4326")测试 A 是否与给定 box 相交。 Geometry 是指 WKT 格式的数据,主要有以下几种: 类型示例 POINTPOINT(6 10) LINESTRINGLINESTRING(3 4,10 50,20 25) POLYGONPOLYGON((1 1,5 1,5 5,1 5,1 1),(2 2,2 3,3 3,3 2,2 2)) MULTIPOINTMULTIPOINT(3.5 5.6, 4.8 10.5) MULTILINESTRINGMULTILINESTRING((3 4,10 50,20 25),(-5 -8,-10 -8,-15 -4)) MULTIPOLYGONMULTIPOLYGON(((1 1,5 1,5 5,1 5,1 1),(2 2,2 3,3 3,3 2,2 2)),((6 3,9 2,9 4,6 3))) GEOMETRYCOLLECTIONGEOMETRYCOLLECTION(POINT(4 6),LINESTRING(4 6,7 10)) ※注: POLYGON 中的边界点必须闭合,即首尾点相同,若存在多个边界,则需要遵循 逆时针,顺时针,逆时针,顺时针... 的点排列顺序,逆时针封闭,顺时针开孔,以形成具有岛和洞的复杂多边形。   由于 WKT 标准只支持二维的坐标,为支持三维坐标以及齐次线性计算,所以在 PostGIS 中又有 EWKT 标准实现,EWKT 扩展了 WKT,带 Z 结尾用来支持三维坐标,带 M 结尾用来支持齐次线性计算,如 POINTZ(6 10 3),POINTM(6 10 1),POINTZM(6 10 3 1),同时还支持坐标内嵌空间参考系,如 SRID=4326;LINESTRING(-134.921387 58.687767, -135.303391 59.092838)。GeoTools 19.0 之后也默认以 EWKT 进行解析和编码。 查询篇 属性字段查询 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 查询属性 ATTR1 小于 7 的数据 Filter filter = ECQL.toFilter("ATTR1 < (1 + ((3 / 2) * 4))" ); // 查询属性 ATTR1 小于属性 ATTR2 绝对值的数据 Filter filter = ECQL.toFilter("ATTR1 < abs(ATTR2)" ); // 查询属性 ATTR1 为 test 字符串的数据 Filter filter = ECQL.toFilter("ATTR1 == 'test'" ); // 查询属性 ATTR1 在 10 和 20 之间的数据 Filter filter = ECQL.toFilter( "ATTR1 BETWEEN 10 AND 20" ); Filter filter = ECQL.toFilter( "ATTR1 >= 10 AND ATTR1 <= 20" ); // 多条件查询 Filter filter = ECQL.toFilter("ATTR1 < 10 AND ATTR2 < 2 OR ATTR3 > 10" ); // 查询属性 ATTR1 为 silver 或 oil 或 gold 的数据 Filter filter = ECQL.toFilter("ATTR1 IN ('silver','oil', 'gold' )"); // 以 ID 主键进行查询 Filter filter = ECQL.toFilter("IN ('river.1', 'river.2')"); Filter filter = ECQL.toFilter("IN (300, 301)"); 模糊查询 1 2 3 4 5 6 7 8 9 10 11 // 查询属性 ATTR1 包含 abc 字符串的数据 Filter filter = ECQL.toFilter( "ATTR1 LIKE '%abc%'" ); // 查询属性 ATTR1 开头不为 abc 字符串的数据 Filter filter = ECQL.toFilter( "ATTR1 NOT LIKE 'abc%'" ); // 查询属性 cityName 开头为 new 的数据,忽略 new 的大小写 Filter filter = ECQL.toFilter("cityName ILIKE 'new%'"); // 测试字符串是否包含 Filter filter = ECQL.toFilter("'aabbcc' LIKE '%bb%'"); 空属性查询 1 2 3 4 5 6 7 8 // 查询有属性 ATTR1 存在的数据 Filter filter = ECQL.toFilter( "ATTR1 EXISTS" ); // 查询属性 ATTR1 不存在的数据 Filter filter = ECQL.toFilter( "ATTR1 DOES-NOT-EXIST" ); // 查询 Name 为 NULL 的数据 Filter filter = ECQL.toFilter("Name IS NULL"); 时间查询 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 查询时间属性 dtg 等于的数据 Filter filter = ECQL.toFilter( "dtg TEQUALS 2006-11-30T01:30:00Z" ); // 查询时间属性 dtg 在之后的数据 Filter filter = ECQL.toFilter("dtg AFTER 2006-11-30T01:30:00Z"); // 查询时间属性 dtg 在之前的数据 Filter filter = ECQL.toFilter("dtg BEFORE 2006-11-30T01:30:00Z"); // 查询时间属性 dtg 在之间的数据,+3:00 代表 GMT 时间 +3 小时,以 Z 结尾的时间就是 GMT 时间 Filter filter = ECQL.toFilter( "dtg DURING 2006-11-30T00:30:00+03:00/2006-11-30T01:30:00+03:00 "); // 查询时间属性 dtg 等于的数据 Filter filter = ECQL.toFilter("dtg = 1981-06-20"); // 查询时间属性 dtg 小于等于的数据 Filter filter = ECQL.toFilter("dtg <= 1981-06-20T12:30:01Z"); 空间查询 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 查询空间属性 geom 包含点的数据 Filter filter = ECQL.toFilter( "CONTAINS(geom, POINT(1 2))" ); // 查询空间属性 geom 与 box 相交的数据 Filter filter = ECQL.toFilter( "BBOX(geom, 10,20,30,40)" ); // 查询空间属性 geom 与点最短距离不超过 10 千米的数据 Filter filter = ECQL.toFilter( "DWITHIN(geom, POINT(1 2), 10, kilometers)" ); // 查询空间属性 geom 与线相交的数据(geom 也必须是线) Filter filter = ECQL.toFilter( "CROSS(geom, LINESTRING(1 2, 10 15))" ); // 查询空间属性 geom 与 GEOMETRYCOLLECTION 相交的数据(geom 也必须是 GEOMETRYCOLLECTION) Filter filter = ECQL.toFilter( "INTERSECT(geom, GEOMETRYCOLLECTION (POINT (10 10),POINT (30 30),LINESTRING (15 15, 20 20)) )" ); // 查询空间属性 geom 与线相交的数据 Filter filter = ECQL.toFilter( "CROSSES(geom, LINESTRING(1 2, 10 15))" ); // 查询空间属性 geom 与 GEOMETRYCOLLECTION 相交的数据 Filter filter = ECQL.toFilter( "INTERSECTS(geom, GEOMETRYCOLLECTION (POINT (10 10),POINT (30 30),LINESTRING (15 15, 20 20)) )" ); // 查询空间属性 geom 与包含线的数据 Filter filter = ECQL.toFilter("RELATE(geom, LINESTRING (-134.921387 58.687767, -135.303391 59.092838), T*****FF*)");   在 GeoTools 中,可通过 FilterFactory 来构造 Filter,而不是直接写字符串,具体示例如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2(); // 相当于 Filter filter1 = ECQL.toFilter("ATTR1 = 1 AND ATTR2 < 4" ); List<Filter> filterList = ECQL.toFilterList("ATTR1=1; ATTR2<4"); Filter filter1 = ff.and(filterList); // 相当于 Filter filter2 = ECQL.toFilter( "BBOX(geom, 10,20,30,40)" ); Filter filter2 = ff.bbox("geom", 10, 20, 30, 40, "EPSG:4326"); // 相当于 Filter filter3 = ECQL.toFilter( "dtg DURING 2006-11-29T00:30:00Z/2006-11-30T00:30:00Z"); Date startTime = ZonedDateTime.of(2006, 11, 29, 0, 30, 0, 0, ZoneOffset.UTC); Date endTime = Date.from(startTime.plusDays(1).toInstant()); Filter filter3 = ff.between(ff.property("dtg"), ff.literal(startTime), ff.literal(endTime)); 后记   基本可认为 CQL 和 SQL 中查询条件差不多,虽然不支持分组查询等复杂 SQL 特性,但对于一般的时空查询基本够用,CQL 中还有些空间操作函数就不继续写了,如取面积,取缓冲区,取交集,取长度等等,有需要的可自行查询 uDig Common Query Language。 参考资料 GeoTools CQL GeoTools ECQL GeoServer ECQL Reference / GeoServer 属性查询和空间查询支持 CQL / ECQL过滤器语言 WKT解读 GEOS库学习之三:空间关系、DE-9IM和谓词

2021/1/23
articleCard.readMore

GeoMesa踩坑指北

前言   需要做个 GeoMesa 的微服务,简单熟悉一下 GeoMesa。 基础篇   GeoMesa 可以说是大数据中的 PostGIS,主要用来在存储和处理 GIS 数据时提供相应的索引,从而加快处理速度。GeoMesa 基于 GeoTools,其中最重要的两个概念就是 SimpleFeatureType 和 SimpleFeature,SimpleFeatureType 对应的是关系型数据库中表的描述(表明,表的列字段属性信息等),而 SimpleFeature 对应的是表中每行数据。下面重点谈谈 GeoMesa 中的 SimpleFeatureType 以及其创建索引方式。   在 GeoMesa 中通常使用 SimpleFeatureTypes.createType 方法进行创建,该方法有两个重载,以没有 namespace 参数的方法为例: 1 2 3 4 def createType(typeName: String, spec: String): SimpleFeatureType = { val (namespace, name) = parseTypeName(typeName) createType(namespace, name, spec) } 先通过 parseTypeName 解析 typeName,以 : 作为分隔符,取最后一个有效(不为空)字符串作为表名(name),其余部分如有效则作为 namespace,否则 namespace 则为 null。spec 参数的通用形式有以下几种: 1 2 3 4 5 6 7 val spec = "name:String,dtg:Date,*geom:Point:srid=4326" val spec = "name:String,dtg:Date,*geom:Point:srid=4326;geomesa.indices.enabled='z2,id,z3'" val spec = "name:String:index=true,tags:String:json=true,dtg:Date:default=true,*geom:Point:srid=4326;geomesa.indices.enabled='z2,id,z3'" val spec = "userId:String,trackId:String,altitude:Double,dtg:Date,*geom:Point:srid=4326;geomesa.index.dtg='dtg',geomesa.table.sharing='true',geomesa.indices='z3:4:3,z2:3:3,id:2:3',geomesa.table.sharing.prefix='\\u0001'" 先使用 ; 分隔符,再使用 , 分隔符,最后使用 : 分隔符。; 分隔符将 spec 分割为两个字符串:前者表示表中的全部列属性信息,列属性经过 , 分隔符分割为多列,列又经过 : 分隔符分割为 列名,列数据类型,列的一些属性(是否是索引,json 数据,默认索引等),而列名首字母 * 代表该字段是用于索引的 geometry 类型,一般采用 WKT 格式进行描述,当然存在数据库时会以字节码进行压缩;后者表示创建表时的 userData,同样经过 , 分隔符分割为多个 userData,userData 的一些默认属性可在 SimpleFeatureTypes.Configs 中看到,其它的可以用户自定义,这里重点说一下 geomesa.indices.enabled 属性,目前 GeoMesa 支持 8 种索引,分别为: 1 2 3 4 5 6 7 8 "attr", // 属性索引 "id", // 主键索引 "s2", // Hilbert 曲线点空间索引 "s3", // Hilbert 曲线点时空索引 "z2", // Z 型曲线点空间索引 "xz2", // Z 型曲线线面空间索引 "z3", // Z 型曲线点时空索引 "xz3" // Z 型曲线线面时空索引   由于 GeoMesa 中的索引一般存在多个版本,而 geomesa.indices.enabled 默认使用最新的版本,若需要指定版本,需要使用 geomesa.indices,该属性是 geomesa 内部属性,不对外开放,通用格式为: 1 s"$name:$version:${mode.flag}:${attributes.mkString(":")}" name 代表索引类别,version 代表索引版本,mode.flag 代表索引模式(是否支持读写,一般为3,支持读也支持写),attributes 代表是哪些字段需要建立该索引。spec 参数可以只有描述列属性的字段,即不带任何 useData 信息,GeoMesa 会默认添加索引信息,若存在空间和时间字段,则会默认建立 z3(空间字段为点 Point 类型) 或 xz3(空间字段为线面 非Point 类型) 索引,若有多个空间和时间字段,建立索引的字段为第一个空间和第一个时间字段;若只存在空间字段,则会建立 z2 或 xz2 索引;若只有时间字段,则默认建立时间属性索引。当然如没有在 spec 指明索引信息,可以在后续继续添加信息,如下: 1 2 3 4 5 6 7 8 9 10 import org.locationtech.geomesa.utils.interop.SimpleFeatureTypes; String spec = "name:String,dtg:Date,*geom:Point:srid=4326"; SimpleFeatureType sft = SimpleFeatureTypes.createType("mySft", spec); // enable a default z3 and a default attribute index sft.getUserData().put("geomesa.indices.enabled", "z3,attr:name"); // or, enable a default z3 and an attribute index with a Z2 secondary index sft.getUserData().put("geomesa.indices.enabled", "z3,attr:name:geom"); // or, enable a default z3 and an attribute index with a temporal secondary index sft.getUserData().put("geomesa.indices.enabled", "z3,attr:name:dtg"); 坑篇 导入 OSM 数据问题   在导入 osm 数据时,若使用 osm-ways 作为 SimpleFeatureType,则 geomesa 会使用数据库存储 node 临时使用,这时其默认使用 H2 Database,若想使用其它数据库,则需要在 lib 导入相应 jdbc 包,若使用 postgresql 数据库,则 geomesa 会触发一个 bug,因为 postgresql 没有 double 类型,只有 double precision 类型,这将导致建表出错。详情见 geomesa/geomesa-convert/geomesa-convert-osm/src/main/scala/org/locationtech/geomesa/convert/osm/OsmWaysConverter.scala 中 1 2 3 4 private def createNodesTable(): Unit = { val sql = "create table nodes(id BIGINT NOT NULL PRIMARY KEY, lon DOUBLE, lat DOUBLE);" WithClose(connection.prepareStatement(sql))(_.execute()) } 所以若需要使用 geomesa-convert-osm 导入 osm 数据时,需要进入 geomesa/geomesa-convert/geomesa-convert-osm 文件夹中输入命令 1 mvn dependency:copy-dependencies -DoutputDirectory=./depLib 导出 geomesa-convert-osm 依赖包,将其中的 h2,osm4j,dynsax,trove4j 等一系列库放入 $GEOMESA_HBASE_HOME/lib 中。 s2 索引问题   s2 索引即 Google S2 Geometry 算法基于 Hilbert 曲线生成一种索引,GeoMesa 的 s2 索引是一个国人提交的,目前 3.2 版本只支持点的时空索引,不支持线面的时空索引,当然官方也在实现自己的 Hilbert 曲线,希望后续 GeoMesa 中会有 h2 索引。Shaun 在导入 osm 数据并启用 s2 索引时,报错,被提示不支持,对比 geomesa-index-api2Index.scala 和 geomesa-index-api2Index.scala 两文件的 defaults 函数可发现 S2Index 直接返回空,而在 geomesa-index-api.scala 中 fromName 函数需要调用 defaults 函数,从而导致 s2 索引不支持,修改 S2Index 的 defaults 函数即可(别忘了在 S2Index 类中首行加上 import org.locationtech.geomesa.utils.geotools.RichSimpleFeatureType.RichSimpleFeatureType)。 后记   暂时就了解了这么多,等后续熟悉的更多再继续更吧 (ง •_•)ง。 附录 GeoMesa 命令行工具部分参数 Geomesa 命令行参数: 参数描述 -c, --catalog *存放 schema 元数据的catalog 表(相当于数据库) -f, --feature-nameschema 名(相当于数据库中的表) -s, --spec要创建 SimpleFeatureType 的说明(即表中列的描述信息,表的 schema,如 "name:String,age:Int,dtg:Date,*geom:Point:srid=4326") -C, --converter指定转换器,必须为一下之一:1、已经在classpath中的converter 名;2、converter 的配置(一个字符串);3、包括converter的配置的名 –converter-error-mode自定义的转换器的error mode -t, --threads指定并行度 –input-format指定输入源格式(如csv, tsv, avro, shp, json,) –no-tracking指定提交的 ingest job何时终止(在脚本中常用) –run-mode指定运行模式,必须为:local(本地)、distributed (分布式)、distributedcombine(分布式组合)之一 –split-max-size在分布式中,指定切片最大大小(字节) –src-list输入文件为文本文件,按行输入 –force禁用任何的提示 [files]…指定输入的文件 参考资料:GeoMesa命令行工具---摄取命令

2021/1/16
articleCard.readMore