⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 http://www.iocoder.cn/Fight/JavaheLuadewanmeijieheshixianJavachengxudedongtaikuozhanhejiaobenzidongshengji/ 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

又一个动态规则需求来了

风控同学跑过来:「这个反欺诈规则要求改完不发版——你们看着办。」

国内业务这种场景一抓一把:

  • 风控规则:黑名单评分、组合策略,每周改三次;
  • 游戏服:副本逻辑、活动玩法、技能数值,运营要随时调;
  • API 网关:限流策略、改写规则、降级配置;
  • 报文解析:上下游接口字段映射,对接方一变就要改。

Java 后端的默认武器是 Groovy —— Spring 内置 GroovyClassLoader,不少团队的规则引擎跑在它上面。但 Groovy 不是唯一答案——国内游戏圈、网关圈、风控圈大量在用 Lua:OpenResty / Tengine / Skynet / 网易 Pomelo 都把 Lua 当默认嵌入脚本。

嵌入式脚本三国杀:JS / Groovy / Lua 的领地

JVM 里能跑的嵌入式脚本主要三种:

维度 Groovy Lua(LuaJ / JNLua) JavaScript(Nashorn / GraalJS)
JVM 互操作能力 强(JVM 语言,无缝调 Java) 中(要走 LuaValue 桥接) 强(GraalJS 原生支持)
启动开销 大(首次加载几百 ms) 小(毫秒级) 中(GraalJS 启动稍慢)
内存占用 中等 小(每个 Lua state 几 KB) 中等
沙盒能力 弱(默认能调任意 Java 类) 强(默认隔离,主动开放才能调 Java) 中等
学习曲线 低(写 Java 就会) 中(语法陌生) 低
生态领地 Java 业务规则引擎 / DSL 游戏服 / 网关 / Redis 脚本 / 风控 早期 Web 后端、规则引擎
代表用户 Drools、Spring Cloud Gateway OpenResty、Skynet、Redis、网易 Pomelo 历史用户多,新项目少

关键洞察:

  • Groovy 适合「JVM 内业务规则」——本质是 Java 的”动态版”,调 Java 类无缝;
  • Lua 适合「需要沙盒 + 高频 + 轻量」——游戏每秒跑几万次脚本不能让 GC 抖动,Lua 的 1KB 级 state 比 Groovy 的几 MB 强一个量级;
  • JavaScript 在 Nashorn 退役后剩下 GraalJS,前者已被官方移除(Java 15+),后者用得少。

国内最有代表性的两个 Lua 落地:Redis EVAL + OpenResty/Tengine(淘宝、京东、字节都用)——已经证明 Lua 在「轻量、沙盒、高频」场景里经过实战检验。

决策矩阵:什么场景用什么

按两个维度判断:

执行频率高(每秒千次以上) 执行频率低(按需触发)
需要复杂调 Java 类 Groovy ✅ Groovy ✅✅(甜区)
简单逻辑 / 主要是数值算 Lua ✅✅(甜区) Lua ✅ / Groovy ✅ 都可
需要沙盒(不能任意调系统类) Lua ✅✅ Lua ✅

直接结论:

  • 业务规则引擎 / 风控决策树 → Groovy(强调表达能力、需要调 Java 业务对象);
  • 游戏战斗逻辑 / 副本配置 / 活动玩法 → Lua(轻量、沙盒、高频);
  • 网关限流 / 路由规则 → Lua(已有 OpenResty 生态);
  • 报文解析 / DSL → Groovy(自定义语法表达力强);
  • 不可信用户脚本(多租户场景) → 必须 Lua(沙盒能力是底线)。

国内业务里最值得参考的两个分工模式:

公司 / 项目 实战用法
网易游戏 / 巨人游戏 服务端核心用 Java/C++,业务逻辑全 Lua
OpenResty 网关 Nginx C 层 + 业务全 Lua(运维不用改 C 代码)
蚂蚁 / 字节风控 决策树用 Groovy,单条规则的复杂数值用 Lua(混用)

LuaJ 接入:Java 调 Lua 的最小代码

LuaJ 是 Java 实现的 Lua 解释器(Lua 5.2 兼容),纯 JVM 实现不依赖 native,部署简单。

引依赖:

<dependency>
<groupId>org.luaj</groupId>
<artifactId>luaj-jse</artifactId>
<version>3.0.1</version>
</dependency>

最小可跑代码:

import org.luaj.vm2.*;
import org.luaj.vm2.lib.jse.*;

public class LuaDemo {
public static void main(String[] args) {
// 创建一个完整的 Lua 全局环境(含标准库)
LuaValue globals = JsePlatform.standardGlobals();

// 加载并执行 Lua 代码
LuaValue chunk = globals.load("print('Hello from Lua!')");
chunk.call();
}
}

JsePlatform.standardGlobals() 给的是「带完整标准库的环境」——io、os、debug 这些都开放。生产里不能这么干——下面会讲沙盒。

