Update 系统设计基础.md

补充 Lua 脚本中 math.random 的行为(附录 A26)、红包到期退款的顺序、API 签名覆盖的字段与随机数的保存、数据权限改写 SQL 的注意事项、MinIO 的分片限制;调整 Feed 配图的说明。
This commit is contained in:
CyC2018
2026-10-31 14:03:54 +08:00
parent 8b057da93e
commit ae93e5bfba
2 changed files with 60 additions and 43 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 217 KiB

+60 -43
View File
@@ -302,7 +302,7 @@ return 0 -- 可以发送
同一个手机号 1 秒内请求 5 次,只有第 1 次发送了短信;把冷却时间改为 1 秒后连续请求 12 次,第 11、12 次因为当天的次数用完被拒绝;同一个 IP 给 30 个手机号发验证码,从第 21 个开始被拒绝(Redis 7.4 实测,见[附录 A20](#a20-接口防刷))。按小时、按天计数是固定窗口,窗口边界附近可能短时间内放过两倍的请求,需要更精确时改用滑动窗口,见 [限流算法](#1-限流算法)。
验证码本身也要防止被猜中。6 位数字只有 100 万种,不限制输错次数时,在本机直接调用校验脚本逐个尝试,第 835770 次猜中,只用了 8 秒(见附录 A20);通过网络调用接口会慢一些,但按每秒 1000 次计算,平均约 8 分钟也能猜中。所以校验时:
验证码本身也要防止被猜中。6 位数字只有 100 万种,不限制输错次数时,在服务器所在的机器上直接调用校验逻辑逐个尝试,第 835770 次猜中,只用了 8 秒(见附录 A20);通过网络调用接口会慢一些,但按每秒 1000 次计算,平均约 8 分钟也能猜中。所以校验时:
- 验证码保存在 Redis 中,5 分钟后过期,多个服务实例都能读到;
- 输对后立即删除,一个验证码只能用一次;
@@ -310,7 +310,7 @@ return 0 -- 可以发送
加上发送的限制,一个手机号每天最多能猜 50 次,猜中的概率只有 50 / 10<sup>6</sup> = 0.005%。
开放给合作方的接口还要防止请求被篡改或者重放。重放指的是把截获的合法请求原样再发一次,例如把一次退款请求重复提交。做法是双方事先约定一个密钥,调用方把请求的关键字段和时间戳、随机数(nonce)拼在一起,用密钥计算 HMAC 签名:
开放给合作方的接口还要防止请求被篡改或者重放。重放指的是把截获的合法请求原样再发一次,例如把一次退款请求重复提交。做法是双方事先约定一个密钥,调用方把请求的方法、路径、查询参数、请求体和时间戳、随机数(nonce)拼在一起(漏掉的部分可以被改掉而签名不变),用密钥计算 HMAC 签名:
```text
签名原文:POST\n/api/refund\n1793000000\nn-8f3a2c\n{"order": 1024, "amount": 3900}
@@ -321,9 +321,9 @@ return 0 -- 可以发送
- 时间戳与当前时间相差不超过 5 分钟,很久以前的请求直接拒绝;
- 用同样的密钥重新计算签名并比较,请求中任何一个字段被改过都对不上;
- 执行 SET nonce:n-8f3a2c 1 NX EX 600 记录随机数,失败说明这个请求已经处理过。随机数保存的时间要覆盖时间戳允许的范围,否则过期后又能重放。
- 执行 SET nonce:n-8f3a2c 1 NX EX 600 记录随机数,失败说明这个请求已经处理过。随机数保存的时间要覆盖时间戳允许的范围,前后各 5 分钟就保存 10 分钟,否则过期后又能重放。多个调用方时键名带上调用方的标识,避免随机数相互冲突。
原样重放的请求因为随机数已经用过被拒绝,改掉金额的请求签名对不上,10 分钟后再重放则时间戳超出了范围(见附录 A20)。需要注意的是,签名只适合密钥保存在服务端的调用,例如开放平台和支付回调;写在 App 或网页中的密钥可以被逆向取出,只能增加刷接口的成本。
原样重放的请求因为随机数已经用过被拒绝,改掉金额的请求签名对不上,10 分钟后再重放则时间戳超出了范围(见附录 A20)。随机数只能拦住原样的重放:调用方超时后换一个随机数重试,签名层看到的是新请求,退款这类操作仍要按业务单号做[幂等](分布式.md#九幂等)。需要注意的是,签名只适合密钥保存在服务端的调用,例如开放平台和支付回调;写在 App 或网页中的密钥可以被逆向取出,只能增加刷接口的成本。
识别出的刷接口来源,还可以在网关中加入黑名单,例如 SET ban:ip:203.0.113.7 1 EX 3600,一小时内直接拒绝它的请求。规模更大的系统会接入风控,根据设备指纹、操作轨迹等判断请求是否来自机器,可疑时要求验证码。图形和滑块验证码能被打码平台破解,同样只能提高成本,所以要和限频、黑名单一起使用。
@@ -496,7 +496,7 @@ WHERE id = 70001 AND user_id = 1001 AND status = 0 AND valid_end > NOW();
拆分的时机有两种:
- 预先拆分:发红包时就拆成 10 份,用 RPUSH 放入 Redis 列表,抢的时候 LPOP 一份。抢的路径只有一次弹出,金额在发的时候就已确定,可以随红包一起落库备查;代价是每一份都要占用内存,份数多时更明显;
- 实时计算:Redis 中只保存剩余金额和剩余个数,抢的时候在 Lua 脚本中按二倍均值法算出本份金额,再更新这两个值。占用的内存固定,但每次领取都要在脚本中计算。Redis 7 中脚本的随机数生成器每次调用都会随机设置种子,可以直接调用 math.random。
- 实时计算:Redis 中只保存剩余金额和剩余个数,抢的时候在 Lua 脚本中按二倍均值法算出本份金额,再更新这两个值。占用的内存固定,但每次领取都要在脚本中计算。Redis 7 中脚本可以直接调用 math.random。需要注意的是,随机数生成器只在启动时设置固定的初值,之后各次调用依次取同一个序列中的值,重启后序列从头开始(Redis 7.4 实测,见[附录 A26](#a26-脚本中的-mathrandom));对结果的不可预测性有要求时,由应用生成随机数作为参数传入。
两种做法用的是同一个拆分方法,金额的分布相同,在 Lua 脚本中执行时也都不会超发。红包的份数通常不多,预先拆分的实现更简单。
@@ -537,7 +537,7 @@ WHERE id = 8001 AND remain_num > 0 AND remain_amount >= 276;
应用在脚本执行成功之后、消息发出之前宕机时,这条领取只记录在 Redis 中。因此红包抢完或过期后,要用 Redis 中的领取记录与数据库对账,补发缺失的消息。例如每条消息都投递两次、其中一个用户的消息全部丢失:18 条消息插入 9 条、冲突 9 条,对账补发后剩余金额为 0、领取记录 10 条,合计正好 100 元(MySQL 8.0 实测,见[附录 A16](#a16-并发抢红包与异步落库))。
红包一般有有效期,例如 24 小时。到期后先完成对账,再把数据库中的剩余金额退回给发送者。
红包一般有有效期,例如 24 小时。到期后先在 Redis 中停止领取(例如删除金额列表,之后的脚本 LPOP 取不到金额,直接返回已抢完),再对账并补发缺失的消息,确认这些领取都已写入数据库后,把数据库中的剩余金额退回给发送者。顺序反过来时,对账之后仍可能有人抢到,退回的金额就多了。
## 七、海量数据处理
@@ -582,7 +582,7 @@ WHERE id = 8001 AND remain_num > 0 AND remain_amount >= 276;
## 八、短链系统
短链系统把很长的网址换成很短的网址,例如把 https://shop.example.com/item/1024?from=campaign 换成 https://s.example/6LAze,常用于短信和社交媒体。用户访问短链时,服务返回一个跳转,浏览器再去访问原来的长链接。
短链系统把很长的网址换成很短的网址,例如把 `https://shop.example.com/item/1024?from=campaign` 换成 `https://s.example/6LAze`,常用于短信和社交媒体。用户访问短链时,服务返回一个跳转,浏览器再去访问原来的长链接。
<div align="center"> <img src="pics/short-url.png" width="760px"/> </div><br>
@@ -889,7 +889,7 @@ S3 的完成请求要求列出每个分片的分片号和 ETag,服务端按分
分片本身不是对象,只有完成请求之后才生成对象,分片也随之消失。因此合并是分片上传的最后一步。在 S3 中,合并由存储服务按分片号组合,应用不需要再下载、拼接并重新上传一次。
MinIO 兼容 S3 的分片上传接口,它的限制与 S3 一致:分片号必须在 1 到 10000 之间,分片小于 5 MB 时返回 EntityTooSmall 错误。应用侧的流程与上文相同,区别在于分片由 MinIO 保存,完成时由 MinIO 组合成对象。如果希望大文件不经过应用服务器,也可以让客户端用预签名 URL 直接上传到对象存储,应用服务只保存上传状态。
MinIO 兼容 S3 的分片上传接口,分片号和最小分片的限制与 S3 一致:分片号必须在 1 到 10000 之间,除最后一片外每片至少 5 MiB,否则完成上传时返回 EntityTooSmall 错误;单个分片最大 5 TiB,比 S3 的 5 GiB 大。应用侧的流程与上文相同,区别在于分片由 MinIO 保存,完成时由 MinIO 组合成对象。如果希望大文件不经过应用服务器,也可以让客户端用预签名 URL 直接上传到对象存储,应用服务只保存上传状态。
## 十二、权限系统
@@ -1030,7 +1030,8 @@ SELECT id, title FROM sys_order ORDER BY id;
常见做法是在 Mapper 或 DAO 层用注解标记需要过滤的查询,由 AOP 或 MyBatis 拦截器在执行前把范围条件追加到 WHERE 中,业务代码不需要关心范围。这种做法需要注意以下几点:
- 条件的值必须通过参数绑定传入,不能拼接字符串;
- 多表关联或子查询中,条件要加在正确的表别名上,拦截器需要能正确解析 SQL;
- 多表关联或子查询中,条件要加在正确的表别名上,原条件还要先加括号。例如 status = 1 OR status = 2 直接拼上 AND dept_id = 20,AND 的优先级高于 OR,其它部门 status = 1 的订单也会返回,所以拦截器要用 SQL 解析器改写,而不是拼接字符串;
- MyBatis 拦截器要在 Executor.query 中、计算缓存键之前改写 SQL。在 StatementHandler.prepare 中改写时,缓存键仍按原 SQL 和参数计算,开启二级缓存后,另一个部门的用户会命中前一个用户的查询结果;
- 分页的总数(COUNT)也要带同一条件。张三的订单列表只有 2 条,不带条件的 COUNT(*) 却返回 4,分页会显示错误的总数;
- 修改和删除同样要带范围条件。张三修改订单 2 时,执行 UPDATE sys_order SET amount = 1 WHERE id = 2 AND creator_id = 1001,影响行数为 0,应用据此拒绝请求。这是为了防止不安全的直接对象引用(Insecure Direct Object Reference, IDOR),即用户只改动 URL 中的 ID 就访问到别人的数据;
- 部门有层级时,DEPT 的范围还应包括下级部门,可以在部门表中保存祖先路径,或使用闭包表;
@@ -1468,12 +1469,12 @@ OBJECT ENCODING inbox:0 -> listpack
### A4 MySQL 主键点查的并发扫描
环境:MySQL 8.0.44,通过 unix socket 连接;Apple M1 Pro(10 核),macOS 15.8.1;客户端 mysqlslap 与服务端在同一台机器上。表 `t1_item` 有 10 万行,主键为 `id BIGINT`,表大小约 5.5 MB,小于 128 MB 的 InnoDB 缓冲池,预热后查询都在内存中完成。
环境:MySQL 8.0.44,通过 unix socket 连接;Apple M1 Pro(10 核),macOS 15.8.1;客户端 mysqlslap 与服务端在同一台机器上。表 `item` 有 10 万行,主键为 `id BIGINT`,表大小约 5.5 MB,小于 128 MB 的 InnoDB 缓冲池,预热后查询都在内存中完成。
查询为 2000 条不同主键的语句循环执行,形如:
```sql
SELECT name, price, stock FROM t1_item WHERE id = 48213;
SELECT name, price, stock FROM item WHERE id = 48213;
```
命令(每个并发数执行 3 次,每次 10 万条查询):
@@ -1494,7 +1495,7 @@ mysqlslap --user=root --socket=<socket 路径> --create-schema=demo \
| 64 | 0.557 | 179476 | 0.357 |
| 128 | 0.522 | 191510 | 0.668 |
吞吐量为实际执行条数除以耗时。mysqlslap 的 `--number-of-queries` 指每个客户端约执行的条数([mysqlslap 手册](https://dev.mysql.com/doc/refman/8.0/en/mysqlslap.html)),64 和 128 个连接时取整后实际分别执行 99968 条。平均响应时间为耗时除以每个客户端的条数,即并发数 ÷ 吞吐量,包含客户端自身的开销。32 个连接的单次耗时在 0.557 至 0.927 秒之间波动。
吞吐量为实际执行条数除以耗时。mysqlslap 的 `--number-of-queries` 是全部客户端合计的条数,按连接数向下取整平分(手册写作每个客户端的上限,与源码 mysqlslap.cc 中按连接数相除的实现不一致),64 和 128 个连接时实际分别执行 99968 条。平均响应时间为耗时除以每个客户端的条数,即并发数 ÷ 吞吐量,包含客户端自身的开销。32 个连接的单次耗时在 0.557 至 0.927 秒之间波动。
原始输出(并发数 16 的一段):
@@ -1509,10 +1510,10 @@ Benchmark
### A5 热点行与分散行的 UPDATE
环境同[附录 A4](#a4-mysql-主键点查的并发扫描)。表 `t1_stock` 有 100 行(`sku_id` 为 1 至 100,`sold` 为计数)。热点负载为:
环境同[附录 A4](#a4-mysql-主键点查的并发扫描)。表 `sku_stock` 有 100 行(`sku_id` 为 1 至 100,`sold` 为计数)。热点负载为:
```sql
UPDATE t1_stock SET sold = sold + 1 WHERE sku_id = 1;
UPDATE sku_stock SET sold = sold + 1 WHERE sku_id = 1;
```
分散负载为 2000 条语句,`sku_id` 为 1 至 100 之间的随机数。两者都是自动提交,每个并发数执行 20000 条,3 次平均。服务端的 `innodb_flush_log_at_trx_commit` 和 `sync_binlog` 均为默认值 1,每次提交都要刷盘。
@@ -1567,7 +1568,7 @@ print(params / 1e9, kv_per_token)
### A9 分片上传的最小实现与实测
环境:Apple M1 Pro,macOS 15.8.1;Python 3.13.7,标准库 http.server 和 hashlib,pymysql 2.2.8;MySQL 8.0.44,demo 库中三张表 t2_upload、t2_part、t2_file。测试文件为 300 MiB(314572800 字节),由固定种子的伪随机数生成,分片 5 MiB,共 60 片。
环境:Apple M1 Pro,macOS 15.8.1;Python 3.13.7,标准库 http.server 和 hashlib,pymysql 2.2.8;MySQL 8.0.44,demo 库中三张表 upload_task、upload_part、file_meta。测试文件为 300 MiB(314572800 字节),由固定种子的伪随机数生成,分片 5 MiB,共 60 片。
服务端的分片接收和合并(核心片段,省略建表、路由和部分错误分支):
@@ -1583,18 +1584,18 @@ def put_part(self, uid, n):
if not buf: # 客户端中途断开
break
f.write(buf); h.update(buf); md5.update(buf); got += len(buf)
expect = q("SELECT expect_sha256 FROM t2_part WHERE upload_id=%s AND part_no=%s",
expect = q("SELECT expect_sha256 FROM upload_part WHERE upload_id=%s AND part_no=%s",
(uid, n), 'one')
if got != length or h.hexdigest() != expect['expect_sha256']:
os.remove(tmp)
return self.send(400 if got != length else 409, {'error': 'part rejected'})
os.replace(tmp, part_path(uid, n)) # 同号分片被覆盖
q("UPDATE t2_part SET size=%s, etag=%s WHERE upload_id=%s AND part_no=%s",
q("UPDATE upload_part SET size=%s, etag=%s WHERE upload_id=%s AND part_no=%s",
(got, md5.hexdigest(), uid, n))
self.send(200, {'part': n, 'etag': md5.hexdigest()})
# 服务端:完成上传。条件更新保证只有一个请求进入合并,合并时流式计算整文件哈希
if q("UPDATE t2_upload SET status='completing' WHERE id=%s AND status='uploading'", (uid,)) == 0:
if q("UPDATE upload_task SET status='completing' WHERE id=%s AND status='uploading'", (uid,)) == 0:
return self.send(409, {'error': 'being completed or aborted'})
# 此处省略检查缺片、逐片核对哈希的代码
h = hashlib.sha256()
@@ -1747,10 +1748,10 @@ $ 客户端 status e355c1f1002e409d
upload directory exists after abort: False
== E1 数据库状态
t2_upload by status: (('aborted', 1), ('done', 2))
t2_file rows: 2
t2_part (upload_id, rows, uploaded): ('762fa9bd16a44d31', 20, Decimal('20'))
t2_part (upload_id, rows, uploaded): ('b80db1c51e31407b', 60, Decimal('60'))
upload_task by status: (('aborted', 1), ('done', 2))
file_meta rows: 2
upload_part (upload_id, rows, uploaded): ('762fa9bd16a44d31', 20, Decimal('20'))
upload_part (upload_id, rows, uploaded): ('b80db1c51e31407b', 60, Decimal('60'))
== 服务端日志
listening on 49696
@@ -1782,7 +1783,7 @@ SHA-256 扫描 300 MiB 实测 0.31 s,换算 2 GiB 约 2.12 s
### A11 权限判断与执行计划
环境:MySQL 8.0.44,macOS(Apple M1 Pro),库 t3_rbac,客户端字符集 utf8mb4。判断查询与正文相同,五个用例只替换 user_id、请求方法和路径。原始输出如下:
环境:MySQL 8.0.44,macOS(Apple M1 Pro),库 rbac,客户端字符集 utf8mb4。判断查询与正文相同,五个用例只替换 user_id、请求方法和路径。原始输出如下:
```text
+--------------------------------+---------+
@@ -1795,13 +1796,13 @@ SHA-256 扫描 300 MiB 实测 0.31 s,换算 2 GiB 约 2.12 s
| 1005 GET /api/orders (no role) | 0 |
+--------------------------------+---------+
+----+-------------+-------+------------+--------+-----------------------+---------------+---------+--------------------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+--------+-----------------------+---------------+---------+--------------------------+------+----------+-------------+
| 1 | SIMPLE | p | NULL | const | PRIMARY,uk_method_url | uk_method_url | 1056 | const,const | 1 | 100.00 | NULL |
| 1 | SIMPLE | ur | NULL | ref | PRIMARY,idx_role_id | PRIMARY | 8 | const | 1 | 100.00 | Using index |
| 1 | SIMPLE | rp | NULL | eq_ref | PRIMARY,idx_perm_id | PRIMARY | 16 | t3_rbac.ur.role_id,const | 1 | 100.00 | Using index |
+----+-------------+-------+------------+--------+-----------------------+---------------+---------+--------------------------+------+----------+-------------+
+----+-------------+-------+------------+--------+-----------------------+---------------+---------+-----------------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+--------+-----------------------+---------------+---------+-----------------------+------+----------+-------------+
| 1 | SIMPLE | p | NULL | const | PRIMARY,uk_method_url | uk_method_url | 1056 | const,const | 1 | 100.00 | NULL |
| 1 | SIMPLE | ur | NULL | ref | PRIMARY,idx_role_id | PRIMARY | 8 | const | 1 | 100.00 | Using index |
| 1 | SIMPLE | rp | NULL | eq_ref | PRIMARY,idx_perm_id | PRIMARY | 16 | rbac.ur.role_id,const | 1 | 100.00 | Using index |
+----+-------------+-------+------------+--------+-----------------------+---------------+---------+-----------------------+------+----------+-------------+
```
### A12 数据范围、分页总数与写操作
@@ -1947,7 +1948,7 @@ return 0
10400 ms 拒绝 剩余令牌 0.0
```
内存用 MEMORY USAGE <key> SAMPLES 0 测量:写入 1000 条记录的 ZSET(成员形如 req-1000000,分值为毫秒时间戳)为 102016 字节,多次运行在 101888~102384 字节之间;固定窗口的计数器为 56 字节,令牌桶的哈希表为 104 字节。
内存用 MEMORY USAGE \<key> SAMPLES 0 测量:写入 1000 条记录的 ZSET(成员形如 req-1000000,分值为毫秒时间戳)为 102016 字节,多次运行在 101888~102384 字节之间;固定窗口的计数器为 56 字节,令牌桶的哈希表为 104 字节。
单个键的吞吐量用 redis-benchmark 测量(本机回环,50 个连接,每项 200000 次,同一个键),令牌桶脚本先用 SCRIPT LOAD 加载:
@@ -2258,40 +2259,40 @@ ignoreExceptions(BizException):状态 CLOSED,失败 0 次,失败率 -1%,
### A19 防止超卖的几种写法
环境:MySQL 8.0.44(默认的可重复读),Python 3 + PyMySQL,Apple M1 Pro。表 d5_goods(id, stock, version) 中商品 1001 的库存为 100,d5_order 的主键为 (user_id, goods_id)。每种写法用 64 个线程,每个用户请求 1 次,每个线程使用自己的连接(自动提交):
环境:MySQL 8.0.44(默认的可重复读),Python 3 + PyMySQL,Apple M1 Pro。表 goods(id, stock, version) 中商品 1001 的库存为 100,goods_order 的主键为 (user_id, goods_id)。每种写法用 64 个线程,每个用户请求 1 次,每个线程使用自己的连接(自动提交):
```python
def naive(uid): # 先查询,大于 0 再扣减
cur.execute("SELECT stock FROM d5_goods WHERE id = 1001")
cur.execute("SELECT stock FROM goods WHERE id = 1001")
if cur.fetchone()[0] <= 0: return 'soldout'
cur.execute("UPDATE d5_goods SET stock = stock - 1 WHERE id = 1001")
cur.execute("INSERT INTO d5_order VALUES (%s, 1001)", (uid,))
cur.execute("UPDATE goods SET stock = stock - 1 WHERE id = 1001")
cur.execute("INSERT INTO goods_order VALUES (%s, 1001)", (uid,))
return 'ok'
def pessimistic(uid): # SELECT ... FOR UPDATE
c.begin()
cur.execute("SELECT stock FROM d5_goods WHERE id = 1001 FOR UPDATE")
cur.execute("SELECT stock FROM goods WHERE id = 1001 FOR UPDATE")
if cur.fetchone()[0] <= 0: c.rollback(); return 'soldout'
cur.execute("UPDATE d5_goods SET stock = stock - 1 WHERE id = 1001")
cur.execute("INSERT INTO d5_order VALUES (%s, 1001)", (uid,))
cur.execute("UPDATE goods SET stock = stock - 1 WHERE id = 1001")
cur.execute("INSERT INTO goods_order VALUES (%s, 1001)", (uid,))
c.commit(); return 'ok'
def optimistic(uid, retries=0): # 版本号,冲突时最多重试 retries 次
for _ in range(retries + 1):
cur.execute("SELECT stock, version FROM d5_goods WHERE id = 1001")
cur.execute("SELECT stock, version FROM goods WHERE id = 1001")
stock, ver = cur.fetchone()
if stock <= 0: return 'soldout'
n = cur.execute("UPDATE d5_goods SET stock = stock - 1, version = version + 1 "
n = cur.execute("UPDATE goods SET stock = stock - 1, version = version + 1 "
"WHERE id = 1001 AND version = %s", (ver,))
if n == 1:
cur.execute("INSERT INTO d5_order VALUES (%s, 1001)", (uid,))
cur.execute("INSERT INTO goods_order VALUES (%s, 1001)", (uid,))
return 'ok'
return 'conflict'
def conditional(uid): # 条件更新
n = cur.execute("UPDATE d5_goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0")
n = cur.execute("UPDATE goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0")
if n == 0: return 'soldout'
cur.execute("INSERT INTO d5_order VALUES (%s, 1001)", (uid,))
cur.execute("INSERT INTO goods_order VALUES (%s, 1001)", (uid,))
return 'ok'
```
@@ -2683,6 +2684,22 @@ WHERE o.pay_txn IS NOT NULL AND o.channel = 'wxpay'
+--------------+----------+-------------+--------------+---------+------------------------+
```
### A26 脚本中的 math.random
环境:Redis 7.4.1,Apple M1 Pro,macOS,默认配置。连续调用三次同一个脚本,重启 Redis 后再调用三次:
```bash
for i in 1 2 3; do redis-cli eval "return math.random(1,1000000)" 0; done
redis-cli shutdown nosave # 重新启动 redis-server 后再执行上一行
```
```text
重启前:396465 840486 353337
重启后:396465 840486 353337
```
三次调用的结果不同,说明不是每次调用都把种子重设为同一个值;重启后得到同样的序列,说明初值是固定的。源码中 math.random 被替换为 redisLrand48,只有脚本调用 math.randomseed 时才重设种子(src/rand.c、src/script_lua.c)。
## 参考资料
- [Redis: Scripting with Lua](https://redis.io/docs/latest/develop/programmability/eval-intro/)