7.2 配套代码:减少工作的五种模式
对应小节:7.2 减少工作 每种模式给「反例 → 正例 → 验证方法」,以及一份可自动扫描的审查清单。
一、❶ 批处理:N 次变 1 次
// ❌ 反例:N 次往返
suspend fun loadOrdersBad(ids: List<Long>): List<Order> =
ids.map { repo.findById(it) ?: error("not found") } // N 次查询
// ✅ 正例 1:批量查询(= any)
suspend fun loadOrdersGood(ids: List<Long>): List<Order> = withContext(Dispatchers.IO) {
ds.connection.use { c ->
c.prepareStatement("select id, user_id, amount from orders where id = any(?)").use { ps ->
ps.setArray(1, c.createArrayOf("bigint", ids.toTypedArray()))
ps.executeQuery().use { rs ->
buildList { while (rs.next()) add(rs.toOrder()) }
}
}
}
}
// ✅ 正例 2:分批(避免单次 IN 列表过长,也便于控制内存)
suspend fun loadOrdersBatched(ids: List<Long>, batchSize: Int = 500): List<Order> =
ids.chunked(batchSize).flatMap { chunk -> loadOrdersGood(chunk) }
// ✅ 正例 3:并行分批(IO 密集,且下游能承受)
suspend fun loadOrdersParallel(ids: List<Long>, batchSize: Int = 500): List<Order> =
coroutineScope {
ids.chunked(batchSize)
.map { chunk -> async(Dispatchers.IO) { loadOrdersGood(chunk) } }
.awaitAll()
.flatten()
}
验证方法:
// 统计"每个请求执行了多少次查询"
val before = queryCounter.get()
loadOrdersBad(ids)
val badQueries = queryCounter.get() - before // ≈ ids.size
queryCounter.reset()
loadOrdersGood(ids)
val goodQueries = queryCounter.get() // = 1
二、❷ 消除 N+1
// ❌ 反例:典型的 N+1(循环里查关联对象)
suspend fun listOrdersBad(userId: Long): List<OrderView> {
val orders = orderRepo.findByUserId(userId) // 1 次
return orders.map { order ->
val user = userRepo.findById(order.userId) // N 次!
OrderView(order.id, user.name, order.amount)
}
}
// ✅ 正例 1:批量预取 + 内存组装
suspend fun listOrdersGood(userId: Long): List<OrderView> {
val orders = orderRepo.findByUserId(userId) // 1 次
val userIds = orders.map { it.userId }.distinct()
val users = userRepo.findByIds(userIds).associateBy { it.id } // 1 次
return orders.map { OrderView(it.id, users[it.userId]?.name ?: "?", it.amount) }
}
// ✅ 正例 2:JOIN(同一个库时最省)
suspend fun listOrdersJoin(userId: Long): List<OrderView> = withContext(Dispatchers.IO) {
ds.connection.use { c ->
c.prepareStatement("""
select o.id, o.amount, u.name
from orders o join users u on u.id = o.user_id
where o.user_id = ?
""".trimIndent()).use { ps ->
ps.setLong(1, userId)
ps.executeQuery().use { rs ->
buildList { while (rs.next()) add(OrderView(rs.getLong(1), rs.getString(3), rs.getLong(2))) }
}
}
}
}
验证:pg_stat_statements 里那条子查询的 calls 应该从「与 QPS 成正比」变成「与 QPS 同阶」。
三、❸ 提前剪枝
// ❌ 反例 1:先算再判断
suspend fun reportBad(user: User, data: Data): Report {
val full = generateFullReport(data) // 很贵(800ms)
return if (user.isAdmin) full else full.summary()
}
// ✅ 正例 1:先判断
suspend fun reportGood(user: User, data: Data): Report =
if (user.isAdmin) generateFullReport(data)
else generateSummary(data) // 便宜(50ms)
// ❌ 反例 2:先加载数据再鉴权(安全问题 + 性能问题)
suspend fun getOrderBad(userId: Long, orderId: Long): Order {
val order = orderRepo.findById(orderId) ?: throw NotFound() // 先加载(贵)
if (order.userId != userId) throw Forbidden() // 后鉴权
return order
}
// ✅ 正例 2:先鉴权(既安全又快)
suspend fun getOrderGood(userId: Long, orderId: Long): Order {
// 轻量查询:只取 owner,或者直接按 (id, userId) 联合查询
val order = orderRepo.findByIdAndUserId(orderId, userId)
?: throw NotFoundOrForbidden() // 不泄露"存在但无权限"
return order // 只有鉴权通过才加载完整对象
}
// ❌ 反例 3:空集合也去查询
suspend fun loadBad(ids: List<Long>) = repo.findByIds(ids) // ids 为空时白跑一次
// ✅ 正例 3:先判断
suspend fun loadGood(ids: List<Long>) =
if (ids.isEmpty()) emptyMap() else repo.findByIds(ids)
验证方法:
// 用一个"昂贵的下游"测量:无权限的请求是否还调用它
val called = AtomicBoolean(false)
val mockRepo = object : OrderRepo {
override suspend fun findById(id: Long): Order {
called.set(true) // 标记"昂贵调用发生了"
return heavyLoad(id)
}
}
getOrderGood(nonOwnerId, orderId)
assert(!called.get()) { "鉴权失败时不应加载数据" } // ✅ 应该通过
四、❹ 避免重复序列化
// ❌ 反例 1:对象 → JSON → Map → JSON
fun processBad(order: Order, mapper: ObjectMapper): String {
val json = mapper.writeValueAsString(order) // 序列化 1
val map = mapper.readValue(json, Map::class.java) // 反序列化
map.remove("secret")
return mapper.writeValueAsString(map) // 序列化 2
}
// ✅ 正例 1:直接在强类型对象上处理
fun processGood(order: Order, mapper: ObjectMapper): String =
mapper.writeValueAsString(order.copy(secret = null)) // 序列化 1
// ❌ 反例 2:同一份数据序列化多次(比如打日志 + 返回)
fun handleBad(order: Order): Response {
val json = mapper.writeValueAsString(order)
log.info("response: {}", json) // 序列化 1(且打日志)
return Response(json) // 复用
}
// ✅ 正例 2:日志用参数化(不拼接),或者只在需要时序列化
fun handleGood(order: Order): Response {
if (log.isDebugEnabled) log.debug("order id={}", order.id) // 不序列化
return Response(mapper.writeValueAsString(order)) // 序列化 1(只此一次)
}
// ❌ 反例 3:服务间透传时反复转换
fun forwardBad(payload: ByteArray): ByteArray {
val obj = mapper.readValue(payload) // 反序列化
obj.metadata = enrich(obj) // 处理
return mapper.writeValueAsBytes(obj) // 再序列化
}
// ✅ 正例 3:如果只是透传,用二进制流(或明确只解析需要的字段)
五、❺ 减少系统调用
// ❌ 反例 1:热路径同步日志
get("/orders/{id}") {
log.info("handling order id={}", call.parameters["id"]) // 每次请求都写磁盘
// ...
}
// ✅ 正例 1:异步 appender(配置层面)+ 降低级别
// logback.xml: <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
// <neverBlock>true</neverBlock>
// ❌ 反例 2:逐条 flush
fun writeBad(records: List<Record>) {
records.forEach { r ->
file.write(r.toString())
file.flush() // 每次都 flush!
}
}
// ✅ 正例 2:批量写 + 一次 flush
fun writeGood(records: List<Record>) {
val sb = StringBuilder()
records.forEach { sb.append(it).append('\n') }
file.write(sb.toString())
file.flush() // 只 flush 一次
}
// ❌ 反例 3:每个请求建连
suspend fun queryBad(sql: String) = DriverManager.getConnection(URL).use { /* ... */ }
// ✅ 正例 3:用连接池(第 7.6 节)
suspend fun queryGood(sql: String) = withContext(Dispatchers.IO) {
dataSource.connection.use { /* ... */ }
}
六、审查清单脚本
# tools/review-reduce-work.py <SRC_DIR>
"""
扫描代码,检出「可能有减少工作空间」的模式。
注意:这是启发式扫描,会误报——它的价值是提醒你去检查。
"""
import pathlib
import re
import sys
PATTERNS = [
("❶ N+1 嫌疑:循环里的单条查询", r"\.(map|forEach)\s*\{[^}]*\.(findById|queryOne|selectOne|get)\(",
"改成批量查询(where id = any(?))"),
("❶ 循环里的数据库调用", r"for\s*\([^)]*\)\s*\{[^}]*\.(query|execute|find)",
"考虑批处理"),
("❸ 空集合检查缺失", r"fun\s+\w+\(ids:\s*List<[^>]+>\)[^{]*\{\s*\n\s*(?!.*isEmpty)[^\n]*",
"先判断 ids.isEmpty() 再查询"),
("❹ 重复序列化嫌疑", r"writeValueAsString\([^)]*\)[\s\S]{0,200}?readValue",
"避免 JSON → Map → JSON 的来回转换"),
("❺ 热路径日志", r"(get|post)\s*\(\s*\"[^\"]+\"\s*\)\s*\{[\s\S]{0,200}?log\.(info|debug)",
"降低级别或用异步 appender"),
("❺ 逐条 flush", r"\.(write|append)\([^)]*\)[\s\S]{0,50}?\.flush\(\)",
"改成批量写 + 一次 flush"),
("正则重复编译", r"Regex\s*\(\s*\"\"\"",
"检查是否应该提到顶层常量"),
("重复查询同一数据", r"(\w+Repo|\w+Repository)\.(findById|get)\([^)]*\)[\s\S]{0,500}?\2\.\1",
"同一请求内重复查询 → 缓存到局部变量"),
]
def scan_file(path):
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except Exception:
return []
hits = []
for label, pattern, advice in PATTERNS:
for m in re.finditer(pattern, text, re.MULTILINE):
# 找行号
line_no = text[:m.start()].count("\n") + 1
hits.append((label, line_no, advice))
return hits
def main(src_dir):
root = pathlib.Path(src_dir)
files = list(root.rglob("*.kt")) + list(root.rglob("*.java"))
total = 0
print("═" * 84)
print(f"「减少工作」审查:{src_dir}")
print("═" * 84)
print()
for f in files:
hits = scan_file(f)
if hits:
total += len(hits)
print(f"📄 {f.relative_to(root)}")
for label, line_no, advice in hits:
print(f" L{line_no:<5} {label}")
print(f" → {advice}")
print()
print("═" * 84)
if total == 0:
print("✅ 未检测到明显模式(但这不代表没有问题——启发式扫描有盲区)")
else:
print(f"⚠️ 检出 {total} 处待检查点")
print()
print("提醒:")
print(" ① 这些是「嫌疑」不是「问题」——需要人工确认")
print(" ② 优先检查 N+1 与循环里的数据库调用(收益最大)")
print(" ③ 用 queryCounter 之类的指标验证,而不是靠看代码")
print("═" * 84)
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "src/main/kotlin")
七、动手改造
| 改动 | 观察什么 |
|---|---|
对 Lab 6 的 /slow-db 用 review-reduce-work.py 扫描 |
能检出 N+1 嫌疑 |
用 queryCounter 量化优化前后的查询次数 |
得到最直接的证据 |
给 /slow-cpu 的 Regex 做「提到顶层」的改动 |
用第 6.7 节的 RegexDemo 验证收益 |
| 把「先鉴权再加载」应用到你的真实接口 | 既提升性能又加固安全 |
| 在真实项目上跑审查脚本 | 看有多少误报(理解启发式扫描的局限) |
八、这段代码的局限
review-reduce-work.py是正则扫描:会大量误报(比如map { ... findById(...) }可能是在批量处理,也可能真的有 N+1);也会漏报(跨文件、跨方法的模式看不出来)。- 收益需要用指标验证:脚本只能提示"去看看",真正的判断要靠
queryCounter、pg_stat_statements、火焰图这些工具。 - 批处理有前提:
where id = any(?)在 ID 数量很大时可能变慢(IN 列表过长);需要分批(chunked)。 - 「先鉴权」要注意错误语义:要避免"404 vs 403"泄露资源是否存在(示例里用了
NotFoundOrForbidden)。