某耀员工:同事都快40了,加个班疯狂3条朋友圈,不愧是优秀的演员!
给我看笑了。某耀员工吐槽同事快40了,加个班连发三条朋友圈,灯光、电脑、咖啡杯都安排上了,那个氛围感,差点以为在拍职场纪录片。
领导看见估计还挺欣慰,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 懒着,就别把它提前变成列表。