Python技术迷

某耀员工:同事都快40了,加个班疯狂3条朋友圈,不愧是优秀的演员!

给我看笑了。某耀员工吐槽同事快40了,加个班连发三条朋友圈,灯光、电脑、咖啡杯都安排上了,那个氛围感,差点以为在拍职场纪录片。

Image

领导看见估计还挺欣慰,HR看完也得沉默两秒:这员工情绪价值给得是真足。

但你说他傻吧,也不一定。职场里有时候干活是一回事,被看见又是另一回事。只是快40了还玩这套,多少有点辛酸,也有点好笑。

朋友圈一发,班没少加,戏也没少演。大家嘴上不说,心里估计都在默默围观。

算法题:Range 模块

批量补数据的脚本刚跑起来,机器内存就开始往上顶。

我第一眼看代码,问题不在数据库,也不在线程池,就在这一行:

ids = list(range(1, 50000001))

这行代码看着很普通,甚至很多人写 Python 第一天就这么写。但在线上脚本里,这种写法我一般直接判死刑。

range 不是模块,严格说它是 Python 里的一个内置类型。只是很多人习惯把它叫 Range 模块。名字不重要,坑才重要。

range(1, 50000001) 本身不吓人,它不会真的把 5000 万个数字全塞进内存。真正吓人的是你外面套了一个 list()。

r = range(1, 50000001)

print(type(r))
print(r[0])
print(r[-1])
print(len(r))

这段没问题。

range 保存的不是一堆数字,而是三个东西:起点、终点、步长。

大概就是:

start = 1
stop  = 50000001
step  = 1

你要第几个,它现算。

所以它适合干一类活:顺序扫、分批跑、生成编号、控制循环次数。

但它不适合一上来就转成列表。

我平时写补数脚本,基本不会这么干:

order_ids = list(range(begin_id, end_id + 1))

for order_id in order_ids:
    repair_order(order_id)

这种代码开发机看不出问题,数据量一大就很难看。

我更愿意写成这样:

defwalk_order_id(begin_id: int, end_id: int):
for order_id in range(begin_id, end_id + 1):
yield order_id


for oid in walk_order_id(100000, 900000):
    repair_order_snapshot(oid)

这里 yield 不是为了炫技,就是别提前占内存。

还有一种更常见的场景,分批查数据库。

defsplit_range(begin_id: int, end_id: int, size: int = 500):
    cursor = begin_id
while cursor <= end_id:
        right = min(cursor + size - 1, end_id)
yield cursor, right
        cursor = right + 1


for left, right in split_range(100000, 180000, 1000):
    rows = query_orders(left, right)
    sync_to_report_table(rows)

我喜欢这个写法,比 for i in range(0, len(data), 1000) 更像业务代码。日志也好打。

for left, right in split_range(100000, 180000, 1000):
    print(f"[repair] order_id from {left} to {right}")
    rows = query_orders(left, right)
    sync_to_report_table(rows)

线上排脚本问题时,看到这种日志,心里会稳一点。

range 还有个地方容易写错,右边界不包含。

for i in range(1, 5):
    print(i)

打印的是:

1
2
3
4

不包含 5。

这个规则不复杂,但在分页、批量 ID、日期偏移里特别容易多一条少一条。

比如我要清理 30 天前到 7 天前的数据,很多人会写:

for day in range(30, 7):
    clean_expired(day)

这段一行都不会执行。

因为默认步长是 1,从 30 走不到 7。

得写成:

for day in range(30, 6, -1):
    clean_expired(day)

这里第三个参数 -1 才是关键。

步长这东西,别觉得只会用在倒序循环。做抽样检查也挺顺手。

比如日志文件太大,不想每一行都验,只想每 1000 行取一行看看格式有没有脏数据:

defsample_bad_lines(path: str, step: int = 1000):
with open(path, "r", encoding="utf-8") as f:
for line_no, line in enumerate(f, 1):
if line_no notin range(1, 10**12, step):
continue

if"|"notin line:
                print(f"[bad-line] line={line_no}, text={line.strip()[:80]}")

这段代码能跑,不过我不会这么写。

因为 line_no in range(...) 虽然能判断,但在这种地方不够直观。我一般改成取模:

defsample_bad_lines(path: str, step: int = 1000):
with open(path, "r", encoding="utf-8") as f:
for line_no, line in enumerate(f, 1):
if line_no % step != 0:
continue

if"|"notin line:
                print(f"[bad-line] line={line_no}, text={line.strip()[:80]}")

不是所有地方都要强行用 range。

工具是工具,别上头。

range 支持下标和切片,这点有时候挺香。

batch_no = range(1000, 2000, 10)

print(batch_no[0])     # 1000
print(batch_no[3])     # 1030
print(batch_no[-1])    # 1990
print(batch_no[2:5])   # range(1020, 1050, 10)

注意,切片以后还是 range,不是列表。

这个设计我挺喜欢,不乱占内存。

不过也有一个细节:range 里的值必须是整数。

range(1.0, 10.0)

会直接报错。

如果业务里真要按小数递增,比如金额、比例、阈值,不要拿 range 硬搞。用整数转一下更靠谱。

for rate in range(5, 31, 5):
    discount = rate / 100
    rebuild_price_rule(discount)

这样比浮点一路加上去稳。浮点数那点误差,在线上对账里很烦。

最后说个我见过的低级坑。

有人这么写:

for i in range(len(records)):
    save(records[i])

能跑,但没必要。

直接写:

for record in records:
    save(record)

如果确实需要下标,比如打印第几条失败,再用 enumerate:

for idx, record in enumerate(records, 1):
try:
        save(record)
except Exception as e:
        print(f"[save-failed] idx={idx}, order_id={record.get('order_id')}, err={e}")

这比 range(len(records)) 看着舒服,也少一层下标访问。

range 这个东西不大,但很能看出代码习惯。

小脚本里随便写,问题不明显。到了批量导入、日志清洗、数据修复这种场景,list(range(...))、边界少一位、倒序忘记步长,都会变成很别扭的线上问题。

我的习惯就一句:能让 range 懒着,就别把它提前变成列表。