工业互联网实时数据统计一致性保障 --- 基于 Redis Lua 的并发安全方案
1. 场景描述
在工业互联网平台中,设备(如机床、机械手、检测机等)持续上报累计计数器数据:
json
{"deviceId": "D001", "output": 933560, "timestamp": 1718000000000}
平台需要实时计算每个设备在每小时内的产量增量,并将结果持久化到数据库,供报表系统查询。
核心计算逻辑
收到设备上报的 output(累计计数器)
→ 从缓存取上次记录的 output 值
→ delta = 当前 output - 上次 output
→ 数据库累加 delta(本小时产量 += delta)
→ 把当前 output 写回缓存
看似简单,实则在并发场景下存在数据不一致风险。
2. 问题现场
以某台设备在 12:00-13:00 一小时的运行数据为例:
| 数据来源 | 产量 | 说明 |
|---|---|---|
| 设备原始上报记录 | 153 | output 从 933560 → 933713,真实增量 |
| 平台计算结果 | 169 | 比真实值多了 16 |
差异 16 件,误差率 10.5%。对于月累计报表,这个误差会逐小时放大,最终严重偏离真实产能。
排查过程
对比设备原始报文和平台处理日志,发现同一秒内存在重复处理:
13:10:13.195 [线程A] output=422 last=418 delta=4
13:10:13.196 [线程B] output=422 last=418 delta=4 ← 同一数据被两个线程各自计算
在该小时 167 次增量计算中,有 15 次是重复的,恰好对应多出来的 16 件(含一次大跳变带来的 1 件差异)。
3. 根因分析
原实现代码:
java
// 步骤1:从缓存读取上次的计数器值
Long lastOutput = cache.get(key);
// 步骤2:计算增量
long delta = currentOutput - lastOutput;
// 步骤3:更新数据库
database.update("产量 += " + delta);
// 步骤4:把当前值写回缓存
cache.set(key, currentOutput);
问题:这四步不是原子操作。 当两个线程同时处理同一条消息时:
时间轴 →
线程A: ①读缓存→418 ②delta=4 ③数据库+4 ④缓存写422
线程B: ①读缓存→418 ②delta=4 ③数据库+4 ④缓存写422
↑ 都读到旧值 ↑ ↑ 加了两次 ↑
两个线程在线程 A 写回缓存之前 都读到了旧值 418,各自算出 delta=4,各自写入数据库。本该 +4 变成了 +8。
这是一个典型的 Read-Modify-Write 竞态条件。
4. 解决方案:Redis Lua 原子脚本
4.1 设计思路
将「读取旧值 + 比较 + 写入新值」三步合并成一个不可分割的原子操作,利用 Redis 单线程执行 Lua 脚本的特性保证并发安全。
4.2 Lua 脚本实现
lua
-- KEYS[1]: 缓存 key(如 "counter:D001_2024061012")
-- KEYS[2]: 当前计数器值(字符串形式,如 "422")
-- KEYS[3]: 过期时间(秒,如 "86400")
local nv = tonumber(KEYS[2])
local ttl = tonumber(KEYS[3])
local old = redis.call('GET', KEYS[1])
-- 情况1:key 不存在(本周期首条数据)
if old == false then
redis.call('SET', KEYS[1], nv)
if ttl > 0 then redis.call('EXPIRE', KEYS[1], ttl) end
return nv
end
old = tonumber(old)
-- 情况2:旧值无法解析为数字(历史遗留的序列化数据)
if old == nil then
redis.call('SET', KEYS[1], nv)
if ttl > 0 then redis.call('EXPIRE', KEYS[1], ttl) end
return nv
end
-- 情况3:新值 > 旧值 → 正常增长
if nv > old then
redis.call('SET', KEYS[1], nv)
if ttl > 0 then redis.call('EXPIRE', KEYS[1], ttl) end
return old
end
-- 情况4:新值 <= 旧值 → 重复数据或回退,不更新
return old
4.3 Java 侧封装
java
public Long getAndSetGreater(String key, long newVal, long ttlSeconds) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(lua);
script.setResultType(Long.class);
return redisTemplate.execute(script,
Arrays.asList(key, String.valueOf(newVal), String.valueOf(ttlSeconds)));
}
调用方简化为一行:
java
// 原子的 "读取上次值 + 更新为当前值"
long lastOutput = cache.getAndSetGreater(key, currentOutput, 24 * 3600);
long delta = currentOutput - lastOutput;
if (delta > 0) {
database.update("产量 += " + delta);
}
4.4 为什么能保证原子性
Redis 执行 Lua 脚本期间,其他命令必须排队等待。两个线程同时提交:
Redis 单线程执行队列:
[线程A脚本] → GET:418 → SET:422 → 返回418
[线程B脚本] → GET:422 → 不更新 → 返回422
结果:A 得 delta=4 ✓ B 得 delta=0 ✓
线程B 读到的是线程A 刚写入的 422,delta 为 0,不会重复累加。
5. 踩坑记录:序列化兼容问题
5.1 JDK 序列化导致 Lua 脚本解析失败
部署后日志报错:
ERR Error running script: attempt to compare number with nil
原因 :RedisTemplate<String, Object> 默认使用 JdkSerializationRedisSerializer,数字 422 被序列化为 Java 二进制字节:
\xac\xed\x00\x05sr\x00\x11java.lang.Long\x00\x00\x00\x00\x00\x00\x01\xa6...
Lua 的 tonumber() 无法解析这种二进制格式,返回 nil,导致后续比较报错。
5.2 解决方案
RedisTemplate 有两套序列化器:
- Key 序列化器 :默认
StringRedisSerializer(纯字符串,Lua 可解析) - Value/Args 序列化器 :默认
JdkSerializationRedisSerializer(二进制,Lua 不可解析)
将业务参数从 ARGV(走 Value 序列化器)移至 KEYS(走 Key 序列化器):
java
// ❌ ARGV 走 Value 序列化 → 二进制 → Lua tonumber() 返回 nil
execute(script, singletonList(key), String.valueOf(newVal))
// ✅ KEYS 走 Key 序列化 → 纯字符串 → Lua tonumber() 正常解析
execute(script, Arrays.asList(key, "422", "86400"))
Lua 脚本对应使用 KEYS[2]、KEYS[3] 替代 ARGV[1]、ARGV[2]。
6. 方案总结
核心思想
用 Redis Lua 脚本将"读-改-写"三个独立操作合并为一个原子操作,借助 Redis 单线程执行模型消除竞态条件。
适用场景
任何需要基于「上次值」计算「增量」的场景:
| 行业 | 场景举例 |
|---|---|
| 工业互联网 | 设备产量/能耗累计计算 |
| 物联网 | 传感器累计值增量统计 |
| 电商 | 商品库存扣减 |
| 金融 | 账户余额变动 |
| 游戏 | 玩家积分/经验值变更 |
边界情况全覆盖
| 场景 | 脚本行为 | 业务侧处理 |
|---|---|---|
| 周期首发(key 不存在) | 写入当前值,返回当前值 | delta=0,不累加 |
| 正常递增 | 写入新值,返回旧值 | delta > 0,正常累加 |
| 重复数据(并发/重试) | 不更新,返回当前值 | delta=0,跳过 |
| 计数器回退(硬件重置) | 不更新,返回旧值 | delta<0,丢弃 |
| 旧版序列化数据 | 覆盖为纯数字字符串 | delta=0,自动修复 |
注意事项
- Lua 脚本不要有副作用之外的长耗时操作(如网络请求),会阻塞 Redis
- Key 的序列化器必须是 String 类型 ,否则 Lua 中
tonumber()同样失败 - 过期时间兜底:即使代码异常未清理,Key 也会自动过期,不会无限积压
7. 其他可选方案对比
解决 Read-Modify-Write 竞态条件不止 Lua 一种方式,以下是五种替代方案及其权衡。
7.1 分布式锁
处理前先获取锁,拿到锁才执行"读-改-写",否则跳过。
lock = getLock("device:D001:hour:12")
if lock.tryLock(1秒) {
读缓存 → 算delta → 写DB → 写缓存
lock.unlock()
}
| 维度 | 评价 |
|---|---|
| 优点 | 代码改动小,逻辑直观 |
| 缺点 | 每次加解锁有网络往返开销;需处理锁超时、死锁、锁粒度 |
7.2 Redis WATCH + MULTI/EXEC(乐观锁)
Redis 原生事务机制:WATCH 监控 key,执行 EXEC 时若 key 已被其他连接修改,事务自动回滚,应用层重试。
WATCH key
old = GET key
MULTI
SET key newVal
EXEC → 若被改过返回 nil,重试
| 维度 | 评价 |
|---|---|
| 优点 | Redis 原生支持,无需脚本 |
| 缺点 | 需实现重试逻辑;并发越高重试越多,CPU 浪费严重 |
7.3 单线程串行消费
按 deviceId 取模,将同一设备的消息路由到固定线程,天然串行。
executor[deviceId.hashCode() % 线程数].submit(task)
| 维度 | 评价 |
|---|---|
| 优点 | 极简,无需任何锁或脚本 |
| 缺点 | 仅适用于消息队列等可控消费端;线程数固定,设备多则排队延迟;不适用于 HTTP/网关等多入口架构 |
7.4 数据库原子 UPDATE
将缓存逻辑下沉到数据库,一条 SQL 完成比较+更新:
sql
UPDATE counter
SET last_val = :newVal,
total = total + (:newVal - last_val)
WHERE device_id = ? AND hour = ? AND last_val < :newVal
affected rows = 0 表示并发冲突或数据未变化,跳过即可。
| 维度 | 评价 |
|---|---|
| 优点 | 去掉对 Redis 的依赖,天然持久化 |
| 缺点 | 每次报文都击穿到 DB,高频写入下数据库压力大;行锁排队性能显著低于 Redis |
7.5 消息去重
在入口处用 Redis SETNX 或布隆过滤器标记已处理的消息 ID,防止重复消费。
if (!cache.setNX("msgId:" + messageId, "1", 60秒)) {
return // 已处理,跳过
}
| 维度 | 评价 |
|---|---|
| 优点 | 从源头消除重复,改动最小 |
| 缺点 | 依赖 messageId 全局唯一且不变;不能解决两条不同消息恰好同一 output 值的并发;对乱序到达无保护 |
7.6 综合对比
| 方案 | 复杂度 | 性能 | 可靠性 | 额外依赖 | 适用场景 |
|---|---|---|---|---|---|
| Lua 原子脚本 | 中 | 最高 | 最可靠 | 无 | 通用,首选 |
| 分布式锁 | 中 | 较低 | 需处理死锁 | 锁组件 | 锁粒度可控的场景 |
| WATCH/MULTI | 中 | 中 | 可靠 | 无 | 并发不高的场景 |
| 单线程消费 | 低 | 中 | 可靠 | 特定架构 | 消息队列消费者 |
| DB 原子 UPDATE | 低 | 最低 | 可靠 | 无 | 低频写入场景 |
| 消息去重 | 低 | 高 | 有限 | 需 messageId | 重复投递是唯一问题源时 |
7.7 为什么本场景选择 Lua
- 报文频率高(单设备每秒 3~5 条上报)→ 分布式锁和 DB UPDATE 会成为瓶颈
- 消息可能由不同线程并行处理(网关/协议适配层多线程解码)→ 单线程消费不适用
- 计数器存在跳变和回退(传感器噪声)→ 纯比较大小逻辑需要在数据面完成,数据库 SQL 难以表达复杂边界
- Redis 已在架构中(缓存设备最新状态)→ 不引入额外组件
Lua 方案一次 Redis 往返完成"读 + 比较 + 写",零额外网络开销,100% 原子,在本场景下是综合最优解。
我是一名工业互联网开发工程师,服务于纵横工业互联网团队,这是河南863旗下专注工业数字化的团队,也是深耕工业数字化转型领域的专业技术与解决方案服务商。我们聚焦工业企业智能化升级核心需求,打造出全栈式、可落地的工业互联网产品与服务体系,构建了自主可控的五大核心产品体系,包括面向产业集聚区/工业园区的产业集聚区工业互联网管理平台,以及面向工业企业全生产流程的物联网平台、能耗能碳管理平台、设备管理系统、MES生产制造执行系统。