[{"content":"","date":"2026 April 20 21:56:50","externalUrl":null,"permalink":"/tags/automation/","section":"Tags","summary":"","title":"Automation","type":"tags"},{"content":"","date":"2026 April 20 21:56:50","externalUrl":null,"permalink":"/tags/bilibili/","section":"Tags","summary":"","title":"Bilibili","type":"tags"},{"content":"","date":"2026 April 20 21:56:50","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"2026 April 20 21:56:50","externalUrl":null,"permalink":"/tags/feedflow/","section":"Tags","summary":"","title":"Feedflow","type":"tags"},{"content":" feedflow 技术文档 # 多平台媒体订阅自动化工作流系统 — 适用于博客发布与 AI 上下文理解\n1. 项目概述 # 1.1 项目定位 # feedflow 是一个多平台媒体订阅自动化工作流系统，支持 B 站 UP 主和小红书博主的订阅监控，实现从内容发现 → 下载 → 转写 → AI 分析 → 推送通知的完整自动化流程。\n1.2 核心特性 # 特性 B 站 小红书 内容监控 RSS + API 双保险 Spider_XHS + HTTP 降级 媒体处理 yt-dlp 下载 XHS-Downloader 三层降级 转写引擎 SenseVoice（中文优化） 视频笔记用 SenseVoice，图文直接提取正文 AI 分析 CodeBuddy CLI (glm-5.1-ioa) 同左 推送通知 Gotify Gotify（独立 Token 支持） 1.3 适用场景 # 财经 UP 主内容追踪与摘要 知识型博主内容自动化归档 多平台媒体内容聚合与分析 2. 系统架构 # 2.1 整体架构 # 入口层（run_check.py / cron_run.sh）→ 核心编排层（core/pipeline.py）→ 平台适配层（platforms/bilibili/ 和 platforms/xiaohongshu/）→ 公共处理层（core/transcriber.py、core/summarizer.py、core/notifier.py）→ 配置与工具层（shared/、platforms/*/auth.py）\n2.2 数据流 # 订阅配置 ──▶ 监控模块 ──▶ 新内容发现 │ ▼ 下载模块 (yt-dlp / XHS-Downloader) │ ▼ 转写模块 (SenseVoice) │ ▼ AI 摘要 (CodeBuddy CLI) │ ▼ 推送模块 (Gotify) │ ▼ 清理模块 (可选) 3. 技术栈 # 3.1 核心依赖 # 类别 技术 用途 语言 Python 3.12+ 主开发语言 包管理 uv 依赖管理与虚拟环境 异步框架 asyncio 异步 I/O 处理 B 站 SDK bilibili-api-python \u0026gt;= 17.0 B 站 API 调用 视频下载 yt-dlp \u0026gt;= 2024.0 B 站视频下载 转写引擎 modelscope + funasr + torch SenseVoice 转写 AI 分析 CodeBuddy CLI AI 摘要生成 RSS 解析 feedparser \u0026gt;= 6.0 RSS 内容解析 HTTP 客户端 requests + urllib3 HTTP 请求 CLI 框架 click \u0026gt;= 8.0 命令行接口 终端美化 rich \u0026gt;= 13.0 终端输出美化 配置文件 PyYAML \u0026gt;= 6.0 YAML 配置解析 小红书签名 Spider_XHS 小红书 API 签名逆向 推送服务 Gotify 消息推送 3.2 外部依赖 # FFmpeg: 音频提取（SenseVoice 输入预处理） Node.js 20+: Spider_XHS 签名 JavaScript 执行环境 Gotify Server: 推送通知服务端（需自行部署） 4. 项目结构 # feedflow/ ├── platforms/ # 平台适配层 │ ├── bilibili/ # B 站平台实现 │ │ ├── auth.py # Cookie 凭证管理 │ │ ├── monitor.py # API 模式视频监控 │ │ ├── rss_monitor.py # RSS 模式视频监控 + 健康度评分 │ │ ├── dynamic.py # 动态监控 │ │ ├── comments.py # 评论区亮点抓取 │ │ └── (可选) live.py # 直播监控（待扩展） │ └── xiaohongshu/ # 小红书平台实现 │ ├── auth.py # Cookie + 签名参数获取 │ ├── monitor.py # 笔记列表监控 │ ├── downloader.py # 笔记内容下载（三层降级） │ ├── parser.py # 笔记内容解析 │ └── comments.py # 评论亮点抓取 │ ├── core/ # 平台无关核心流程 │ ├── pipeline.py # 流程编排（B 站 + 小红书） │ ├── transcriber.py # SenseVoice 转写 │ ├── summarizer.py # AI 摘要生成 │ └── notifier.py # Gotify 推送 │ ├── shared/ # 共享模块 │ ├── config.py # 配置管理（dataclass 驱动） │ └── downloader.py # yt-dlp 下载封装 │ ├── vendor/ # 第三方依赖（git submodule） │ └── spider_xhs/ # Spider_XHS 签名模块 │ ├── config.yaml # 配置文件（不提交到 git） ├── config.yaml.example # 配置模板 ├── run_check.py # 主入口脚本 ├── cron_run.sh # Cron 调度脚本 ├── pyproject.toml # 项目元数据 + 依赖 └── README.md # 用户文档 5. 核心数据模型 # 5.1 B 站数据模型 # @dataclass class VideoInfo: bvid: str # BV 号（如 BV1xx4y1a7AB） title: str # 视频标题 uid: int # UP 主 UID author: str # UP 主名称 pubdate: int # 发布时间（Unix timestamp） duration: int # 时长（秒） desc: str = \u0026#34;\u0026#34; # 视频描述 pic: str = \u0026#34;\u0026#34; # 封面图 URL @dataclass class DynamicInfo: dynamic_id: str # 动态 ID title: str # 动态标题/摘要 author: str # UP 主名称 uid: int # UP 主 UID pubdate: int # 发布时间 link: str # 动态链接 content: str = \u0026#34;\u0026#34; # 动态正文 image_urls: list[str] = field(default_factory=list) # 配图 linked_bvid: str = \u0026#34;\u0026#34; # 关联视频 BV 号 5.2 小红书数据模型 # @dataclass class NoteInfo: note_id: str # 笔记 ID title: str # 笔记标题 author: str # 博主昵称 user_id: str # 博主 ID note_type: str # \u0026#34;video\u0026#34; | \u0026#34;normal\u0026#34; pubdate: int # 发布时间 desc: str = \u0026#34;\u0026#34; # 笔记描述 cover_url: str = \u0026#34;\u0026#34; # 封面图 URL liked_count: int = 0 # 点赞数 xsec_token: str = \u0026#34;\u0026#34; # 访问令牌 5.3 通用结果模型 # @dataclass class DownloadResult: success: bool bvid: str title: str filepath: Optional[Path] = None error: Optional[str] = None access_limited: bool = False # 是否权限受限 access_note: str = \u0026#34;\u0026#34; # 权限说明 @dataclass class TranscriptResult: success: bool bvid: str title: str transcript_path: Optional[Path] = None json_path: Optional[Path] = None text: str = \u0026#34;\u0026#34; # 转写文本 language: str = \u0026#34;\u0026#34; # 检测语言 duration_seconds: float = 0.0 error: Optional[str] = None 6. 核心流程详解 # 6.1 B 站视频处理流程 # async def process_video(bvid, config, title, author, uid, pubdate, duration, store, warning_tracker): # Step 1: 下载视频（优先音频） dl_result = await download_video(bvid, config) if not dl_result.success: # 永久性失败（付费/404）标记已知，避免无限重试 return result # Step 2: SenseVoice 转写 tr_result = transcribe_file(dl_result.filepath, config, bvid, title, author) if not tr_result.success: return result # Step 3: 抓取评论区亮点 comment_result = fetch_comment_highlights(bvid, config) # Step 4: AI 摘要生成（CodeBuddy CLI） summary, summary_source, is_ai = post_transcribe_hook(...) # Step 5: 提炼关键词 keywords = extract_keywords_codebuddy(summary, title, author) # Step 6: Gotify 推送通知 notified = notify_new_video(bvid, title, author, summary, ...) # Step 7: 清理原始文件 if config.transcribe.delete_after_transcribe: cleanup_media(dl_result.filepath, bvid) # Step 8: 标记为已知视频 store.mark_known(VideoInfo(...)) 6.2 RSS 监控与降级策略 # 核心设计：RSS 优先，失败时 API 补漏。\n并发请求 20+ 公共 RSSHub 实例 选择「最新发布时间最大」的 feed（避免缓存滞后） 记录失败 UP 主，返回给 API 补漏 全部 RSS 实例不可用时，自动降级到 API 模式（抛出 RSSAllFailedError） 6.3 RSSHub 实例健康度评分 # class InstanceHealth: success_count: int = 0 fail_count: int = 0 last_success: float = 0 avg_response_time: float = 0 @property def score(self) -\u0026gt; float: # 基础成功率权重 70% score = (self.success_count / total) * 70 # 近期活跃度权重 30% if now - last_success \u0026lt; 3600: # 1 小时内 score += 30 elif now - last_success \u0026lt; 7200: # 2 小时内 score += 15 return score 健康度数据持久化到 data/instance_health.json，跨运行保持。\n6.4 小红书笔记处理流程 # async def process_xhs_note(note, config, store): # Step 1: 下载笔记内容 dl_result = await download_note(note, config) # Step 2: 解析笔记内容 # - 图文笔记：直接提取正文 # - 视频笔记：返回视频路径供转写 content = parse_note_content(note, dl_result) # Step 3: 视频笔记转写 if content.is_video and content.video_path: tr_result = transcribe_file(content.video_path, ...) content.text = tr_result.text # Step 4: 评论亮点 comment_result = xhs_fetch_comment_highlights(note.note_id, config) # Step 5: AI 摘要 summary = post_transcribe_hook(...) # Step 6: 关键词 keywords = extract_keywords_codebuddy(...) # Step 7: 推送 notify_new_xhs_note(note, content, summary, ...) # Step 8: 清理 if content.video_path and config.transcribe.delete_after_transcribe: cleanup_media(content.video_path, note.note_id) # Step 9: 标记已知 store.mark_known(note) 7. 模块详解 # 7.1 配置管理（shared/config.py） # 配置系统使用 dataclass 驱动，支持环境变量覆盖：\n@dataclass class Config: credential: Credential # B 站凭证 download: DownloadConfig # 下载配置 transcribe: TranscribeConfig # 转写配置 subscriptions: list[Subscription] # B 站订阅列表 monitor: MonitorConfig # 监控配置 analysis: AnalysisConfig # AI 分析配置 notification: NotificationConfig # 推送配置 xiaohongshu: XhsConfig # 小红书配置 环境变量覆盖优先级 \u0026gt; 配置文件：GOTIFY_URL、GOTIFY_TOKEN_BILI、GOTIFY_TOKEN_XHS、XHS_COOKIE\n7.2 转写模块（core/transcriber.py） # 使用阿里通义实验室的 SenseVoiceSmall 模型：\n视频文件 → 提取音频（FFmpeg → 16kHz mono WAV） 加载 SenseVoice 模型（全局缓存，避免重复加载） 转写（自动语言识别） 解析结果 + 繁体转简体（opencc） 保存 .txt + .json（带时间戳分段） SenseVoice 优势：中文识别率显著高于 Whisper；自动繁简转换；多语言自动识别；速度快 2-3 倍。\n7.3 摘要模块（core/summarizer.py） # 支持两种模式，AI 失败时自动降级：\n优先：CodeBuddy CLI（glm-5.1-ioa 模型） 降级：本地提取式摘要（按标点分句 → 关键词权重打分 → 取高分片段拼接） AI 摘要 Prompt：\n请总结以下B站视频的核心观点和关键信息。 内容尽量详尽，把重要观点都覆盖到，请分点列出。 只输出总结内容，不要额外的说明。 7.4 推送模块（core/notifier.py） # 统一 Gotify 推送接口，支持 Markdown 渲染，3 次重试机制。\n通知消息结构：\n**UP主:** 名称 **链接:** [BV号](URL) **发布时间:** YYYY-MM-DD HH:MM **关键词:** 关键词A；关键词B；关键词C --- **详情:** AI 生成的摘要内容... **评论区补充:** 置顶评论｜UP主 评论内容... 7.5 B 站 RSS 监控（platforms/bilibili/rss_monitor.py） # 多实例并发：同时请求 20+ 公共 RSSHub 实例 健康度评分：基于成功率、响应时间、最近成功时间 内容新鲜度优先：选择最新发布时间最大的 feed 复查机制：上一轮失败的 UP 主，下一轮优先检测 + 更长超时 全部失败降级：抛出 RSSAllFailedError → pipeline 捕获 → 切换到 API 模式 7.6 小红书监控（platforms/xiaohongshu/monitor.py） # 方式 1：Spider_XHS API（优先） 方式 2：直接 HTTP 请求（降级，需要 Spider_XHS 的签名函数生成 sign 参数） 7.7 小红书下载（platforms/xiaohongshu/downloader.py） # 三层降级策略：\nXHS-Downloader Python 库 XHS-Downloader API Server 直接 HTTP 下载（兜底） 图文笔记：获取笔记详情 → 提取正文 + 图片 URL → 下载图片\n8. 配置说明 # 8.1 配置文件结构（config.yaml） # # 凭证配置 credential: sessdata: \u0026#34;你的B站SESSDATA\u0026#34; bili_jct: \u0026#34;你的bili_jct\u0026#34; buvid3: \u0026#34;你的buvid3\u0026#34; dedeuserid: \u0026#34;你的DedeUserID\u0026#34; cookies_file: \u0026#39;\u0026#39; # 或指定 cookies.txt 路径 # 下载配置 download: dir: ./downloads quality: worst # 优先级：省空间 format: bestaudio/worst # 优先只下音频 max_concurrent: 3 # 转写配置 transcribe: model: base language: zh output_dir: ./transcripts delete_after_transcribe: true # B 站订阅配置（示例） subscriptions: - uid: 12345678 name: \u0026#34;示例UP主A\u0026#34; # 监控配置 monitor: mode: rss # rss（推荐）/ api rsshub_base: https://rsshub.yfi.moe interval_minutes: 3 watch_dynamic: true max_videos_per_check: 10 # AI 分析配置 analysis: enabled: true # 推送配置 notification: enabled: true gotify_url: \u0026#39;\u0026#39; gotify_token: \u0026#39;\u0026#39; priority: 5 # 小红书配置（示例） xiaohongshu: enabled: true cookie: \u0026#39;\u0026#39; # 或环境变量 XHS_COOKIE subscriptions: - user_id: \u0026#34;示例user_id\u0026#34; name: \u0026#34;示例博主\u0026#34; monitor: mode: api interval_minutes: 10 notification: gotify_token: \u0026#39;\u0026#39; # 或环境变量 GOTIFY_TOKEN_XHS 8.2 环境变量 # 变量名 说明 优先级 GOTIFY_URL Gotify 服务地址 高于 config.yaml GOTIFY_TOKEN_BILI B 站推送 Token 高于 config.yaml GOTIFY_TOKEN_XHS 小红书推送 Token 高于 config.yaml XHS_COOKIE 小红书 Cookie 高于 config.yaml 9. 部署与运行 # 9.1 环境准备 # # 安装 uv（Python 包管理器） # macOS: brew install uv # 其他: https://docs.astral.sh/uv/getting-started/installation/ # 安装 FFmpeg（音频提取） # macOS: brew install ffmpeg # Ubuntu: apt install ffmpeg # 安装 Node.js 20+（小红书签名依赖） # nvm install 22 9.2 运行方式 # # 手动执行一次 uv run python run_check.py uv run python run_check.py --platform bili uv run python run_check.py --platform xhs # Cron 定时执行（推荐） # 每 3 分钟执行一次 */3 * * * * cd /path/to/feedflow \u0026amp;\u0026amp; uv run python run_check.py --platform all \u0026gt;\u0026gt; cron.log 2\u0026gt;\u0026amp;1 9.3 Gotify 推送服务部署 # docker run -d \\ --name gotify \\ -p 8080:80 \\ -v gotify-data:/app/data \\ -e GOTIFY_DEFAULTUSER_NAME=admin \\ -e GOTIFY_DEFAULTUSER_PASS=\u0026lt;your-password\u0026gt; \\ gotify/server:latest # 访问 http://your-server:8080，登录后在 Settings → Apps 创建 Token 10. 扩展开发指南 # 10.1 添加新平台 # 以添加「YouTube 平台」为例，需实现：\n# platforms/youtube/monitor.py @dataclass class VideoInfo: video_id: str title: str channel_id: str author: str pubdate: int duration: int class SubscriptionStore: def is_known(self, video_id: str) -\u0026gt; bool: ... def mark_known(self, video: VideoInfo): ... def check_new_videos(config, store) -\u0026gt; list[VideoInfo]: ... 然后在 core/pipeline.py 中添加 run_youtube_check_once()，并在 run_check.py 中添加入口。\n10.2 添加新转写引擎 # 在 core/transcriber.py 中添加模型加载和推理逻辑，支持 sensevoice、whisper、paraformer 等引擎切换。\n10.3 添加新推送渠道 # 在 core/notifier.py 中添加 send_xxx() 函数，支持 Gotify、Telegram、WeChat 等渠道。\n11. 故障排查 # 问题 原因 解决方案 B 站下载失败 Cookie 过期 重新获取 Cookie 并更新 config.yaml RSS 全部失败 RSSHub 实例不可用 自动降级到 API 模式，或更新实例列表 SenseVoice 转写失败 模型未下载 / FFmpeg 缺失 检查 modelscope 缓存；安装 FFmpeg AI 摘要失败 CodeBuddy CLI 未登录 执行 codebuddy /login 小红书 Cookie 失效 Cookie 有效期短 重新获取 Cookie（需扫码登录网页版） Gotify 推送失败 服务地址/Token 错误 检查 config.yaml 和环境变量 12. AI 上下文提示词 # 如果你是一个 AI 助手，需要基于本项目进行扩展或调试，请关注以下关键点：\n平台无关性：核心流程在 core/ 中实现，平台特定逻辑在 platforms/ 中 降级策略：任何外部依赖都必须有降级方案（RSS → API → 本地） 幂等性：基于 known_*.json 的去重机制，重复运行不会重复处理 异步优先：I/O 密集操作使用 asyncio，下载并发控制通过 Semaphore 调试技巧 # # 单独测试转写 from core.transcriber import transcribe_file result = transcribe_file(Path(\u0026#34;test.mp4\u0026#34;), config) # 单独测试摘要 from core.summarizer import generate_summary summary, source, is_ai = generate_summary(bvid, title, author, text, config) # 单独测试推送 from core.notifier import send_gotify send_gotify(\u0026#34;测试\u0026#34;, \u0026#34;这是一条测试消息\u0026#34;, config) 文档生成时间：2026-04-20\n","date":"2026 April 20 21:56:50","externalUrl":null,"permalink":"/posts/feedflow-tech/","section":"Posts","summary":"","title":"feedflow 技术文档：多平台媒体订阅自动化工作流","type":"posts"},{"content":"","date":"2026 April 20 21:56:50","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"2026 April 20 21:56:50","externalUrl":null,"permalink":"/tags/python/","section":"Tags","summary":"","title":"Python","type":"tags"},{"content":"","date":"2026 April 20 21:56:50","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"2026 April 20 21:56:50","externalUrl":null,"permalink":"/","section":"VFTS 352","summary":"","title":"VFTS 352","type":"page"},{"content":"","date":"2026 April 20 21:56:50","externalUrl":null,"permalink":"/tags/xiaohongshu/","section":"Tags","summary":"","title":"Xiaohongshu","type":"tags"},{"content":"","date":"2026 April 20 21:56:50","externalUrl":null,"permalink":"/categories/%E6%8A%80%E6%9C%AF%E6%96%87%E6%A1%A3/","section":"Categories","summary":"","title":"技术文档","type":"categories"},{"content":"","date":"2026 February 6 11:26:17","externalUrl":null,"permalink":"/tags/apache-pulsar/","section":"Tags","summary":"","title":"Apache Pulsar","type":"tags"},{"content":"","date":"2026 February 6 11:26:17","externalUrl":null,"permalink":"/tags/mq/","section":"Tags","summary":"","title":"Mq","type":"tags"},{"content":" Pulsar(3) —— 消息丢失Bug分析 # 问题背景 # 环境与配置 # Broker 版本: 4.1.1 Client 版本: 4.1.1 (Java) JDK: OpenJDK 24.0.2 OS: Linux 5.4.241 Consumer 关键配置 # .enableBatchIndexAcknowledgement(true) // 开启批量消息索引确认 .acknowledgmentGroupTime(100, TimeUnit.MILLISECONDS) // 确认聚合时间 .acknowledgementGroupSize(1000) // 最大确认聚合数量 .ackMode = Individual // 单独确认模式 Producer 关键配置 # .batchingMaxMessages(1000) // 最大批处理消息数 Bug 现象 # 在对一个分区主题进行生产和消费测试时，发现偶发性的单个消息丢失问题。\n预期结果 # --- Total Acked Messages per Subscription --- Subscription [sub-1]: 1000000 acks Subscription [sub-2]: 1000000 acks 实际结果 # --- Total Acked Messages per Subscription --- Subscription [sub-2]: 999999 acks ← 少了一条消息 Subscription [sub-1]: 1000000 acks ERROR: [0:123:0:45] not received from [sub-2]! 关键特征 # 随机发生 - 并非每次必现 仅丢失一条消息 - 丢失数量固定为一个 随机 batch index - 丢失消息在 batch 中的索引随机 Broker 有积压 - 但 consumer.receive() 和 redeliverUnacknowledgedMessages() 都无法获取该消息 关闭某些特性可规避: 设置 enableBatchIndexAcknowledgment = false 时问题消失 设置 acknowledgmentGroupTime = 0 时问题消失 Go Client（不支持 Batch Index Ack）无此问题 DEBUG 日志线索 # Flushing pending acks to broker: last-cumulative-ack: [] -- individual-acks: [] -- individual-batch-index-acks: [(0, 123, {})] 原因分析 # 经过深入分析，发现问题根源在于 Netty Recycler 的错误使用 导致的竞态条件。\n核心问题定位 # 在 PersistentAcknowledgmentsGroupingTracker 类中：\n// 问题场景：isDuplicate() 和 flushAsync() 之间发生竞态 @Override public boolean isDuplicate(@NonNull MessageIdData messageId) { // ... if (type == AckType.Individual) { // 问题：这里使用的对象是 Netty Recycler 回收的 BitSetRecyclable bitSet = BitSetRecyclable.create(); bitSet.set(batchIndex); // 添加到 pendingIndividualBatchIndexAcks pendingIndividualBatchIndexAcks.put(msgId, bitSet); } return false; } private void flushAsync() { // ... for (Map.Entry\u0026lt;MessageIdImpl, BitSetRecyclable\u0026gt; entry : pendingIndividualBatchIndexAcks.entrySet()) { // 问题：可能在 isDuplicate() 还在使用时就被 Recycler 回收了 entry.getValue().recycle(); // ← 这里回收了对象 } pendingIndividualBatchIndexAcks.clear(); } Netty Recycler 工作原理 # Netty 的 Recycler 是一种对象池机制：\n创建: 对象不再使用时调用 recycle() 返回池中 复用: 需要时从池中取出，避免频繁内存分配 风险: 如果对象仍在被使用就被回收，会导致数据错乱 竞态条件时序 # 线程 A (isDuplicate) 线程 B (flushAsync) -------------------------------- -------------------------------- 1. 创建 BitSetRecyclable 对象 2. set(batchIndex) 3. put to map 1. iterate map 2. get Value (同一个对象引用) 3. recycle() ← 回收了对象！ 4. 返回 4. clear map 5. [问题] 此时 BitSet 已被回收， 可能被其他线程复用，数据被覆盖！ 修复方案 # PR 标题 # [fix][client] Fix race condition between isDuplicate() and flushAsync() method in PersistentAcknowledgmentsGroupingTracker due to incorrect use Netty Recycler\n核心修复思路 # 避免在 pendingIndividualBatchIndexAcks 中存储从 Recycler 获取的对象，改为在即将发送时才创建回收对象。\n代码变更 # // 修改前：存储 BitSetRecyclable 到 Map pendingIndividualBatchIndexAcks.put(msgId, bitSet); // 修改后：存储普通的 BitSet，仅在发送时转换为可回收对象 pendingIndividualBatchIndexAcks.put(msgId, new BitSet()); // 在 flushAsync 中发送前再包装为 BitSetRecyclable BitSetRecyclable bitSetRecyclable = BitSetRecyclable.create(); bitSetRecyclable.or(bitSet); // 复制数据 // 发送后回收 bitSetRecyclable.recycle(); 关键改动点 # Map 类型变更: Map\u0026lt;MessageIdImpl, BitSetRecyclable\u0026gt; → Map\u0026lt;MessageIdImpl, BitSet\u0026gt; 延迟创建: 仅在需要发送确认时才创建 BitSetRecyclable 立即回收: 发送完成后立即回收，确保生命周期可控 验证与测试 # 修复前测试结果 # 运行 10 次，约 3-4 次出现消息丢失 丢失数量均为 1 条 修复后测试结果 # 运行 50 次，无消息丢失 性能无明显退化 参考信息 # Issue: #25145 [Bug] Java consumer occasionally missing one message of a batched entry PR: #25208 [fix][client] Fix race condition\u0026hellip; 合并状态: 已合并到 master (2026-02-04) 修复版本: 将在下一版本发布 ","date":"2026 February 6 11:26:17","externalUrl":null,"permalink":"/posts/pulsar-3-message-loss-bug/","section":"Posts","summary":"","title":"Pulsar(3) 消息丢失Bug分析","type":"posts"},{"content":"","date":"2026 February 6 11:26:17","externalUrl":null,"permalink":"/tags/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97/","section":"Tags","summary":"","title":"消息队列","type":"tags"},{"content":"","date":"2026 February 6 11:26:17","externalUrl":null,"permalink":"/categories/%E4%B8%AD%E9%97%B4%E4%BB%B6/","section":"Categories","summary":"","title":"中间件","type":"categories"},{"content":"","date":"2025 November 15 23:56:52","externalUrl":null,"permalink":"/tags/ani-rss/","section":"Tags","summary":"","title":"Ani-Rss","type":"tags"},{"content":"","date":"2025 November 15 23:56:52","externalUrl":null,"permalink":"/tags/jellyfin/","section":"Tags","summary":"","title":"Jellyfin","type":"tags"},{"content":"","date":"2025 November 15 23:56:52","externalUrl":null,"permalink":"/tags/jellyseerr/","section":"Tags","summary":"","title":"Jellyseerr","type":"tags"},{"content":"","date":"2025 November 15 23:56:52","externalUrl":null,"permalink":"/tags/nas/","section":"Tags","summary":"","title":"Nas","type":"tags"},{"content":"","date":"2025 November 15 23:56:52","externalUrl":null,"permalink":"/tags/prowlarr/","section":"Tags","summary":"","title":"Prowlarr","type":"tags"},{"content":"","date":"2025 November 15 23:56:52","externalUrl":null,"permalink":"/tags/qbittorrent/","section":"Tags","summary":"","title":"Qbittorrent","type":"tags"},{"content":"","date":"2025 November 15 23:56:52","externalUrl":null,"permalink":"/tags/radarr/","section":"Tags","summary":"","title":"Radarr","type":"tags"},{"content":"","date":"2025 November 15 23:56:52","externalUrl":null,"permalink":"/tags/sonarr/","section":"Tags","summary":"","title":"Sonarr","type":"tags"},{"content":"","date":"2025 November 15 23:56:52","externalUrl":null,"permalink":"/tags/%E6%9C%8D%E5%8A%A1%E5%99%A8/","section":"Tags","summary":"","title":"服务器","type":"tags"},{"content":" 瑞士小鸡(1)——构造一个流水线看片服务 # 如果手上有一台NAS，或者一台闲置的服务器，可以用来部署一套自动追番、追剧的服务。因为没钱买nas以及公网问题，一台服务器（最好是海外的）会更方便。\n准备工作 # 目录结构如下，可以提前创建好分类目录，其他的目录启动后自然会被docker映射创建：\n./my-chick/ ├── Caddyfile ├── docker-compose.yml └── data/ ├── torrents/ │ ├── tv/ │ └── movie/ └── media/ ├── anime/ ├── movie/ └── tv/ 使用的docker-compose.yml如下：\nservices: ani-rss: image: wushuo894/ani-rss:latest container_name: ani-rss # 自定义 DNS dns: 8.8.8.8 environment: - PUID=0 - PGID=0 - TZ=America/Los_Angeles - UMASK=022 - PORT=7789 - CONFIG=/config volumes: - ./ani-rss/config:/config - ./data/media/anime:/data/media/anime restart: always network_mode: host qBittorrent: image: linuxserver/qbittorrent:latest container_name: qBittorrent environment: - PUID=0 - PGID=0 - TZ=America/Los_Angeles - WEBUI_PORT=8080 volumes: - ./qBittorrent/config:/config - ./data/torrents:/data/torrents - ./data/media/anime:/data/media/anime # ani rss是原地做种，不是硬链接，需要映射目录 restart: always network_mode: host jellyfin: image: jellyfin/jellyfin:latest container_name: jellyfin network_mode: host volumes: - ./jellyfin/config:/config - ./jellyfin/cache:/cache - ./data/media:/data/media restart: always # Optional - may be necessary for docker healthcheck to pass if running in host network mode extra_hosts: - \u0026#39;host.docker.internal:host-gateway\u0026#39; jellyseerr: image: ghcr.io/fallenbagel/jellyseerr:latest init: true container_name: jellyseerr environment: - PUID=0 - PGID=0 - TZ=America/Los_Angeles - LOG_LEVEL=info - PORT=5055 #optional network_mode: host volumes: - ./seerr/config:/app/config healthcheck: test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1 start_period: 20s timeout: 3s interval: 15s retries: 3 restart: always sonarr: image: ghcr.io/linuxserver/sonarr:latest container_name: sonarr network_mode: host volumes: - ./data:/data - ./xarr/sonarr_config:/config environment: - PUID=0 - PGID=0 - TZ=America/Los_Angeles restart: always radarr: image: lscr.io/linuxserver/radarr:latest container_name: radarr network_mode: host volumes: - ./data:/data - ./xarr/radarr_config:/config environment: - PUID=0 - PGID=0 - TZ=America/Los_Angeles restart: always prowlarr: image: lscr.io/linuxserver/prowlarr:latest container_name: prowlarr network_mode: host volumes: - ./xarr/prowlarr_config:/config environment: - PUID=0 - PGID=0 - TZ=America/Los_Angeles restart: always FlareSolverr: image: ghcr.io/flaresolverr/flaresolverr:latest container_name: flaresolverr network_mode: host restart: always environment: - PUID=0 - PGID=0 - TZ=America/Los_Angeles docker compose up -d启动后，下面进行各个组件的配置。\n下载器 # 这里用qBittorrent举例，新的qBittorrent初次启动会有一个随机密码，我们先查看docker日志获取密码，进行登录后在web ui修改密码，方便其他组件访问。设置默认下载目录为/data/torrents。\n流媒体 # 这里用jellyfin举例。\n访问web地址，配置初步设置。\n插件 存储库添加以下三个 官方：https://sgp1.mirror.jellyfin.org/files/plugin/manifest.json bangumi：https://jellyfin-plugin-bangumi.pages.dev/repository.json 字幕（用处不大）：https://github.com/91270/MeiamSubtitles.Release/raw/main/Plugin/manifest-stable.json 重启或者等一会，搜索并安装插件，然后重启 anidb open subtitles shooter 和 thunder，这俩是射手字幕和迅雷影音 媒体库 节目/tv 追番: /data/media/anime, 元数据下载器把anidb置顶 追剧: /data/media/tv, 元数据下载器不用动 电影/movie 追片: /data/media/movie, 元数据下载器不用动 追番 # 我们使用ani-rss来方便地进行番剧的订阅、自动下载和管理。\n登录ani-rss的web ui，配置一下。\n下载设置的工具配置好qBittorrent的地址和密码，测试一下 保存位置 番剧：/data/media/anime/${themoviedbName}/Season ${seasonFormat}，这样方便后续根据tmdb元信息自动刮削 剧场版：/data/media/movie/${themoviedbName} 基本设置 添加订阅：打开TMDB标题和自动刮削 重命名设置：模板用${themoviedbName} S${seasonFormat}E${episodeFormat}，只打开自动重命名 （下载剧场版的时候单独配置一下重命名模板，上面的模板必须要有集数没办法） bangumi：根据指引设置一下app授权自动获取 其他：设置tmdb api key，需要注册 追剧/追片 # 我们使用*arr家族来进行索引、tv和电影的订阅管理，用jellyseerr来搜索美剧和电影\nsonarr/radarr # 这俩需要配置的东西基本一致，以中文web ui的设置页面为例\n通用 设置认证为表单，设置用户名和密码后保存刷新，重新输入密码登陆 记下API key，后面prowlarr需要用到 下载客户端 添加一个qBittorrent，填写地址和密码，测试连接。分类里面填写一个类别（默认有一个tv-sonarr/radarr可以不用改）并记下来，保存。 远程路径映射，配置qBittorrent的下载路径，因为所有容器都映射了/data或其子目录，所以本地路径和远程路径都填写/data/torrents/tv（sonarr）或者/data/torrents/movie（radarr），保存。 回到qBittorrent的web ui，查看左边栏的分类，找到刚才的类别，右键编辑类别，把路径改为上一步填写的路径并保存。这样是为了确保下载的种子以及文件在torrents目录下，不会被媒体库识别改名前的文件。 媒体管理 重命名 sonarr的用{Series Title} S{season:00}E{episode:00} radarr的用{Movie Title} ({Release Year}) 高级选项打开使用硬链接代替复制 根目录指的是你要存放的具体小目录，被媒体库自动刮削。这样可以使得下载的做种文件与媒体库扫描分开识别，同时共用一份硬盘空间。 例如sonarr:/data/media/tv radarr:/data/media/movie prowlarr # 设置 通用：认证为表单，设置用户名和密码后保存刷新，重新输入密码登陆 索引器：添加搜刮器代理，选flaresolverr，填写地址测试，用来绕过配置了cloudflare的索引器网站 应用程序：添加sonarr和radarr，填写地址和API key，测试连接 索引器 添加一大堆索引器即可，只要测试通过的都可以添加，不用考虑语言限制（一般中文和英文的就ok） 有些索引器添加页面提示了需要flaresolverr代理的，添加页面最下面的标签设置刚才添加flaresolverr时设置的标签，再测试通过即可 点一下同步索引器，会推到sonarr和radarr jellyseerr # 类似ani-rss，用于搜索、订阅电影和电视剧。\n打开web ui，根据引导配置好jellyfin、sonarr、radarr的地址和api key即可。后面两个的先测试才能选画质，一般用any就好了。\n设置完成之后在主页的设置页面/jellyfin 点一下同步库，把jellyfin配置的媒体库同步过来都打开\n通过域名访问 # dns解析 # 用cloudflare添加子域名的dns解析到小鸡上即可，海外服务器拉github，docker快，也不用备案。价格没差多少\n反向代理 # 使用caddy配置，给出一个样例文件（没有配置暴露所有的服务，因为有些不需要公网访问，直接连上服务器访问就好）\n{ storage file_system { root /etc/ssl/caddy } } ani.example.org { reverse_proxy localhost:7789 } jelly.example.org { reverse_proxy localhost:8096 # 如果要开启https，查找cloudflare怎么配置一个账户api令牌，访问dns权限即可 tls { # 同时需要caddy支持cloudflare插件 dns cloudflare \u0026lt;API KEY\u0026gt; } } seerr.example.org { reverse_proxy localhost:5055 tls { dns cloudflare \u0026lt;API KEY\u0026gt; } } ","date":"2025 November 15 23:56:52","externalUrl":null,"permalink":"/posts/chick-1/","section":"Posts","summary":"","title":"瑞士小鸡(1) 看片","type":"posts"},{"content":"","date":"2025 November 15 23:56:52","externalUrl":null,"permalink":"/categories/%E5%81%9A%E6%87%92%E7%8B%97/","section":"Categories","summary":"","title":"做懒狗","type":"categories"},{"content":" Pulsar(2) —— ACK # 正如收消息可以由sdk批，ack也可以由sdk批。\nconsumer ACK # consumer调用ack之后，会在sdk暂存起来, 以batch消息为例，计算ackSet （将对应的为置0） 并更新到pending队列中。\nCompletableFuture\u0026lt;Void\u0026gt; doIndividualBatchAckAsync(MessageIdAdv msgId) { ConcurrentBitSetRecyclable bitSet = pendingIndividualBatchIndexAcks.computeIfAbsent( MessageIdAdvUtils.discardBatch(msgId), __ -\u0026gt; { final BitSet ackSet = msgId.getAckSet(); final ConcurrentBitSetRecyclable value; if (ackSet != null) { synchronized (ackSet) { if (!ackSet.isEmpty()) { value = ConcurrentBitSetRecyclable.create(ackSet); } else { value = ConcurrentBitSetRecyclable.create(); value.set(0, msgId.getBatchSize()); } } } else { value = ConcurrentBitSetRecyclable.create(); value.set(0, msgId.getBatchSize()); } return value; }); bitSet.clear(msgId.getBatchIndex()); return CompletableFuture.completedFuture(null); } sdk需要根据ackSet滤掉已经被ack的message。因此对于batch消息，在ack时，设置bit位可以让broker知道这一个entry中的哪些batch index是被ack过的。\nsdk进行消息的生产和消费（主要是ack）时会有以下表现：\n生产者 不开启batch，每条消息生产之后都没有batch size，entry id都是独立的 开启batch消息并设置batch size，则取决于生产速率，如果达到了timeout则该批消息会提前发出，size小于设定的batch size 消费者 非batch消息，receive得到都是entry独立的单条消息，ack没有特殊处理 batch消息，receive得到的是一个entry里按batch index解码出的单条消息 ack之后如上面提到的，同一个entry重发之后会在sdk过滤掉已ack的，避免重复消费 command ack不会携带batch size !!! ack set基于org.apache.pulsar.common.util.collections.ConcurrentBitSet，底层是java.util.BitSet，会自动truncate，即抹掉高位0以节省空间。 pb的ack set结构是Vec\u0026lt;i64\u0026gt;，或者说long[] 例如batch size = 65, 已ack的index为64（最后一个，刚好是第二个long的最低位），原本的表示应该是\\[0b1..1, 0b0..000 0\\] (超过batch size的位置当成已ack，所以为0)，这时候高位0自动抹除只剩下了\\[0b1..1\\]。 当整个batch都ack之后，因为全0，导致ack set为空, 只看ack set和batch size的话跟非batch消息没什么区别 !!! 因此无法通过ack set来反推batch size 通常情况下调用这个函数来构造ack命令，入参只带了ack set\npublic static ByteBuf newAck(long consumerId, long ledgerId, long entryId, BitSetRecyclable ackSet, AckType ackType, ValidationError validationError, Map\u0026lt;String, Long\u0026gt; properties, long requestId) { return newAck(consumerId, ledgerId, entryId, ackSet, ackType, validationError, properties, -1L, -1L, requestId, -1); // 最后一个参数是batch size = -1， 即不设置 } 这种完全依赖broker记录batch size的行为，可以确保ack的时候不会因为sdk导致batch size混乱。但是在broker恢复后entry缓存还未重建，为了尚未完成的batch ack而要把entry读出才能得到batch size，牺牲了性能和灵活性。\nbroker cursor delete # broker收到ack后，会通过cursor模块移动相应的指针和更新记录。每个subscription下可能有多个consumer，但是只会有一个cursor，即topic/partition/subscription为单位进行记录。\n和ack相关的字段大致如下：\npublic class ManagedCursorImpl implements ManagedCursor { Position markDeletePosition; // 记录最大连续位置（下一个位置是空洞位置） RangeSetWrapper\u0026lt;Position\u0026gt; individualDeletedMessages; // 单条ACK的消息集合，离散的位置 ConcurrentSkipListMap\u0026lt;Position, BitSet\u0026gt; batchDeletedIndexes; // batch消息的子消息ack情况 } class Position { int ledgerId; int entryId; long[] ackSet; } 这三个字段从下往上是一个层级关系\nbatchDeletedIndexes记录了batch消息的ack情况，实际上存储的是ackSet，这里ackSet的位为0代表已经ack individualDeletedMessages记录了离散的ack记录（区间表示），如果是batch消息，只有在batchDeletedIndexes中的ackSet全为0，才会从中转移到individualDeletedMessages上；如果是非batch消息，那么ack会直接记录。 markDeletePosition记录了可以被安全删除的位置，即position最小的空洞的前一个位置，或者说从头开始连续的最大的位置，当空洞合并成了新的区间，且区间的左端点-1是markDeletePosition，那么这个区间的右端点 变会成为新的markDeletePosition 后台会定期的对这些重要的元信息做持久化，便于错误恢复或者节点转移时可以得到尽可能新的ack进度。\nconsumer REDELIVER # sdk定时发送重推请求 # 在sdk内部维护了一个UnAckedMessageRedeliveryTracker，用于统计需要自动定时重推的消息，由名字可以看出其追踪尚未被用户ack的消息。定期扫描所有尚未ack的消息，如果超时则收集起来，并统一批量发送Redeliver UnackedMessages给broker处理。\nprivate void triggerRedelivery(ConsumerBase\u0026lt;?\u0026gt; consumerBase) { if (ackTimeoutMessages.isEmpty()) { return; } Set\u0026lt;MessageId\u0026gt; messageIds = TL_MESSAGE_IDS_SET.get(); messageIds.clear(); try { long now = System.currentTimeMillis(); ackTimeoutMessages.forEach((messageId, timestamp) -\u0026gt; { if (timestamp \u0026lt;= now) { addChunkedMessageIdsAndRemoveFromSequenceMap(messageId, messageIds, consumerBase); messageIds.add(messageId); } }); if (!messageIds.isEmpty()) { log.info(\u0026#34;[{}] {} messages will be re-delivered\u0026#34;, consumerBase, messageIds.size()); Iterator\u0026lt;MessageId\u0026gt; iterator = messageIds.iterator(); while (iterator.hasNext()) { MessageId messageId = iterator.next(); ackTimeoutMessages.remove(messageId); } } } finally { if (messageIds.size() \u0026gt; 0) { consumerBase.onAckTimeoutSend(messageIds); consumerBase.redeliverUnacknowledgedMessages(messageIds); } } } broker接收到重推请求后，则会将这批message id推送到dispatcher中，并增加其重推计数，并写入pb对应的redelivery_count字段中。\npublic void redeliverUnacknowledgedMessages(Consumer consumer, List\u0026lt;PositionImpl\u0026gt; positions) { positions.forEach(redeliveryTracker::incrementAndGetRedeliveryCount); redeliverUnacknowledgedMessages(consumer); } sdk收到之后进行检查，如果订阅了RLQ/DLQ规则且重试次数redelivery_count达到了上限，那么会稍后转投到RLQ/DLQ中。\n// ConsumerImpl#messageReceived if (deadLetterPolicy != null \u0026amp;\u0026amp; possibleSendToDeadLetterTopicMessages != null) { if (redeliveryCount \u0026gt;= deadLetterPolicy.getMaxRedeliverCount()) { possibleSendToDeadLetterTopicMessages.put((MessageIdImpl) message.getMessageId(), Collections.singletonList(message)); if (redeliveryCount \u0026gt; deadLetterPolicy.getMaxRedeliverCount()) { // count超过！！之后，这里继续发重推请求，兜底策略，因为一般count就不会超过 redeliverUnacknowledgedMessages(Collections.singleton(message.getMessageId())); // The message is skipped due to reaching the max redelivery count, // so we need to increase the available permits increaseAvailablePermits(cnx); return; } } } nack # nack由用户主动调用consumer.negativeAcknowledge(msg)，同样有一个NegativeAcksTracker进行记录，并定时扫描需要重推的消息。也是通过RedeliverUnackedMessages这条命令。\n需要注意的是，broker中的重推计数器不会持久化，因此broker挂掉或者一个subscription的所有consumer离线之后，重推计数会归零。\nRLQ # RetryLetterQueue即重试队列，与SDK维护的重试逻辑不同，本质上是一个全新的队列，broker无感。具体而言RLQ表现为：\n配置dlq规则时使用enableRetry(true)开启 使用consumer.reconsumerLater(msg)标记消息自动转发，当redelivery_count超过阈值则先转投RLQ 会订阅一个全新的topic，如果未指定则根据规则自动订阅 全新topic意味着与原来独立，broker对待RLQ和对待普通topic是一样的，不会特殊处理。 可视作sdk侧有一个新的producer和新的consumer，重投就是生产新消息 每次重投会ack原消息，并生产一条新消息（payload一致）投递，如果设置了timeout则是延迟消息 RLQ的消息通过properties进行计数，因此broker可以做到无感 { REAL_TOPIC=\u0026#34;persistent://my-property/my-ns/test, ORIGIN_MESSAGE_ID=314:28:-1, RETRY_TOPIC=\u0026#34;persistent://my-property/my-ns/my-subscription-retry, RECONSUMETIMES=16 # 每次重投+1 } RECONSUMETIMES超过设定之后会转投DLQ（如果有的话） DLQ # DeadLetterQueue即死信队列，本质上与RLQ相似，都是订阅新topic。与RLQ的区别在于：\n只会sdk内部自动订阅producer用于转投，consumer需要用户手动订阅 没有RECONSUMETIMES，因为不会重试 下面给出doReconsumeLater()的核心逻辑\n// 如果重试次数超过阈值，转投DLQ if (reconsumeTimes \u0026gt; this.deadLetterPolicy.getMaxRedeliverCount() \u0026amp;\u0026amp; StringUtils.isNotBlank(deadLetterPolicy.getDeadLetterTopic())) { initDeadLetterProducerIfNeeded().thenAcceptAsync(dlqProducer -\u0026gt; { try { TypedMessageBuilder\u0026lt;byte[]\u0026gt; typedMessageBuilderNew = dlqProducer.newMessage( Schema.AUTO_PRODUCE_BYTES(retryMessage.getReaderSchema().get())) .value(retryMessage.getData()) .properties(propertiesMap); copyMessageKeysIfNeeded(message, typedMessageBuilderNew); copyMessageEventTime(message, typedMessageBuilderNew); // 投往DLQ typedMessageBuilderNew.sendAsync().thenAccept(msgId -\u0026gt; { consumerDlqMessagesCounter.increment(); // 成功后ack当前消息 doAcknowledge(finalMessageId, ackType, Collections.emptyMap(), null).thenAccept(v -\u0026gt; { result.complete(null); }).exceptionally(ex -\u0026gt; { result.completeExceptionally(ex); return null; }); }).exceptionally(ex -\u0026gt; { result.completeExceptionally(ex); return null; }); } catch (Exception e) { result.completeExceptionally(e); } }, internalPinnedExecutor).exceptionally(ex -\u0026gt; { result.completeExceptionally(ex); return null; }); } else { // 重试次数还没耗尽，继续重投 assert retryMessage != null; initRetryLetterProducerIfNeeded().thenAcceptAsync(rtlProducer -\u0026gt; { try { TypedMessageBuilder\u0026lt;byte[]\u0026gt; typedMessageBuilderNew = rtlProducer .newMessage(Schema.AUTO_PRODUCE_BYTES(message.getReaderSchema().get())) .value(retryMessage.getData()) .properties(propertiesMap); if (delayTime \u0026gt; 0) { typedMessageBuilderNew.deliverAfter(delayTime, unit); } copyMessageKeysIfNeeded(message, typedMessageBuilderNew); copyMessageEventTime(message, typedMessageBuilderNew); // 重新发往RLQ typedMessageBuilderNew.sendAsync() .thenCompose( __ -\u0026gt; doAcknowledge(finalMessageId, ackType, Collections.emptyMap(), null)) // ack旧消息 .thenAccept(v -\u0026gt; { result.complete(null); }) .exceptionally(ex -\u0026gt; { result.completeExceptionally(ex); return null; }); } catch (Exception e) { result.completeExceptionally(e); } }, internalPinnedExecutor).exceptionally(ex -\u0026gt; { result.completeExceptionally(ex); return null; }); } ","date":"2025 November 15 17:27:00","externalUrl":null,"permalink":"/posts/pulsar-2-ack/","section":"Posts","summary":"","title":"Pulsar(2) ACK","type":"posts"},{"content":" Pulsar(1) —— 消息的接受 # consumer FLOW # pulsar的消息消费其实是在SDK中由consumer发一条command.FLOW去向broker索要消息。 sdk维护了一个imcommingMessages， 用于缓冲从broker接收到的消息，并记录一个初始permit = imcommingMessages的最大长度，每次FLOW会给出一个具体数值messagePermits代表可以接受的消息条数，并且permit -= messagePermits。实际作用就是一个跨进程的信号量。 pb源码参见github\nmessage CommandFlow { required uint64 consumer_id = 1; // Max number of messages to prefetch, in addition // of any number previously specified required uint32 messagePermits = 2; } 这里偷一个哪都有的图解\nbroker send # broker处理完这条flow command之后，dispatcher会向 cursor（最终通过DB）索要一定数量的entry。主要调用readMoreEntries这个方法。在用户视角或者说consumer视角，receive()得到的是一条条的消息，即message，而entry是一个db的概念。\n具体而言，如果开启了批量消息，那么一个entry可能实际上是一批message，而ACK是以message为单位，当需要重推批量消息时，实际上是重推整一个entry， dispatcher不会花费cpu时间对entry进行解包、过滤、再组包的过程，而是牺牲网络io和客户端cpu，在sdk侧进行解包和过滤。\ndispatcher得到entry之后，进行一些无聊的permit计算，得到合适数量的message后，便通过command.Message发往消费者，以shared模式为例：\nprotected synchronized boolean trySendMessagesToConsumers(ReadType readType, List\u0026lt;Entry\u0026gt; entries) { // 核心逻辑如下 while (entriesToDispatch \u0026gt; 0 \u0026amp;\u0026amp; isAtleastOneConsumerAvailable()) { Consumer c = getNextConsumer(); if (c == null) { // 无可用消费者：释放剩余 entry，rewind cursor 并返回 false entries.subList(start, entries.size()).forEach(Entry::release); cursor.rewind(); lastNumberOfEntriesProcessed = (int) totalEntriesProcessed; return false; } int availablePermits = c.isWritable() ? c.getAvailablePermits() : 1; int maxEntriesInThisBatch = getMaxEntriesInThisBatch( remainingMessages, c.getMaxUnackedMessages(), c.getUnackedMessages(), avgBatchSizePerMsg, availablePermits, serviceConfig.getDispatcherMaxRoundRobinBatchSize() ); int end = Math.min(start + maxEntriesInThisBatch, entries.size()); List\u0026lt;Entry\u0026gt; entriesForThisConsumer = entries.subList(start, end); if (readType == ReadType.Replay) { entriesForThisConsumer.forEach(entry -\u0026gt; redeliveryMessages.remove(entry.getLedgerId(), entry.getEntryId())); } SendMessageInfo sendMessageInfo = SendMessageInfo.getThreadLocal(); EntryBatchSizes batchSizes = EntryBatchSizes.get(entriesForThisConsumer.size()); EntryBatchIndexesAcks batchIndexesAcks = EntryBatchIndexesAcks.get(entriesForThisConsumer.size()); totalEntries += filterEntriesForConsumer( metadataArray, start, entriesForThisConsumer, batchSizes, sendMessageInfo, batchIndexesAcks, cursor, readType == ReadType.Replay, c); totalEntriesProcessed += entriesForThisConsumer.size(); c.sendMessages(entriesForThisConsumer, batchSizes, batchIndexesAcks, sendMessageInfo.getTotalMessages(), sendMessageInfo.getTotalBytes(), sendMessageInfo.getTotalChunkedMessages(), redeliveryTracker); int msgSent = sendMessageInfo.getTotalMessages(); remainingMessages -= msgSent; start += maxEntriesInThisBatch; entriesToDispatch -= maxEntriesInThisBatch; // 全局可用许可调整：已发送消息数减去批内已 ack 的索引数 TOTAL_AVAILABLE_PERMITS_UPDATER.addAndGet(this, -(msgSent - batchIndexesAcks.getTotalAckedIndexCount())); totalMessagesSent += sendMessageInfo.getTotalMessages(); totalBytesSent += sendMessageInfo.getTotalBytes(); } lastNumberOfEntriesProcessed = (int) totalEntriesProcessed; acquirePermitsForDeliveredMessages(topic, cursor, totalEntries, totalMessagesSent, totalBytesSent); if (entriesToDispatch \u0026gt; 0) { // 将未发送的 entries 存入重放队列以便稍后重试 entries.subList(start, entries.size()).forEach(this::addEntryToReplay); } return true; } 我们关注这行代码\nc.sendMessages(entriesForThisConsumer, batchSizes, batchIndexesAcks, sendMessageInfo.getTotalMessages(), sendMessageInfo.getTotalBytes(), sendMessageInfo.getTotalChunkedMessages(), redeliveryTracker); 这里以consumer为单位接受List, 可以看到batchIndexesAcks这个参数，其中的元素对应pb中的这一个字段\nmessage CommandMessage { required uint64 consumer_id = 1; required MessageIdData message_id = 2; optional uint32 redelivery_count = 3 [default = 0]; repeated int64 ack_set = 4; // here optional uint64 consumer_epoch = 5; } 对每个entry进行二进制编码后，最终走到的生成消息逻辑如下，metadataAndPayload即为entry的二进制格式\npublic ByteBufPair newMessageAndIntercept(long consumerId, long ledgerId, long entryId, int partition, int redeliveryCount, ByteBuf metadataAndPayload, long[] ackSet, String topic, long epoch) { // 这里command即为CommandMessage BaseCommand command = Commands.newMessageCommand(consumerId, ledgerId, entryId, partition, redeliveryCount, ackSet, epoch); ByteBufPair res = Commands.serializeCommandMessageWithSize(command, metadataAndPayload); // 忽略interceptor的逻辑 return res; } 可以看到，一条CommandMessage里实际上带的是一条entry。\nconsumer recv # consumer的receive()是接受单条消息，但是command是以entry为单位发送的，因此需要在 sdk的messageReceived()里面进行解包分离message，并塞到imcommingMessages中。 拆分的时候，便需要使用ackSet进行消息过滤。当然，如果是一条全新的消息entry，ackSet == null，自然就会读出整个batch。\nvoid receiveIndividualMessagesFromBatch(...) { for (int i = 0; i \u0026lt; batchSize; ++i) { // 把batch里面的每一条都单独读出为message final MessageImpl\u0026lt;T\u0026gt; message = newSingleMessage(i, batchSize, brokerEntryMetadata, msgMetadata, singleMessageMetadata, uncompressedPayload, batchMessage, schema, true, ackBitSet, ackSetInMessageId, redeliveryCount, consumerEpoch, isEncrypted); //... executeNotifyCallback(message); // 入队 } } MessageImpl\u0026lt;V\u0026gt; newSingleMessage(...) { // 如果已经被ack了，则跳过 if (isSingleMessageAcked(ackBitSet, index)) { return null; } // ... } 有一个ZeroQueueConsumer可以不设缓冲区，这里不再详细展开。\n当消费者真正调用receive()之后，就可以直接incomingMessages.poll()取出单条消息来了。\n","date":"2025 October 3 17:37:43","externalUrl":null,"permalink":"/posts/pulsar-1-recv/","section":"Posts","summary":"","title":"Pulsar(1) 消息接受","type":"posts"},{"content":"","date":"2025 August 21 19:29:48","externalUrl":null,"permalink":"/tags/c/c++/","section":"Tags","summary":"","title":"C/C++","type":"tags"},{"content":"","date":"2025 August 21 19:29:48","externalUrl":null,"permalink":"/tags/ffi/","section":"Tags","summary":"","title":"FFI","type":"tags"},{"content":"","date":"2025 August 21 19:29:48","externalUrl":null,"permalink":"/tags/rust/","section":"Tags","summary":"","title":"Rust","type":"tags"},{"content":" 被FFI做局了(2) —— mut \u0026amp; unsafe # 在rust中写ffi有两种大方向：手动绑定以及自动绑定。 如果万幸对接的是modern Cpp，那还可以用诸如cxx之类的库方便快速且安全的生成。 但是如果不幸对接的是C、 stdc++ \u0026lt; 11，特别是头文件不多的，用bindgen可能还没手写快。\n涉及到互相暴露接口的（rust需要调C，C也需要调rust），还是只能手动添加必要的绑定。\n基本接口 # 由于我们需要封装rust代码到c++侧使用，所以大多数接口是在rust代码中extern的。\n例如我们要暴露一个client以及其方法：\n/// rust lib struct Client; impl Client { fn new() -\u0026gt; Self { todo!() } fn foo(\u0026amp;self) { todo!() } } 我们就需要实现对应的extern方法，通过传递裸指针的方式。\n叽里咕噜写了一大堆，还是自动生成binding舒服啊。\nstruct ExportClient { client: Client, } #[no_mangle] unsafe extern \u0026#34;C\u0026#34; fn client_new() -\u0026gt; *mut ExportClient { let c = ExportClient { client: Client::new(), }; Box::into_raw(Box::new(c)) } #[no_mangle] unsafe extern \u0026#34;C\u0026#34; fn client_delete(ptr: *mut ExportClient) { if !ptr.is_null() { let _ = unsafe { Box::from_raw(ptr) }; } } #[no_mangle] unsafe extern \u0026#34;C\u0026#34; fn client_foo(ptr: *mut ExportClient) { // 我们简单假定ptr非空，所以我们不再检查 unsafe { (*ptr).client.foo() }; } 与之对应的C++侧需要一些绑定，以便调用到rust的函数。我们不希望rust实现公开，只希望提供一个lib和一个header便可以使用。因此我们在头文件中需要隐藏绑定的细节。\n// sdk.h namespace sdk { class Client { public: Client(const Client\u0026amp;) = delete; static Client build(); void foo(); private: struct ClientImpl; Client(ClientImpl* d_ptr); ClientImpl* d_ptr_; } } // namespace sdk //sdk.cpp // 我们在源文件中进行ffi绑定 namespace sdk_rust { extern \u0026#34;C\u0026#34; { // 透明类型 struct ExportClient; ExportClient* client_new(); void client_delete(ExportClient* ptr); void client_foo(ExportClient* ptr); } // extern \u0026#34;C\u0026#34; } // namespace sdk_rust // 并且在源文件中把绑定细节封装好 namespace sdk { // 在源文件中实现头文件中的透明类型 struct Client::ClientImpl { sdk_rust::ExportClient* client; ~ClientImpl() { client_delete(this-\u0026gt;client); } } // 后面解释私有构造函数的原因 Client::Client(ClientImpl* d_ptr): d_ptr_(d_ptr) {} Client Client::build() { sdk_rust::ExportClient* client = sdk_rust::client_new(); ClientImpl* d_ptr = new ClientImpl(); d_ptr-\u0026gt;client = client; return Client(d_ptr); } Client::~Client() { delete this-\u0026gt;d_ptr; } void Client::foo() { if (this-\u0026gt;d_ptr_ != nullptr) { return sdk_rust::foo(this-\u0026gt;d_ptr_-\u0026gt;client); } } } 诶我++西佳佳怎么这么坏，绑定几个函数能写出这么多恶心玩意。\n一般new client都不太可能不返回错误，但是想把rust的result跨过ffi还是有点强人所难了，风格也不太一致。为了搞定错误，我们还是决定返回int错误码以及通过出参返回错误信息。\n// sdk.h namespace sdk { class Client { public: // 我们通过可变引用出参来得到错误 // 因此我们没有使用构造函数 static Client build(int32_t\u0026amp; err_code, std::string\u0026amp; err); } } ```cpp ```cpp // sdk.cpp namespace sdk_rust { extern \u0026#34;C\u0026#34; { // 我们通过在cpp侧导出一个std::string的包装函数来修改std::string void append_str(void* ptr, const char* str) { std::string* s = static_cast\u0026lt;std::string*\u0026gt; ptr; s-\u0026gt;append(str); } // 通过ffi传递一个void*远比一个std::string\u0026amp;容易 ExportClient* client_new(int32_t\u0026amp;, void*); } } namespace sdk { Client Client::build(int32_t\u0026amp; err_code, std::string\u0026amp; err) { sdk_rust::ExportClient* client = sdk_rust::client_new(err_code, static_cast\u0026lt;void*\u0026gt;(\u0026amp;err)); if (client == nullptr) { return Client(nullptr); } ClientImpl* d_ptr = new ClientImpl(); d_ptr-\u0026gt;client = client; return Client(d_ptr); } } 在rust侧代码加上相应的result逻辑\nunsafe extern \u0026#34;C\u0026#34; { // 添加对c++暴露接口的绑定 fn append_str(ptr: *mut c_void, string: *const c_char); } // 用safe包一下 pub fn push_str(ptr: *mut c_void, string: impl ToString) { fn inner(ptr: *mut c_void, string: String) { match CString::new(string) { Ok(v) =\u0026gt; unsafe { append_str(ptr, v.as_ptr()) }, Err(_) unsafe { append_str(ptr, c\u0026#34;internal\u0026#34;.as_ptr()) }, } } inner(ptr, string.to_string()) } // 加上result逻辑 impl Client { fn new() -\u0026gt; Result\u0026lt;(),(i32, String)\u0026gt; { todo!() } } // 起码从 int32_t\u0026amp; 转换到 \u0026amp;mut i32 还是不需要额外操作的 #[no_mangle] unsafe extern \u0026#34;C\u0026#34; fn client_new(err_code: \u0026amp;mut i32, err: *mut void) -\u0026gt; *mut ExportClient { match Client::new() { Ok(client) =\u0026gt; Box::into_raw(Box::new(ExportClient { client })), Err((code, msg)) =\u0026gt; { *err_code = code; push_str(err, msg); null_mut() } } } 废了老大劲终于绑定好了，总算可以用了。跳过编译的部分，直接看代码中怎么调用：\n// main.cpp int main() { int32_t code = 0; std::String err; sdk::Client client = sdk::Client::build(code, err); if (code != 0) { printf(\u0026#34;%s\\n\u0026#34;, err.c_str()); return code; } client.foo(); } 就为了能在cpp代码中调一个函数，洋洋洒洒写了几百行绑定，cpp怎么这么坏。\n这一小段代码就埋了个大雷，在下一篇会讲\n","date":"2025 August 21 19:29:48","externalUrl":null,"permalink":"/posts/ffi-trap-2/","section":"Posts","summary":"","title":"被FFI做局了(2)","type":"posts"},{"content":"","date":"2025 August 21 19:29:48","externalUrl":null,"permalink":"/categories/%E5%B7%A5%E4%BD%9C%E8%B8%A9%E5%9D%91/","section":"Categories","summary":"","title":"工作踩坑","type":"categories"},{"content":"","date":"2025 August 14 15:01:34","externalUrl":null,"permalink":"/tags/ci/","section":"Tags","summary":"","title":"Ci","type":"tags"},{"content":"","date":"2025 August 14 15:01:34","externalUrl":null,"permalink":"/tags/github-action/","section":"Tags","summary":"","title":"Github Action","type":"tags"},{"content":" 通过git和ci操作博客 # hugo new的时候虽然会根据模板自动生成date，即hugo new的时间点。 但是我个人更喜欢date是发布的时间点。除此之外，还有draft之类的也需要在发布时更改更改。\n优雅发布？ # 有的懒人会选择单一分支发布，通过写个bash脚本来修改。 但是向我这种超级懒狗是不会用bash这么不优雅，也不方便的东西的，所以选择了通过github action来完成merge、修改date和draft状态等等流程，一步到位。\n即便头铁写ci不完全依赖kimiv2大人，半问半写的时间比写博客还久，但是我就是要写ci！\n实际上是有两方面考虑\n一是我用的cf pages部署，master分支和非master分支会生成不同的预览页面，而我只需要把master分支绑定到自己的域名上，即可对外隐藏没写完的draft。（即便压根就没有人看）\n二是如果用脚本，可能哪里写错了，一不小心直接把整个本地分支干没了，白干打击热情\n需求分析 # 自动判断是否需要合入master 合入之前，自动修改新的文章的date和draft 合入之后，删掉草稿分支 简单分解为这三步之后，ci就好写很多了。 一开始写的ci还是摸着石头过河，还要自己提pr才能触发，完全不好用，后面重新梳理了一下要求，把拆开来的yml合到同一个里面舒服多了。 虽然有桥，但是别问为什么要摸着石头。\n编写ci配置 # github action不用多说，建一个.github/workflows酷酷写yml就行了。\n在ci中我主要通过提pr的方式来合入，毕竟ci写错了还能自己关掉pr重新来过，留点余地。\n因为后续涉及到分支修改等操作，所以还是要给相应的权限。\nname: Auto PR on \u0026#39;post:\u0026#39; prefix, update posts and merge PR on: push: # 每次push都检查一下条件 branches-ignore: - master # 忽略 master 分支的 push permissions: # 给写权限 contents: write pull-requests: write 因为写博客可能会在这台机器写一点，那台机器写一点，所以不是每笔提交都需要合进master，更何况修ci那一大堆commit。\n因此，我们需要判断哪笔提交需要合入，主要思路就是通过判断commit msg的关键字，简单又直接。\n判断是否需要提pr # 工作中用得比较多的一般是.WIP前缀，提示ci不要触发。 但是写博客也不是什么很严谨的流程，要是忘了写.WIP被ci酷酷合入了不就炸缸了。 所以我采取的是白名单判断，msg有关键字才提pr。\njobs: check-need-pr: runs-on: ubuntu-latest outputs: # 留两个标记给后面的流程用 has_prefix: ${{ steps.extract_title.outputs.has_prefix }} pr_title: ${{ steps.extract_title.outputs.pr_title }} steps: - name: Checkout code uses: actions/checkout@v3 # 获取commit msg - name: Get latest commit message id: get_commit run: | echo \u0026#34;commit_msg=$(git log -1 --pretty=%B)\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT - name: Check commit message prefix and extract title id: extract_title run: | PREFIX=\u0026#34;post:\u0026#34; MSG=\u0026#34;${{ steps.get_commit.outputs.commit_msg }}\u0026#34; # 这里判断msg有\u0026#39;post:\u0026#39;前缀才提pr if [[ \u0026#34;$MSG\u0026#34; == $PREFIX* ]]; then # 去掉前缀和前后空格 TITLE=\u0026#34;${MSG#$PREFIX}\u0026#34; TITLE=\u0026#34;${TITLE#\u0026#34;${TITLE%%[![:space:]]*}\u0026#34;}\u0026#34; # 去左空格 TITLE=\u0026#34;${TITLE%\u0026#34;${TITLE##*[![:space:]]}\u0026#34;}\u0026#34; # 去右空格 echo \u0026#34;has_prefix=true\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT echo \u0026#34;pr_title=$TITLE\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT else echo \u0026#34;has_prefix=false\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT fi 提pr # 因为我们的目标很简单，把当前分支合入master，所以很多地方写死即可\njobs: # check-need-pr: ... create-pr-and-merge: runs-on: ubuntu-latest needs: check-need-pr # 链式job if: needs.check-need-pr.outputs.has_prefix == \u0026#39;true\u0026#39; #如果不需要pr就不用走到这里了 steps: - name: Checkout current branch uses: actions/checkout@v3 with: ref: ${{ github.ref }} fetch-depth: 0 - name: Create PR env: # 这里在env设置了token之后，就不要在gh cli上面使用token手动登陆了 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | sudo apt-get update sudo apt-get install -y gh BRANCH=$(git rev-parse --abbrev-ref HEAD) TITLE=\u0026#34;${{ needs.check-need-pr.outputs.pr_title }}\u0026#34; # 这里做一个判断，检查是否已经有pr开着 # 因为成功的话同时只会有一个pr，所以不需要考虑多个pr没关的情况 EXISTING=$(gh pr list --base master --head \u0026#34;$BRANCH\u0026#34; --state open --json number -q \u0026#39;.[0].number\u0026#39;) # 有pr就直接复用，没有pr才提一个新的 if [ -n \u0026#34;$EXISTING\u0026#34; ]; then echo \u0026#34;PR #$EXISTING already exists, skipping creation.\u0026#34; else gh pr create --title \u0026#34;$TITLE\u0026#34; \\ --body \u0026#34;Auto generated PR from commit\u0026#34; \\ --base master \\ --head \u0026#34;$BRANCH\u0026#34; fi - name: Checkout PR branch uses: actions/checkout@v3 with: ref: ${{ github.head_ref }} fetch-depth: 0 使用gh这个cli的时候还是挺坑的，弱智gpt会设置了token env之后再获取一次token，手动登陆gh cli，然后被github拦下来报错。\n修改草稿信息 # 提好pr之后，我们就可以去修改一下新文章的信息了。 根据需求，我们要修改两部份\n把date改为当前时间 把draft状态设为false，这样部署后的页面上不会出现【草稿】字样 关于第二点，其实是cf pages的一个问题引起的。 按道理来说，既然他可以自动把master和非master分支分开部署，那他应该也能针对这两种分支设置不同的编译命令。\n但是我鼓捣了半天没发现怎么做，一旦设置了非生产分支的编译命令后（比如支持编译draft），生产分支的设置也会被同步修改。也就是说，master分支部署的页面也会把草稿编进去。\n- name: Get changed markdown files in content/posts id: changed_files run: | # 提取diff的文章 files=$(git diff --name-only origin/master HEAD | grep \u0026#39;^content/posts/.*\\.md$\u0026#39; || true) echo \u0026#34;files\u0026lt;\u0026lt;EOF\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT echo \u0026#34;$files\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT echo \u0026#34;EOF\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT - name: Update date and draft in changed files if: steps.changed_files.outputs.files != \u0026#39;\u0026#39; run: | now=$(TZ=Asia/Shanghai date +\u0026#34;%Y-%m-%dT%H:%M:%S%:z\u0026#34;) echo \u0026#34;Current time: $now\u0026#34; while IFS= read -r file; do if [ -z \u0026#34;$file\u0026#34; ]; then continue fi echo \u0026#34;Processing $file\u0026#34; # 修改文章时间 sed -i -E \u0026#34;s/date\\s*=\\s*\u0026#39;[^\u0026#39;]*\u0026#39;/date = \u0026#39;2025-08-14T15:01:34+08:00\u0026#39;/g\u0026#34; \u0026#34;$file\u0026#34; # 修改draft为false sed -i -E \u0026#34;s/draft\\s*=\\s*true/draft = false/g\u0026#34; \u0026#34;$file\u0026#34; done \u0026lt;\u0026lt;\u0026lt; \u0026#34;${{ steps.changed_files.outputs.files }}\u0026#34; # 用bot commit - name: Commit and push changes if: steps.changed_files.outputs.files != \u0026#39;\u0026#39; run: | git config user.name \u0026#34;github-actions[bot]\u0026#34; git config user.email \u0026#34;github-actions[bot]@users.noreply.github.com\u0026#34; git add content/posts/*.md if git diff --cached --quiet; then echo \u0026#34;No changes to commit\u0026#34; exit 0 fi git commit -m \u0026#34;ci: update date and draft in posts [skip ci]\u0026#34; BRANCH=$(git rev-parse --abbrev-ref HEAD) git push origin HEAD:$BRANCH 最关键的其实是这一步\ngit diff --name-only origin/master HEAD \\ | grep \u0026#39;^content/posts/.*\\.md$\u0026#39; || true 通过指定diff master和当前分支的HEAD，来得到diff的文件。 有些写法可能会写成origin/master..HEAD 或者 origin/master...HEAD，这里不再赘述这几个点的作用。 我们的草稿分支很可能和master在很远的地方就已经叉开了，因此我们简单粗暴判断两个分支头的差异即可，只要保证草稿分支上的文章是master的超集。\n合入master # 合入master就比较简单，用gh cli merge一下当前打开的pr即可。\n- name: Auto squash merge PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | BRANCH=$(git rev-parse --abbrev-ref HEAD) pr_number=$(gh pr list --base master --head \u0026#34;$BRANCH\u0026#34; --state open --json number -q \u0026#39;.[0].number\u0026#39;) if [ -z \u0026#34;$pr_number\u0026#34; ]; then echo \u0026#34;No open PR found for branch $BRANCH\u0026#34; exit 1 fi echo \u0026#34;Merging PR #$pr_number\u0026#34; gh pr merge \u0026#34;$pr_number\u0026#34; --squash # squash保持一次pr只有一个commit，美观 自动删除当前分支不需要通过ci配置，在仓库的Setting/General/Pull Requests, 勾选上Automatically delete head branches即可。这样成功合入master并关闭pr后，该分支会删除。\n完整版 # name: Auto PR on \u0026#39;post:\u0026#39; prefix, update posts and merge PR on: push: branches-ignore: - master # 忽略 master 分支的 push permissions: contents: write pull-requests: write jobs: check-need-pr: runs-on: ubuntu-latest outputs: has_prefix: ${{ steps.extract_title.outputs.has_prefix }} pr_title: ${{ steps.extract_title.outputs.pr_title }} steps: - name: Checkout code uses: actions/checkout@v3 - name: Get latest commit message id: get_commit run: | echo \u0026#34;commit_msg=$(git log -1 --pretty=%B)\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT - name: Check commit message prefix and extract title id: extract_title run: | PREFIX=\u0026#34;post:\u0026#34; MSG=\u0026#34;${{ steps.get_commit.outputs.commit_msg }}\u0026#34; if [[ \u0026#34;$MSG\u0026#34; == $PREFIX* ]]; then # 去掉前缀和前后空格 TITLE=\u0026#34;${MSG#$PREFIX}\u0026#34; TITLE=\u0026#34;${TITLE#\u0026#34;${TITLE%%[![:space:]]*}\u0026#34;}\u0026#34; # 去左空格 TITLE=\u0026#34;${TITLE%\u0026#34;${TITLE##*[![:space:]]}\u0026#34;}\u0026#34; # 去右空格 echo \u0026#34;has_prefix=true\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT echo \u0026#34;pr_title=$TITLE\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT else echo \u0026#34;has_prefix=false\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT fi create-pr-and-merge: runs-on: ubuntu-latest needs: check-need-pr if: needs.check-need-pr.outputs.has_prefix == \u0026#39;true\u0026#39; steps: - name: Checkout current branch uses: actions/checkout@v3 with: ref: ${{ github.ref }} fetch-depth: 0 - name: Create PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | sudo apt-get update sudo apt-get install -y gh BRANCH=$(git rev-parse --abbrev-ref HEAD) TITLE=\u0026#34;${{ needs.check-need-pr.outputs.pr_title }}\u0026#34; EXISTING=$(gh pr list --base master --head \u0026#34;$BRANCH\u0026#34; --state open --json number -q \u0026#39;.[0].number\u0026#39;) if [ -n \u0026#34;$EXISTING\u0026#34; ]; then echo \u0026#34;PR #$EXISTING already exists, skipping creation.\u0026#34; else gh pr create --title \u0026#34;$TITLE\u0026#34; \\ --body \u0026#34;Auto generated PR from commit\u0026#34; \\ --base master \\ --head \u0026#34;$BRANCH\u0026#34; fi - name: Checkout PR branch uses: actions/checkout@v3 with: ref: ${{ github.head_ref }} fetch-depth: 0 - name: Get changed markdown files in content/posts id: changed_files run: | files=$(git diff --name-only origin/master HEAD | grep \u0026#39;^content/posts/.*\\.md$\u0026#39; || true) echo \u0026#34;files\u0026lt;\u0026lt;EOF\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT echo \u0026#34;$files\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT echo \u0026#34;EOF\u0026#34; \u0026gt;\u0026gt; $GITHUB_OUTPUT - name: Update date and draft in changed files if: steps.changed_files.outputs.files != \u0026#39;\u0026#39; run: | now=$(TZ=Asia/Shanghai date +\u0026#34;%Y-%m-%dT%H:%M:%S%:z\u0026#34;) echo \u0026#34;Current time: $now\u0026#34; while IFS= read -r file; do if [ -z \u0026#34;$file\u0026#34; ]; then continue fi echo \u0026#34;Processing $file\u0026#34; sed -i -E \u0026#34;s/date\\s*=\\s*\u0026#39;[^\u0026#39;]*\u0026#39;/date = \u0026#39;2025-08-14T15:01:34+08:00\u0026#39;/g\u0026#34; \u0026#34;$file\u0026#34; sed -i -E \u0026#34;s/draft\\s*=\\s*true/draft = false/g\u0026#34; \u0026#34;$file\u0026#34; done \u0026lt;\u0026lt;\u0026lt; \u0026#34;${{ steps.changed_files.outputs.files }}\u0026#34; - name: Commit and push changes if: steps.changed_files.outputs.files != \u0026#39;\u0026#39; run: | git config user.name \u0026#34;github-actions[bot]\u0026#34; git config user.email \u0026#34;github-actions[bot]@users.noreply.github.com\u0026#34; git add content/posts/*.md if git diff --cached --quiet; then echo \u0026#34;No changes to commit\u0026#34; exit 0 fi git commit -m \u0026#34;ci: update date and draft in posts [skip ci]\u0026#34; BRANCH=$(git rev-parse --abbrev-ref HEAD) git push origin HEAD:$BRANCH - name: Auto squash merge PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | BRANCH=$(git rev-parse --abbrev-ref HEAD) pr_number=$(gh pr list --base master --head \u0026#34;$BRANCH\u0026#34; --state open --json number -q \u0026#39;.[0].number\u0026#39;) if [ -z \u0026#34;$pr_number\u0026#34; ]; then echo \u0026#34;No open PR found for branch $BRANCH\u0026#34; exit 1 fi echo \u0026#34;Merging PR #$pr_number\u0026#34; gh pr merge \u0026#34;$pr_number\u0026#34; --squash 编写并推送文章 # 流程比较清晰明了，也可以很好隔离不同草稿\n开一个新分支，写到一半的草稿推送上该分支，msg不要带post:前缀 最终定稿之后，msg带post:前缀即可 push到github会自动触发ci，半分钟内即可更新到博客主页 ","date":"2025 August 14 15:01:34","externalUrl":null,"permalink":"/posts/ci-merge/","section":"Posts","summary":"","title":"通过git和ci操作博客","type":"posts"},{"content":"","date":"2025 August 10 00:51:12","externalUrl":null,"permalink":"/tags/asan/","section":"Tags","summary":"","title":"Asan","type":"tags"},{"content":" 被FFI做局了(3) —— 定位内存泄露 # 内存泄露在Rust中被不被认为是违反内存安全的。\n因为泄露的内存在正常途径中，是不会被访问到的，并且最终还是会释放的，也不会出现use-after-free，所以最终还是安全的（长期运行的服务有话说）\nTL;DR # unsafe传播裸指针后，\u0026amp;mut self被错误的在并发条件下进行修改，从而引起容器内存泄露。\n通过以下方式编译Rust静态库，\nRUSTFLAGS=\u0026#34;-Zsanitizer=address -Zexternal-clangrt -C force-frame-pointers=yes\u0026#34; \\ cargo build -Zbuild-std --target x86_64-unknown-linux-gnu 将rust lib链接到C++代码时，使用-static-asan，即可借助asan完全展开两种语言代码的调用栈，定位内存泄露。\n背景 # C++业务需要提供一个SDK，于是通过Rust代码包装主要组件，通过FFI将Rust接口暴露出去，并提供一个C++头文件，最终打包成静态库供业务使用。下称SDK代表这个static lib所负责的代码逻辑。\n然后被告知测试过程中观测到内存缓慢增长，怀疑内存泄露。C++业务自查代码没发现异常。\n好嘛，Rust的神话破灭力～\n工具 # 熟悉C++的对内存泄露可太熟悉了，查起来都是信手拈来，可能用gdb看看coredump就能找到问题。 但是Rust基本不会出现内存泄露，反而一旦出问题还得先二分代码，更别说C++链接一个嵌入了C++代码的Rust lib，找都难找。 不过工具还是相通的。\nsanitizer算是比较常用的工具组合，Rust支持也比较多，可以很方便的在编译时就链接进去。 valgrind也可以进行分析，但是还得安装个二进制，相对没那么方便。 miri是Rust官方开发的一个工具，用于运行和检查 Rust 代码的中间层表示MIR，功能更为强大。特别是检测unsafe代码中存在的问题。 jemalloc_pprof可以协助使用了jemalloc内存分配器的rust程序打内存火焰图，因为默认内存分配器有太多毛病，一般都会换成jemalloc或者mimalloc，所以这点修改还是相对方便的。 使用asan进行定位 # 因为C++侧先斩后奏用asan定位过了，发现是Rust侧产生了内存泄露（屋檐了，伟大的Rust竟然有内存泄露），并且自查代码后，并没有发现在FFI边界使用的CStr::as_ptr()之类的方法leak了或者没有drop。\n这就令人头大了，但是更令人头大的还在后面。\n神秘rust符号 # Direct leak of 4 byte(s) in 1 object(s) allocated from: #0 0xabcde in malloc (libasan.so.4+0xabcde) #1 0xabcde in alloc::alloc::alloc::hf5e9d1f96004a7c7/rustc/8fcd4dd08e2ba3e922d917d819ba0be066bdb005/library/alloc/src/alloc.rs:100 #2 0xabcde in C++业务代码.cc:456 ... alloc.rs:100泄露？ # 怎么#1的下一跳直接回到了SDK的调用方，甚至不是SDK的C++代码本身？\n也就是说整个SDK只能追踪到最后一处调用，即申请内存的那一行。\n于是询问了一下编译和链接过程，cargo build\u0026hellip;\n难不成Rust打包静态库也要启用asan？\nRUSTFLAGS=-Zsanitizer=address \\ cargo build -Zbuild-std --target x86_64-unknown-linux-gnu 我连std都给你加上了asan重编，总该没问题了吧？编译是能编译了，结果来了个更神秘的链接错误\u0026hellip;\nlibsdk.a(xxx.o) In function `asan.module_ctor\u0026#39;: 8hayt6gtqu5sbw3clkx4qs5no:(.text.asan.module_ctor[asan.module_ctor]+0x21): undefined reference to `__asan_register_elf_globals\u0026#39; 什么叫没有找到asan函数的定义？酷酷找了半天资料，终于在一个犄角旮旯#114127发现了问题所在：rustc偷偷把自己的librustc-nightly_rt.asan.a静态链接进去了，而C++业务代码链接的是libasan.so，我勒个豆啊，纯坑b。在非常远的半年以后，他们终于提出了解决方案#121207。\n于是我们使用解决方案重新编译SDK\nRUSTFLAGS=\u0026#34;-Zsanitizer=address -Zexternal-clangrt\u0026#34; \\ cargo build -Zbuild-std --target x86_64-unknown-linux-gnu 并且C++侧也使用静态链接的方式使用libasan，终于成功了运行了！\nDirect leak of 4 byte(s) in 1 object(s) allocated from: #0 0xabcde in malloc (/debug+0xabcde) #1 0xabcde in std::sys::pal::unix::alloc::_$LT$impl$u20$core..alloc..global..pal/unix/alloc.rs:14 #2 0xabcde in _rdl_alloc src/alloc.rs:402 #3 0xabcde in alloc::alloc::Global::alloc_impl::h93f3448be7b573f1/root/.rustbrary/alloc/src/alloc.rs:183 #4 0xabcde in _$LT$alloc..alloc..Global$u20$as$u20$core..alloc..Allocator$GT$unknown-linux-gnu/lib/rustlib/src/rust/Library/alloc/src/alloc.rs:243 #5 0xabcde in main (/debug+0xabcde) #6 0xabcde in _libc_start_call_main (/lib64/Libc.so.6+0xabcde) 靠北诶，你这是啥玩意啊，还在alloc.rs起飞啊，这次怎么连c++都不见了？ 检查构建模式，确实为debug，并且也没有strip\n[profile.dev] debug = true opt-level= 0 酷酷搜寻资料，一无所获，自暴自弃找gpt帮忙，4.1悠悠道：\nRUSTFLAGS=\u0026#34;-Zsanitizer=address -Zexternal-clangrt -C force-frame-pointers=yes\u0026#34; \\ cargo build -Zbuild-std --target x86_64-unknown-linux-gnu 这种混合编译的问题，必须强制把栈帧打开-C force-frame-pointers=yes，不然就会像现在这样出问题。\n事后测试，纯Rust的代码只需要RUSTFLAGS=-Zsanitizer=address就够了\n最终成功拿下泄露点～\nDirect leak of 4 byte(s) in 1 object(s) allocated from: #0 0xabced in malloc (/debug+0xabced) ... 一些std调用栈（包括alloc.rs:100） #7 泄露点的函数调用 #8 0xabcde in 泄露点 src/lib.rs:101 #9 0xabdce in SDKC++侧.cc:123 #10 0xabcde in C++业务代码.cc:456 ... 哦咩跌多！哦咩跌多！\n额外的信息 # 其实可以更早定位出来问题是HashMap导致的，因为在上asan之前，出现过了线程卡死的问题，并且后续已经gdb定位到了使用HashMap的上下文，甚至具体到了行号，且多个线程均卡死在同一个地方。后面把asan用起来才反应过来这里有问题。\nThread 0xabcde***** (LWP ***) 0xabcde********* in __memcmp_sse4_1 () from /lib64/libc.so.6 因为线程不安全的容器在多线程环境下读写，大概率会重复写同一个槽，这样卡死在memcmp也不奇怪了\n反思 # 那我问你，为什么你在并发条件下不用DashMap而是用HashMap，脑子有坑？\n非也，其实是被rust-analyzer蒙蔽了双眼\n思维惯性 # 平时写代码会先写个HashMap用着，因为不确定后面该结构体是需要整体上锁，或者是其他用途（吃了不先设计的亏），或者压根就是单独有线程/协程直接持有所有权，并没有线程安全问题。况且，借用检查器也会帮我检查是否存在多处持有\u0026amp;mut T，或是试图修改不可变的\u0026amp;T。\n于是我写了这么一个函数，没有任何问题。\nstruct S { map: HashMap\u0026lt;u64,u64\u0026gt;, } impl S { // 这是可变引用 fn foo(\u0026amp;mut self, k: u64) { let v = self.map.entry(k).or_insert(1); i_can_do_what_the_fuck_ever_i_want_with_mut(v); } } 🦀神跌下神坛？ # 但是问题就在于我想把S通过FFI给出去，所以我写出了这样一段代码\n// 是的，我把S实例的裸指针给出去了 // 甚至图方便，没有把 // Box::into_raw(Box::new(s)) // 得到的 *mut S 转换为 *const S 再传递 #[no_mangle] unsafe extern \u0026#34;C\u0026#34; fn bar(s: *mut S) { if s.is_null() { do_sth(); } else { unsafe { (*s).foo() }; // 于是蟹神降下了神罚，这真的not safe } } 因为unsafe传出了裸指针之后，借用检查器已经不能对这部分代码负责了。 火速把HashMap换成DashMap之后，问题解决。\n真的泄露了吗？ # 像一些static变量，rust是不会回收内存的，而是等待OS直接在进程exit时收回。\nvalgrind可能会把这部分内存报告为leak，容易影响判断。所以有时候确实是sanitizer方便一些，加编译参数即可快速使用。\n","date":"2025 August 10 00:51:12","externalUrl":null,"permalink":"/posts/ffi-trap-3/","section":"Posts","summary":"","title":"被FFI做局了(3)","type":"posts"},{"content":" 被FFI做局了(1) —— #[repr(C)] # 因为实际上对C/C++的了解可能还没对jvav的了解深，所以在相关基础知识上踩了不少坑，特别是这些坑不是不知道，单纯是没死磕过八股，不会看到对应的情况就条件反射开始吟诵，大多数是忘了有这么一出。\n而且rust写多了，能编译通过就能跑的观念已经变成思想钢印了，写点别的难免有惯性。\nFFI # 根据需求，我们要把已有的Rust代码套一层皮，然后做成C++ SDK给上游使用。 上游给出的接口有一个A和一个foo。\n// api.h extern \u0026#34;C\u0026#34; { struct A { uint32_t x; uint64_t y; uint32_t z; }; int32_t foo(A\u0026amp; a); } 我们实现一下Rust函数，调用一下我们的业务逻辑。\n#[repr(C)] #[derive(Debug, Serialize)] struct A { x: u32, y: u64, z: u32, } pub unsafe extern \u0026#34;C\u0026#34; fn foo(a: \u0026amp;A) -\u0026gt; i32 { let bytes: Vec\u0026lt;u8\u0026gt; = serde_json::to_vec(a).unwrap(); if rust_logic(bytes).is_err() { return -1; } 0 } 不赖，编译完测一下跑通了，万事大吉。 但是上游加字段了。\nstruct A { uint32_t x; uint32_t x2; // new uint64_t y; uint32_t z; }; 这扯不扯，但是你都叫我哥们了，那我就加上吧。 x2名字比较长，加后面比较好看，而且主打一个先来后到，反正没改api.h，又不是不能用。\n#[repr(C)] #[derive(Debug, Serialize)] struct A { x: u32, y: u64, z: u32, x2: u32, // new } 编译完，懒得跑了，反正业务逻辑没变。给上游，core~\n这扯不扯，啥没改编译都过了咋就core了呢？\n内存布局 # Rust # 熟悉rust的都比较清楚，一般的情况下，rust会自动对结构体的字段进行排序来进行对齐。所以没事干就按名字长度啥的排一排字段顺序。 例如以下两种定义实际上是等效的内存布局\n// size = 32 (0x20), align = 0x8 struct RA { x: u32, y: String, z: u32, } // 24 + 4 + 4 struct RB { y: String, x: u32, z: u32, } 其中 String 的size是24B，在Rust中String实际上是封装了一层Vec\u0026lt;u8\u0026gt;，而Vec\u0026lt;T\u0026gt;简单来说包含了三部分\nstruct SimpleVec\u0026lt;T\u0026gt; { ptr: *const T, capacity: usize, length: usize, } 指针的大小是usize，后两者的类型也是usize，在64位系统上长度都是8B。\n自动优化后，两个u32被紧凑的排列在了一起，使得实际布局类似于（实际上并不一定是）struct B呈现的顺序。\n更详细的信息可以阅读 https://rustcc.cn/article?id=98adb067-30c8-4ce9-a4df-bfa5b6122c2e\nC # C语言的内存布局是比较简单的，结构体的字段按照定义的顺序排列，大小和对齐方式由字段类型决定。 例如以下两种定义的内存布局并不等效:\n// size = 24 (0x18), align = 0x8 // 4(+4) + 1(+7) + 4(+4) typedef struct CA { uint32_t x; const char *y; uint32_t z; } CA; // size = 16 (0x10), align = 0x8 // 1(+7) + 4 + 4 typedef struct CB { const char *y; uint32_t x; uint32_t z; } CB; 可以精确控制，大道至简。\n#[repr(C)] # 那我问你，你的struct A脑袋怎么有个尖尖的#[repr(C)]，怎么就给忘了呢？\n既然按C的布局来，就得保证字段顺序也得一样。\n#[repr(C)] #[derive(Debug, Serialize)] struct A { x: u32, x2: u32, // same place y: u64, z: u32, } The interaction of repr(C) with Rust\u0026rsquo;s more exotic data layout features must be kept in mind. Due to its dual purpose as \u0026ldquo;for FFI\u0026rdquo; and \u0026ldquo;for layout control\u0026rdquo;\n欸我++怎么rust这么坏，专坑没写过C的门外汉。你这么大一个FFI章节和repr(C)也没写C不会自动排序对齐啊，偷偷说个for layout control糊弄大学生说是。\n🤦‍ 被rust的FFI做局了。\n","date":"2025 July 15 20:01:47","externalUrl":null,"permalink":"/posts/ffi-trap-1/","section":"Posts","summary":"","title":"被FFI做局了(1)","type":"posts"}]