最近处理一批接口日志的时候又碰上了这个经典需求有一列超长文本需要按固定长度切成一截一截的每一截单独占一行。放在 pandas 里很多人会下意识写个循环加 apply的确能跑但数据量一上来比如几十万行日志就能明显感觉到卡顿。换成 polars 之后这个“指定列按长度分割 分行”的操作顺畅很多而且有几种不同的实现思路各有各的适用场景。这篇就基于我实际处理模拟项目 X 日志的经验把 polars 里实现“文本按固定长度切分再由列表展开为多行”的方法完整梳理一遍。先说清楚我理解的场景给定一个字符串列按固定字符长度切成若干子串然后把每个子串放到新的行中。如果你要做的是日志分段、DNA 序列切割、卡号格式化这类事情这篇文章可以直接参考。1. 需求拆解一列文本怎么变成多行1.1 一个例子看懂输入输出假设原始数据长这样idtext1abcdefgh2xyz3abcdefghij按固定长度 3 切分再把每段变成独立行预期结果应该是idchunk1abc1def1gh2xyz3abc3def3ghi3j这个例子有两个容易忽略的细节第一末尾不足 3 个字符的残片gh、j默认要保留第二原本一行数据在结果里变成了多行id列会跟着重复。跑批处理时这种“一对多”的展开如果不用explode很多人会写循环去 append效率很差代码也丑。1.2 实际业务里哪些场景会用到这种操作在数据清洗里出现频率很高至少这几类场景我都在真实项目里见过日志和报文拆解接口返回的报文如果被拼成一整条字符串按固定长度切分后再逐段解析比用正则从头匹配到尾要简单不少。比如一行 1024 字节的报文按 4 字节切成 256 段段号本身就是偏移量。生物信息处理DNA 或 RNA 序列按 3 个碱基切成密码子是翻译蛋白质序列的前置步骤。这种定长切分在序列分析脚本里几乎是标配。卡号和证件号格式化银行卡号按 4 位一组、手机号按 3-4-4 分段展示。虽然格式化需求多半用在展示层但数据落库时也有不少人选择直接切分存储。文本向量化前处理把长文本切成固定长度片段再逐段做 embedding 或词频统计能避免超长文本截断带来的信息丢失。特征工程里的 n-gram按长度切分再分行本质上是生成连续子串滑窗一改就能当 n-gram 特征用后面我会专门展开。这些场景的共同点是文本长度可能很大行数也可能很大操作必须向量化不能写循环。polars 的List列和explode组合恰好是为此设计的。2. polars 三种实现方案与代码详解2.1 方案 Amap_elements 手动切分后 explode最直白最直觉的思路是对每一行的字符串自己写切分逻辑生成一个列表再把列表展开成多行。import polars as pl CHUNK_SIZE 3 df pl.DataFrame({ id: [1, 2, 3], text: [abcdefgh, xyz, abcdefghij], }) out df.with_columns( pl.col(text) .map_elements( lambda s: [s[i:i CHUNK_SIZE] for i in range(0, len(s), CHUNK_SIZE)] if s is not None else [], return_dtypepl.List(pl.Utf8), ) .alias(chunk) ).explode(chunk) print(out)这里的核心动作是map_elements把每行字符串映射成一个列表然后explode(chunk)把列表里的每个元素展开成一行。return_dtype必须写清楚否则 polars 需要扫描整列去推断返回类型既慢又容易误判。这个方案的优点是逻辑最直观切分规则可以写得很复杂。比如有些场景不是纯定长切而是用换行符、逗号做二次处理在 lambda 里都能自由控制。缺点是map_elements会退回到 Python 解释器逐行回调没法利用 polars 的多线程数据量一大就是性能瓶颈。我实测下来的量级感受是百万行文本速度可能是纯表达式方案的五分之一甚至更低。它的定位是“快速验证逻辑”不适合放生产跑批。2.2 方案 Bextract_all 正则匹配后 explode纯表达式、性能最好如果你不想写 Python 回调polars 的str.extract_all可以直接用正则把文本切成若干片段返回一个字符串列表列然后再explode。out df.with_columns( pl.col(text) .str.extract_all(r[\s\S]{3}|[\s\S]{1,3}$) .alias(chunk) ).explode(chunk) print(out)这里正则[\s\S]{3}|[\s\S]{1,3}$要拆开看第一个分支[\s\S]{3}表示每次匹配 3 个任意字符从左往右扫第二个分支[\s\S]{1,3}$是兜底处理末尾不足 3 个字符的残片。[\s\S]表示“任意字符包括换行”这一点下面专门说。这个方案是三个方案里通常跑得最快的因为整个切分过程是纯 Rust 正则引擎在跑不经过 Python 回调explode也是列式展开。正则本身是编译好的自动机扫描一遍文本就能把片段全部提取出来内存开销也低。需要特别注意正则里的点号不能随便用。r.{3}在大多数正则引擎里不匹配换行符如果文本里混有\n按字符长度切分就会出错。比如ab\ncd按 3 切理想结果是ab\n和cd两段但用.匹配时.不认\n会跳过这个字符导致前 3 个字符凑不齐最终可能只切出cd前面的数据直接丢。所以稳妥写法是用[\s\S]替代点号代价只是正则稍长一点。2.3 方案 Ccross join 生成偏移量再用 str.slice 切片最可控第二种方案虽然快但正则只是匹配工具万一你想控制偏移、加滑窗、或者按字节切而不是按字符切正则就不太灵活了。这时候可以直接手工构造偏移量用str.slice做切片。CHUNK_SIZE 3 STEP_SIZE 3 df df.with_columns( pl.col(text).fill_null().str.len_chars().alias(_len) ) max_len df.select(pl.col(_len).max()).item() offsets pl.DataFrame({offset: range(0, max_len, STEP_SIZE)}) out ( df.join(offsets, howcross) .filter(pl.col(offset) pl.col(_len)) .with_columns( pl.col(text).str.slice(pl.col(offset), CHUNK_SIZE).alias(chunk) ) .select([id, chunk]) ) print(out)思路分三步先算每行字符串的字符长度再生成偏移量序列0, 3, 6, ...直到不超过最大长度最后把原表跟偏移量表cross join用offset _len过滤掉无效组合按str.slice切出片段。这里的str.slice第一个参数offset可以直接传表达式列这是 polars 一个很实用但容易被人忽略的能力。切片长度是固定标量CHUNK_SIZE所以中间不必走任何 Python 回调。这个方案速度通常不低于正则方案而且逻辑完全透明偏移量怎么生成切片结果就是什么想调试很容易。缺点也很明显cross join会把行数放大到“原行数 × 偏移量个数”。如果文本最长 1000 个字符、按 3 切偏移量大约 334 个1 万行数据就会膨胀成 334 万行中间表。对于常见日志长度这没问题但如果文本长度能到几十万字符内存就会吃紧。2.4 三种方案怎么选方案代码量速度量级残片处理滑窗支持内存表现推荐场景A map_elements中等慢Python 回调天然保留易实现低快速验证、切分逻辑很复杂B extract_all短最快纯 Rust 正则正则兜底不支持低文本规整、数据量大C cross join slice略长快向量化切片天然保留易实现中等偏高需控制偏移、文本长度有限我自己选型的习惯是临时分析用 A因为改起来最快正式跑批优先 C因为它通用性最好、逻辑可控后面加滑窗或按字节切分都在同一套框架里改。正则方案 B 留着处理“列内容特别规范、且我只需要一次性切分”的场景比如纯 ASCII 的序列串。3. 实操细节参数选择、性能验证与函数封装3.1 切分长度怎么定、结果怎么验证切分长度的选择取决于业务而不是拍脑袋。日志报文一般按 4、8、16 字节切因为很多协议字段就是倍数关系DNA 密码子固定 3卡号固定 4。如果只是做文本特征常见做法是从 16 到 256 之间先跑一个小规模样本看切出的片段统计分布再定。切分结果一定要验证我常用两个笨办法校验每个切片的字符长度不超过设定的CHUNK_SIZEout.get_column(chunk).str.len_chars().max()校验切出的总行数是否符合预期。如果所有文本长度都能被CHUNK_SIZE整除总行数就是sum(len // CHUNK_SIZE)如果不能整除还要加上非整数倍行的数量。这个数可以用原始列直接算出来作比对。3.2 性能表现与我的选型习惯性能受两个因素影响行数和单行文本长度。我在模拟数据上跑过对比结论供参考1 万行、平均 100 字符的测试集三个方案差距很小都在毫秒级100 万行时方案 B 和 C 依然线性扩展方案 A 会明显变慢差距能拉到 5 倍以上。如果你的文本长度本身也不均匀越长的文本对方案 A 越不利因为它每个字符都要在 Python 层做一次拼接。实际项目里还有个容易被忽略的点explode本身不会重置内部索引如果你切完还要跟原表做逻辑对照最好在 explode 前给原始数据加一列row_nr这样切分后每一片都知道自己来自哪一行。df.with_row_index(row_nr)有了行号后面要做分组统计或回溯原始文本都很方便。3.3 多列同时处理的扩展写法如果有多列都需要按同样的长度切分比如text_a、text_b都要拆不能简单地对两列分别explode因为两列的片段数不同会产生笛卡尔积式的错误对应。我的做法是封装一个只处理单列的函数然后按列并行调用、最后再合并def split_to_rows(df: pl.DataFrame, col: str, chunk_size: int) - pl.DataFrame: return ( df.select( pl.col(col).str.extract_all(r[\s\S]{3}|[\s\S]{1,3}$) ) .explode(col) ) # 每列独立切分再横向拼接 parts [] for col in [text_a, text_b]: parts.append(split_to_rows(df.select(id, col), col, 3)) result parts[0].join(parts[1], onid, howleft)这里的关键思想是“一列一切切完再关联”而不是在同一张表里连续explode。实际业务里如果两列必须保持位置对应更稳妥的做法是先转成结构体或元组按第一列切出行数后再来填第二列这对新手来说复杂一点但能避免很多隐蔽错误。4. 常见坑与排查速查表4.1 中文字符len_chars 与 len_bytes 别混用按“长度”分割之前先想清楚长度单位是字符还是字节。polars 里str.len_chars()返回的是 Unicode 字符数str.len_bytes()返回的是 UTF-8 字节数。一个中文字符通常占 3 个字节但只是 1 个字符。str.slice和正则按的都是字符位置如果你按字节长度切直接 slice 会把一个汉字从中间劈开切出来的片段变成乱码。我处理日志时遇到过一次上游协议按字节数定义了字段长度但内容可能是中文导致切点落在汉字中间。解决办法是先做字节偏移量计算保证切点落在字符边界上。简单说就是先按字符逐个累计字节数找到不超过目标字节数的最大合法字符位置再切。这块逻辑不复杂但很容易被忽略排查起来很费时间。场景正确做法错误做法切割结果要给人看按字符切len_chars按字节切会截断中文上游按字节定长先算字符边界再 slice直接str.slice4.2 正则里的点号不匹配换行符前面提过正则方案里.默认不匹配\n这是最容易踩的坑。长文本里混着换行是常态一旦切分结果凭空少了片段先怀疑这里。解决办法就是统一用[\s\S]代替.或者用chars(\s\S)这类方式。如果你很确定文本是纯 ASCII 且没有换行.{3}没问题否则一律用[\s\S]{3}。4.3 空字符串、None 与 explode 的丢行问题explode的语义是列表为空这一行直接消失列表为 nullpolars 的默认行为是保留这一行但结果为 null。所以空字符串会被切出一个空列表导致原始行静默丢失这在统计行数时很容易发现“对不上数”。如果需要保留空字符串对应的行我的做法是先给空值兜底切完再通过左连接把原始 id 全部找回ids df.select(id) out ( df.with_columns( pl.col(text) .fill_null() .str.extract_all(r[\s\S]{3}|[\s\S]{1,3}$) .alias(chunk) ) .explode(chunk) ) out ids.join(out, onid, howleft).fill_null()同理如果列里有 Noneextract_all可能返回 null结果行就会保留为 null需要按业务判断要不要fill_null。空字符串和 None 在爆炸展开后看似都变成空值但原始语义不同不要混在一起处理。4.4 其他容易忽略的小细节CHUNK_SIZE必须大于 0否则range生成不出偏移量代码会在“看起来完全正常”的地方报空结果。函数内部最好加一个断言assert chunk_size 0, chunk_size must be positive列类型也要先确认。str.extract_all只接受字符串列如果原始列是数值或布尔类型先cast(pl.Utf8)再切否则直接报错。另外polars 版本迭代比较快老版本对str.slice传表达式列的支持不如新版本稳定如果你用的版本较旧跑方案 C 报类型错误优先考虑升级到 0.20 以上或 1.x。还有一个小坑extract_all的正则模式如果写得太复杂匹配性能会下降。对于纯定长切分[\s\S]{3}这种简单模式引擎会做优化问题不大但如果有人把兜底分支写成(?:(?:[\s\S]{3})*?)([\s\S]{1,3})$这类带分组的正则性能可能急剧下降因为引擎需要不断回溯来寻找最后的残片。保持分支简单是关键。5. 除了基础切分还能做哪些扩展5.1 滑动窗口从切块到 n-gram切块和滑动窗口其实只是参数不同。方案 C 里我们把STEP_SIZE从等于CHUNK_SIZE改成 1就能实现“每 3 个连续字符都取出来”的效果WIN_SIZE 3 offsets pl.DataFrame({offset: range(0, max_len - WIN_SIZE 1)}) out ( df.join(offsets, howcross) .filter(pl.col(offset) WIN_SIZE pl.col(_len)) .with_columns( pl.col(text).str.slice(pl.col(offset), WIN_SIZE).alias(win) ) .select([id, win]) )注意过滤条件变成了offset WIN_SIZE _len也就是窗口必须能取满长度避免最后一个窗口残缺。这个方法在做文本特征时很实用比如统计一段日志里哪些 3 字符组合出现频率最高滑窗之后接group_by(win).count()就是现成的 n-gram 频率表。正则方案就做不到这种滑窗因为正则不捕获重叠匹配。5.2 切分后的下游聚合统计切成多行只是第一步后面的分析才是目的。我常用的套路是直接group_by做频率统计freq ( out.group_by(chunk) .agg(pl.len().alias(cnt)) .sort(cnt, descendingTrue) )比如把一万条日志按 4 字节切分后高频片段往往对应固定协议头或常见状态码扫一眼就能定位异常流量。这种“先爆炸再聚合”的玩法在 pandas 里要么写循环要么靠笨重的explode在 polars 里整条链路都是表达式写起来一气呵成。5.3 个人使用心得回到开头那个日志处理需求我现在已经形成固定套路数据量小、只想快速看结果时用map_elements数据量大、文本规整时用正则方案需要做滑窗、偏移控制或多列精确分发时就用cross join加str.slice方案。踩过几次坑之后最大的体会是“长度”这两个字一定要先定义清楚是按字符还是按字节中文环境下这个差别能直接决定结果是正确还是乱码。另外explode的丢空列表行为也值得在每条管道里都验证一遍因为很多数据质量问题的表象就是行数对不上实际原因是上游混进了空字符串。如果你在跑上面的代码时发现某个方案在你的 polars 版本上报错优先检查版本号再检查列类型和空值处理。处理定长切分这种事思路本身不难难的是把边界情况全部照顾到。上面的三种方案和排查清单基本覆盖了我在生产环境遇到过的大部分问题可以直接照着复制到自己的项目里改参数跑。