调用 Lua 函数 + 传参 + 拿返回值

Lua 脚本 script.lua:

function add(a, b)
return a + b
end

function risk_score(user)
local score = 100
if user.age < 18 then score = score - 30 end
if user.history_fraud then score = score - 50 end
return score
end

Java 端:

LuaValue globals = JsePlatform.standardGlobals();
globals.load(new InputStreamReader(scriptStream), "script.lua").call();

// 调用 add
LuaValue add = globals.get("add");
int result = add.call(LuaValue.valueOf(3), LuaValue.valueOf(5)).toint();

// 调用 risk_score(传 Lua table)
LuaValue user = LuaValue.tableOf();
user.set("age", 17);
user.set("history_fraud", LuaValue.TRUE);
int score = globals.get("risk_score").call(user).toint();

LuaValue 是 Java/Lua 类型互转的中转。业务代码里通常包一层工具类,避免到处写 LuaValue.valueOf。

Lua 调 Java:双向桥接

风控规则里经常需要在 Lua 里查 Redis、调 Java 业务方法——Lua 调 Java 是必需能力。

LuaJ 提供两种方式。

方式 A:注入 Java 对象到 Lua 全局环境

// Java 业务对象
public class RiskUtil {
public int redisCount(String key) { /* 查 Redis 计数 */ }
public boolean isBlacklist(String userId) { /* 黑名单 */ }
}

// 注入到 Lua
LuaValue globals = JsePlatform.standardGlobals();
globals.set("RiskUtil", CoerceJavaToLua.coerce(new RiskUtil()));

Lua 脚本里直接调用:

function check(userId)
if RiskUtil:isBlacklist(userId) then return 0 end
return RiskUtil:redisCount(userId .. ":fraud")
end

CoerceJavaToLua 是 LuaJ 的核心桥梁——把 Java 对象包装成 Lua 可调用的 LuaValue。

方式 B:注册 Java 函数为 Lua 全局函数

如果只想暴露一个具体方法(不是整个对象),用 OneArgFunction / TwoArgFunction:

globals.set("get_redis", new OneArgFunction() {
@Override
public LuaValue call(LuaValue arg) {
String key = arg.tojstring();
return LuaValue.valueOf(redisTemplate.opsForValue().get(key));
}
});

Lua 里:

local v = get_redis("user:82001:score")

这种方式更细粒度,常用于网关 / 风控的「白名单 API 暴露」——只让脚本调指定的函数。

沙盒化与脚本热升级

沙盒:限制 Lua 的能力

JsePlatform.standardGlobals() 给的是完整环境——Lua 能调 io、os.execute、require 模块,相当于让用户脚本能读你服务器的文件、执行 shell 命令。生产里必须用受限环境:

// 不要:完整全局环境
LuaValue globals = JsePlatform.standardGlobals();

// 要:只开必要库
Globals safeGlobals = new Globals();
safeGlobals.load(new BaseLib());
safeGlobals.load(new PackageLib());
safeGlobals.load(new StringLib());
safeGlobals.load(new MathLib());
safeGlobals.load(new TableLib());
// ↑ 不加 IoLib / OsLib / DebugLib —— Lua 脚本读不了文件、不能执行系统命令

// 然后注入业务函数
safeGlobals.set("RiskUtil", CoerceJavaToLua.coerce(new RiskUtil()));

多租户场景必须沙盒——用户上传的 Lua 脚本不能让它读到 /etc/passwd 或者 os.execute("rm -rf /")。

脚本热升级:定期检查 + 重载

热升级的本质是「定期检查脚本版本,发现新版本就重新 load」:

脚本约定:每个脚本定义一个与 name 同名的全局函数(如 risk_score.lua 里写 function risk_score(user) ... end)。manager 会 load + call 把函数注册进沙盒,再用 sandbox.get(name) 取出来缓存。

@Component
public class LuaScriptManager {

/** 每个脚本一个独立沙盒 + 取出的命名函数;沙盒隔离防全局污染 */
private static class CompiledScript {
final Globals sandbox;
final LuaValue function;
CompiledScript(Globals sandbox, LuaValue function) {
this.sandbox = sandbox;
this.function = function;
}
}

private volatile Map<String, CompiledScript> scripts = new ConcurrentHashMap<>();

@PostConstruct
public void init() {
refresh();
}

@Scheduled(fixedDelay = 30_000) // 30s 轮询
public void refresh() {
Map<String, String> latest = scriptStore.fetchAll(); // 从 DB / 配置中心拉
Map<String, CompiledScript> compiled = new HashMap<>();
latest.forEach((name, code) -> {
try {
Globals sandbox = createSandboxGlobals(); // 每个脚本独立沙盒
sandbox.load(code, name).call(); // ← 关键:执行脚本以注册同名函数
LuaValue fn = sandbox.get(name); // ← 按约定取出函数
if (!fn.isfunction()) {
throw new LuaError("脚本 " + name + " 未定义同名函数");
}
compiled.put(name, new CompiledScript(sandbox, fn));
} catch (Exception e) {
log.error("Lua 脚本 {} 编译失败,保留旧版本", name, e);
CompiledScript old = scripts.get(name);
if (old != null) compiled.put(name, old); // 编译失败用旧版兜底
}
});
this.scripts = compiled;
}

public LuaValue execute(String name, LuaValue... args) {
CompiledScript script = scripts.get(name);
if (script == null) throw new IllegalStateException("脚本不存在: " + name);
return script.function.invoke(args).arg1();
}
}

关键点:

  • 脚本编译用 globals.load() 拿到 LuaValue 缓存住——不要每次执行都重新编译;
  • 编译失败用旧版本兜底——别因为脚本写错了把整个服务搞挂;
  • 用 volatile + 整体替换 Map(COW 风格),不要在原 Map 上 put / remove,避免并发问题。

真正会让你掉坑的几件事

按踩到概率从高到低排:

坑一:每次执行都 load 是性能杀手(最常见)

globals.load(code) 是编译过程——耗时几毫秒到几十毫秒。生产里调用频率高的脚本必须编译一次缓存住,下次直接 chunk.call(),否则 QPS 上来直接 CPU 爆表。首次接入几乎都踩这个。

这点跟 Groovy 一模一样——GroovyClassLoader.parseClass() 也要缓存。

坑二:异常处理必须 catch 并降级(最常见)

Lua 脚本里 error() 抛出的会被 LuaJ 包成 LuaError 异常。必须 catch 住并降级——脚本错误不能影响主业务流程:

try {
return scriptManager.execute("risk_score", user);
} catch (LuaError e) {
log.error("Lua 脚本异常,走默认策略", e);
return defaultScore; // 降级
}

脚本是用户/运营改的,任何脚本错误都不能传染主流程——这是底线。

坑三:LuaValue 在 JVM 堆里不便宜(常见,高频场景)

每次 Java ↔ Lua 桥接都要创建 LuaValue 对象——一次调用如果传一个大 table,会创建几百个 LuaValue。高频调用场景(风控每条请求跑一次脚本)这个对象创建就是 GC 压力源。

做法:

  • 输入参数能用 LuaValue.valueOf(int/string/boolean) 这种基础类型就别用 table;
  • 用 LuaTable 复用而不是每次新建;
  • 实在性能敏感,考虑 LuaJC 把 Lua 编译成 Java 字节码(性能 5-10 倍提升)。

坑四:Lua 版本兼容 + 调 Java 语法(少见但调试痛)

LuaJ 兼容 5.2,不支持 5.3 的整数类型 / 5.4 的 to-be-closed 变量。脚本作者按 5.3+ 语法写了 1 // 2(整除),LuaJ 解析会报错。

Lua 调 Java 方法的冒号 vs 点语法也容易出错——obj:method(arg) 是实例方法(obj 自动作首参),Class.staticMethod(arg) 是静态方法。写错运行时才抛 attempt to call a nil value,调试糟糕。

做法:脚本协议文档化(明确 5.2 语法 + 哪些 Java API 可调);业务代码把 Java 调用包装成无歧义的 LuaJ 全局函数,别直接暴露 Java 类。

一句话收口

Lua 在 Java 项目里的位置很明确:Groovy 管「业务规则」,Lua 管「沙盒 + 高频 + 轻量」——不是抢 Groovy 饭碗,是补它不擅长的场景。

工程取舍:业务规则上 Groovy;游戏 / 网关 / 沙盒场景用 Lua。选脚本语言不是选「最强」,是选「最契合场景的」——Groovy 强但重,Lua 轻但隔离深。

文章目录
  1. 1. 又一个动态规则需求来了
  2. 2. 嵌入式脚本三国杀:JS / Groovy / Lua 的领地
  3. 3. 决策矩阵:什么场景用什么
  4. 4. LuaJ 接入:Java 调 Lua 的最小代码
    1. 4.0.1. 调用 Lua 函数 + 传参 + 拿返回值
  • 5. Lua 调 Java:双向桥接
    1. 5.0.1. 方式 A:注入 Java 对象到 Lua 全局环境
    2. 5.0.2. 方式 B:注册 Java 函数为 Lua 全局函数
  • 6. 沙盒化与脚本热升级
    1. 6.0.1. 沙盒:限制 Lua 的能力
    2. 6.0.2. 脚本热升级:定期检查 + 重载
  • 7. 真正会让你掉坑的几件事
    1. 7.0.1. 坑一:每次执行都 load 是性能杀手(最常见)
    2. 7.0.2. 坑二:异常处理必须 catch 并降级(最常见)
    3. 7.0.3. 坑三:LuaValue 在 JVM 堆里不便宜(常见,高频场景)
    4. 7.0.4. 坑四:Lua 版本兼容 + 调 Java 语法(少见但调试痛)
  • 8. 一句话收口