news 2026/5/12 4:17:50

02-秒杀系统-商品详细页多级缓存实战(上)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
02-秒杀系统-商品详细页多级缓存实战(上)

秒杀系统-商品详细页多级缓存实战一
秒杀系统-商品详细页多级缓存实战二
秒杀系统-商品详细页多级缓存实战三

商品数据表模块技术难点

问题:此时有什么问题?: 目前这个方案有什么问题了?我们慢慢发现一个问题,只有分类并不能适应所有的需求,比如 nike鞋和nikeT恤,用户可能希望先看nike的所有商品,这个模型就不能满足。我们想在这个关 系中,加入“品牌”概念

第二个版本:商品+分类+品牌

这样基本用户可以在首页上通过分类或者品牌找到自己想要的商品,也可以直接查看热门的商
品和新上架的商品。

但是问题也来了,用户在进入分类后,展示在用户面前的是很多很多商品,用户希望再通过筛选查询出更接近
他目标的商品?

于是优秀的产品设计师,设计出了类似这样的UI:

怎么设计

分类管有哪些属性

属性管有哪些可选值

商品直接勾选对应的可选值

前端筛选就是:按分类带出对应属性,再按属性选项过滤商品。

  • 作用:筛选用的公共属性,决定了商品属于哪个分类、能被用户怎么过滤。
  • 例子:
    • 手机的「CPU 型号:骁龙 865」「运行内存:12GB」「屏幕尺寸:6.6 英寸」
    • 牛仔裤的「裤型:直筒」「版型:修身」「腰型:中腰」
  • 特点:
    • 同分类下的商品共用这些属性(比如所有手机都有 CPU、内存、屏幕尺寸)
    • 一个商品绑定固定的属性值,不会因为用户选了不同颜色 / 尺寸而改变
    • 只影响「能不能被搜出来」,不影响价格、库存、图片这些东西
  • 一件商品的不同颜色不同尺寸
    是算一个商品还是多个商品。

第四个版本:商品+分类+品牌+属性+规格

货品 = SPU商品 = SKU

关系:1 个货品(SPU)对应 N 个商品(SKU)一对多关系

搜索引擎elasticsearch

商品模块展示技术难点

前端展示可以分为这么几个维度:商品维度(标题、图片、属性等)、主商品维度(商品介
绍、规格参数)、分类维度、商家维度、店铺维度等;

另外还有一些实时性要求比较高
的如实时价格、实时促销、广告词、配送至、预售等是通过异步加载

SPU: Standard Product Unit (标准化产品单元),SPU是商品信息聚合的最小单位,是一组
可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。
SKU: Stock keeping unit(库存量单位) SKU即库存进出计量的单位(买家购买、商家进货、
供应商备货、工厂生产都是依据SKU进行的),在服装、鞋类商品中使用最多最普遍。 例如纺
织品中一个SKU通常表示:规格、颜色、款式。SKU是物理上不可分割的最小存货单元。

  • SPU:iPhone 15 (就这一款机型,不分颜色、内存)
  • SKU
    1. iPhone 15 黑色 128G
    2. iPhone 15 粉色 256G
    3. iPhone 15 蓝色 512G

每一条都是独立 SKU,单独算库存、单独定价、单独下单

单品页流量特点
  • 热点少大部分商品没人看,只有少数爆款、热门商品是流量热点;流量极度不均匀,冷门商品流量极低,少数商品扛住大部分访问。

  • 爬虫、比价软件疯狂抓取商品详情页是爬虫、比价工具、第三方导购站重点爬的页面,请求量大、频率高、无间断,真实用户流量之外,还掺杂大量机器流量,很容易把系统打垮。

静态化处理

FreeMarker 是一款模板引擎:即基于模板和数据源生成输出文本(html网页,配置文件,电
子邮件,源代码)的通用工具。它是一个 java 类库,最初被设计用来在MVC模式的Web开发框架中生成HTML页面,它没有被绑定到Servlet或HTML或任意Web相关的东西上。也可以用
于非Web应用环境中。
模板编写使用FreeMarker Template Language(FTL)。使用方式类似JSP的EL表达式。模板中
专注于如何展示数据,模板之外可以专注于要展示什么数据

优缺点

这是静态化的一个痛点:如果页面的公共模板(比如页头、页脚、样式、布局)改了,需要把所有商品的 HTML 文件重新生成一遍。比如京东上亿个页面,改一次模板就要全量重生成,成本极高,所以后面才会有动态模板、局部静态化的优化方案。

  • 京东的量级:千万级商品,部署在 50 台服务器 / 节点,最终是上亿个静态 HTML 文件,全靠静态化扛住超大流量。

  • 「1 个模板改了,所有的静态化页面跟着改」这是静态化的一个痛点:如果页面的公共模板(比如页头、页脚、样式、布局)改了,需要把所有商品的 HTML 文件重新生成一遍。比如京东上亿个页面,改一次模板就要全量重生成,成本极高,所以后面才会有动态模板、局部静态化的优化方案。

架构方案的问题

商品详情页静态化架构 ├─ 核心问题 │ ├─ 新增商品如何同步? │ │ ├─ 方案1:scp/rsync推送(低效,不推荐) │ │ ├─ 方案2:定时任务+分布式锁(简单但延迟高) │ │ └─ 方案3:MQ消息通知+各节点本地生成(推荐) │ ├─ 数据变更如何同步? │ │ ├─ 全量重生成静态文件 │ │ └─ 动静分离,动态数据用JS异步拉取 │ ├─ 模板修改如何生效? │ │ ├─ 全量重生成(成本极高) │ │ ├─ 局部静态化,公共部分动态引入 │ │ └─ 前后端分离,放弃静态化 │ └─ 用户如何找到静态页面? │ ├─ 约定URL规则(如 /product/{id}.html) │ └─ Nginx URL重写 └─ 演进方向 ├─ 纯静态化(早期) ├─ 动静分离(主流) └─ 前后端分离+SSR(现代)
方案核心思路优点缺点
1.文件推送(scp/rsync)一台服务器生成静态文件,然后推送到所有节点实现简单,直接粗暴节点越多,同步成本越高(商品数 × 节点数);带宽压力大;容易出现延迟和一致性问题
2.定时任务(各节点本地生成)每台服务器定时从数据库拉取未静态化的商品,本地生成 HTML无需文件同步,节点间无依赖必须解决重复执行问题,需要分布式锁(如 Redis 锁、ZooKeeper);定时任务有延迟,实时性差
3.消息中间件(MQ)商品新增 / 变更时,发送消息到 MQ;所有节点订阅 topic,收到消息后本地生成 HTML实时性高;天然支持多节点分发;避免重复同步文件架构复杂度提升;需要维护 MQ 集群;需保证消息可靠性(防丢、防重)

后台优化

缓存

提高请求的吞吐量,除了减少磁盘IO,还有网络IO,我们可以发现,请求redis其实也会涉及到
网络IO,我们所有的请求都要走xxx端口号。那有没有更好的优化思路了,来同学们你们鲜花在
哪儿?

优化方式解决什么问题优化的 IO 类型作用效果
Redis 缓存替代查 MySQL减少磁盘 IO避开数据库磁盘读写,大幅降低 DB 压力
连接池(Redis/DB)频繁创建销毁网络连接优化网络 IO复用连接,省去三次握手 / 断开的网络开销
线程池请求串行阻塞、并发低优化网络 IO 并发处理多线程异步处理网络等待,提升整体吞吐量
JVM 本地缓存还要走 Redis 内网网络消除网络 IO本机内存直接读,不用连 Redis,零网络 IO
CDN/Nginx 静态缓存后端还要处理请求彻底绕过应用 + Redis直接返回静态页,无网络、无磁盘 IO

缓存专栏

缓存一致性

最终一致性

实时一致性

访问量大、QPS高、更新频率不是很高的业务
数据一致性要求不高

缓存击穿

1.加锁,在未命中缓存时,通过加锁避免大量请求访问数据库
2.不允许过期。物理不过期,也就是不设置过期时间。而是逻辑上定时在后台异步的更新数据。
3.采用二级缓存。L1缓存失效时间短,L2缓存失效时间长。请求优先从L1缓存获取数据,如果
未命中,则加锁,保证只有一个线程去数据库中读取数据然后再更新到L1和L2中。然后其他线
程依然在L2缓存获取数据。

缓存穿透

布隆过滤器

HASH

缓存雪崩

随机过期时间

不过期

限流

一致性

为最终一致性和强一致性

一般是mq cancl mysql

分布式锁

https://www.processon.com/view/link/6044dcb85653bb620cda200c

不如八股

package com.tuling.tulingmall.service.impl; import com.github.pagehelper.PageHelper; import com.tuling.tulingmall.common.constant.RedisKeyPrefixConst; import com.tuling.tulingmall.component.LocalCache; import com.tuling.tulingmall.component.zklock.ZKLock; import com.tuling.tulingmall.dao.FlashPromotionProductDao; import com.tuling.tulingmall.dao.PortalProductDao; import com.tuling.tulingmall.domain.*; import com.tuling.tulingmall.mapper.SmsFlashPromotionMapper; import com.tuling.tulingmall.mapper.SmsFlashPromotionSessionMapper; import com.tuling.tulingmall.model.SmsFlashPromotion; import com.tuling.tulingmall.model.SmsFlashPromotionExample; import com.tuling.tulingmall.model.SmsFlashPromotionSession; import com.tuling.tulingmall.model.SmsFlashPromotionSessionExample; import com.tuling.tulingmall.service.PmsProductService; import com.tuling.tulingmall.util.DateUtil; import com.tuling.tulingmall.util.RedisOpsUtil; import lombok.extern.slf4j.Slf4j; import org.redisson.Redisson; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; /** * ,;,,; * ,;;'( 社 * __ ,;;' ' \ 会 * /' '\'~~'~' \ /'\.) 主 * ,;( ) / |. 义 * ,;' \ /-.,,( ) \ 码 * ) / ) / )| 农 * || || \) * (_\ (_\ * * @author :图灵学院 * @date :Created in 2019/12/31 17:22 * @version: V1.0 * @slogan: 天下风云出我辈,一入代码岁月催 * @description: **/ @Slf4j @Service public class PmsProductServiceImpl implements PmsProductService { @Autowired private PortalProductDao portalProductDao; @Autowired private FlashPromotionProductDao flashPromotionProductDao; @Autowired private SmsFlashPromotionMapper flashPromotionMapper; @Autowired private SmsFlashPromotionSessionMapper promotionSessionMapper; @Autowired private RedisOpsUtil redisOpsUtil; private Map<String, PmsProductParam> cacheMap = new ConcurrentHashMap<>(); @Autowired private LocalCache cache; /* * zk分布式锁 */ @Autowired private ZKLock zkLock; private String lockPath = "/load_db"; @Autowired RedissonClient redission; /** * 获取商品详情信息 * * @param id 产品ID */ public PmsProductParam getProductInfo(Long id) { PmsProductParam productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class); if (null != productInfo) { return productInfo; } RLock lock = redission.getLock(lockPath + id); try { if (lock.tryLock()) { productInfo = portalProductDao.getProductInfo(id); System.out.println("走数据库" + id); if (null == productInfo) { return null; } FlashPromotionParam promotion = flashPromotionProductDao.getFlashPromotion(id); if (!ObjectUtils.isEmpty(promotion)) { productInfo.setFlashPromotionCount(promotion.getRelation().get(0).getFlashPromotionCount()); productInfo.setFlashPromotionLimit(promotion.getRelation().get(0).getFlashPromotionLimit()); productInfo.setFlashPromotionPrice(promotion.getRelation().get(0).getFlashPromotionPrice()); productInfo.setFlashPromotionRelationId(promotion.getRelation().get(0).getId()); productInfo.setFlashPromotionEndDate(promotion.getEndDate()); productInfo.setFlashPromotionStartDate(promotion.getStartDate()); productInfo.setFlashPromotionStatus(promotion.getStatus()); } redisOpsUtil.set(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo, 360, TimeUnit.SECONDS); } else { productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class); } } finally { if (lock.isLocked()){ if (lock.isHeldByCurrentThread()){ lock.unlock(); } } } return productInfo; } /*** * 直接访问数据库 * @param id * @return */ public PmsProductParam getProductInfo1(Long id) { PmsProductParam productInfo = portalProductDao.getProductInfo(id); if (null == productInfo) { return null; } FlashPromotionParam promotion = flashPromotionProductDao.getFlashPromotion(id); if (!ObjectUtils.isEmpty(promotion)) { productInfo.setFlashPromotionCount(promotion.getRelation().get(0).getFlashPromotionCount()); productInfo.setFlashPromotionLimit(promotion.getRelation().get(0).getFlashPromotionLimit()); productInfo.setFlashPromotionPrice(promotion.getRelation().get(0).getFlashPromotionPrice()); productInfo.setFlashPromotionRelationId(promotion.getRelation().get(0).getId()); productInfo.setFlashPromotionEndDate(promotion.getEndDate()); productInfo.setFlashPromotionStartDate(promotion.getStartDate()); productInfo.setFlashPromotionStatus(promotion.getStatus()); } return productInfo; } /** * 获取商品详情信息 加入redis * * @param id 产品ID */ public PmsProductParam getProductInfo2(Long id) { PmsProductParam productInfo = null; //从缓存Redis里找 productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class); if (null != productInfo) { return productInfo; } productInfo = portalProductDao.getProductInfo(id); System.out.println("我被执行了"); if (null == productInfo) { log.warn("没有查询到商品信息,id:" + id); return null; } checkFlash(id, productInfo); redisOpsUtil.set(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo, 3600, TimeUnit.SECONDS); return productInfo; } /** * 获取商品详情信息 加入redis 加入锁 * * @param id 产品ID */ /** * 获取商品详情信息 加入redis 加入锁 * * @param id 产品ID */ public PmsProductParam getProductInfo3(Long id) { PmsProductParam productInfo = null; //从缓存Redis里找 productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class); if (null != productInfo) { return productInfo; } RLock lock = redission.getLock(lockPath + id); try { if (lock.tryLock()) { productInfo = portalProductDao.getProductInfo(id); if (null == productInfo) { log.warn("没有查询到商品信息,id:" + id); return null; } checkFlash(id, productInfo); redisOpsUtil.set(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo, 3600, TimeUnit.SECONDS); } else { productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class); } } finally { if (lock.isLocked()) { if (lock.isHeldByCurrentThread()) lock.unlock(); } } return productInfo; } /** * 获取商品详情信息 分布式锁、 本地缓存、redis缓存 * * @param id 产品ID */ public PmsProductParam getProductInfo4(Long id) { PmsProductParam productInfo = null; productInfo = cache.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id); if (null != productInfo) { return productInfo; } productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class); if (productInfo != null) { log.info("get redis productId:" + productInfo); cache.setLocalCache(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo); return productInfo; } RLock lock = redission.getLock(lockPath + id); try { if (lock.tryLock()) { productInfo = portalProductDao.getProductInfo(id); if (null == productInfo) { return null; } checkFlash(id, productInfo); log.info("set db productId:" + productInfo); redisOpsUtil.set(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo, 3600, TimeUnit.SECONDS); cache.setLocalCache(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo); } else { log.info("get redis2 productId:" + productInfo); productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class); if (productInfo != null) { cache.setLocalCache(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo); } } } finally { if (lock.isLocked()) { if (lock.isHeldByCurrentThread()) lock.unlock(); } } return productInfo; } private void checkFlash(Long id, PmsProductParam productInfo) { FlashPromotionParam promotion = flashPromotionProductDao.getFlashPromotion(id); if (!ObjectUtils.isEmpty(promotion)) { productInfo.setFlashPromotionCount(promotion.getRelation().get(0).getFlashPromotionCount()); productInfo.setFlashPromotionLimit(promotion.getRelation().get(0).getFlashPromotionLimit()); productInfo.setFlashPromotionPrice(promotion.getRelation().get(0).getFlashPromotionPrice()); productInfo.setFlashPromotionRelationId(promotion.getRelation().get(0).getId()); productInfo.setFlashPromotionEndDate(promotion.getEndDate()); productInfo.setFlashPromotionStartDate(promotion.getStartDate()); productInfo.setFlashPromotionStatus(promotion.getStatus()); } } /** * add by yangguo * 获取秒杀商品列表 * * @param flashPromotionId 秒杀活动ID,关联秒杀活动设置 * @param sessionId 场次活动ID,for example:13:00-14:00场等 */ public List<FlashPromotionProduct> getFlashProductList(Integer pageSize, Integer pageNum, Long flashPromotionId, Long sessionId) { PageHelper.startPage(pageNum, pageSize, "sort desc"); return flashPromotionProductDao.getFlashProductList(flashPromotionId, sessionId); } /** * 获取当前日期秒杀活动所有场次 * * @return */ public List<FlashPromotionSessionExt> getFlashPromotionSessionList() { Date now = new Date(); SmsFlashPromotion promotion = getFlashPromotion(now); if (promotion != null) { SmsFlashPromotionSessionExample sessionExample = new SmsFlashPromotionSessionExample(); //获取时间段内的秒杀场次 sessionExample.createCriteria().andStatusEqualTo(1);//启用状态 sessionExample.setOrderByClause("start_time asc"); List<SmsFlashPromotionSession> promotionSessionList = promotionSessionMapper.selectByExample(sessionExample); List<FlashPromotionSessionExt> extList = new ArrayList<>(); if (!CollectionUtils.isEmpty(promotionSessionList)) { promotionSessionList.stream().forEach((item) -> { FlashPromotionSessionExt ext = new FlashPromotionSessionExt(); BeanUtils.copyProperties(item, ext); ext.setFlashPromotionId(promotion.getId()); if (DateUtil.getTime(now).after(DateUtil.getTime(ext.getStartTime())) && DateUtil.getTime(now).before(DateUtil.getTime(ext.getEndTime()))) { //活动进行中 ext.setSessionStatus(0); } else if (DateUtil.getTime(now).after(DateUtil.getTime(ext.getEndTime()))) { //活动即将开始 ext.setSessionStatus(1); } else if (DateUtil.getTime(now).before(DateUtil.getTime(ext.getStartTime()))) { //活动已结束 ext.setSessionStatus(2); } extList.add(ext); }); return extList; } } return null; } //根据时间获取秒杀活动 public SmsFlashPromotion getFlashPromotion(Date date) { Date currDate = DateUtil.getDate(date); SmsFlashPromotionExample example = new SmsFlashPromotionExample(); example.createCriteria() .andStatusEqualTo(1) .andStartDateLessThanOrEqualTo(currDate) .andEndDateGreaterThanOrEqualTo(currDate); List<SmsFlashPromotion> flashPromotionList = flashPromotionMapper.selectByExample(example); if (!CollectionUtils.isEmpty(flashPromotionList)) { return flashPromotionList.get(0); } return null; } /** * 获取首页的秒杀商品列表 * * @return */ public List<FlashPromotionProduct> getHomeSecKillProductList() { PageHelper.startPage(1, 8, "sort desc"); FlashPromotionParam flashPromotionParam = flashPromotionProductDao.getFlashPromotion(null); if (flashPromotionParam == null || CollectionUtils.isEmpty(flashPromotionParam.getRelation())) { return null; } List<Long> promotionIds = new ArrayList<>(); flashPromotionParam.getRelation().stream().forEach(item -> { promotionIds.add(item.getId()); }); PageHelper.clearPage(); return flashPromotionProductDao.getHomePromotionProductList(promotionIds); } @Override public CartProduct getCartProduct(Long productId) { return portalProductDao.getCartProduct(productId); } @Override public List<PromotionProduct> getPromotionProductList(List<Long> ids) { return portalProductDao.getPromotionProductList(ids); } /** * 查找出所有的产品ID * * @return */ public List<Long> getAllProductId() { return portalProductDao.getAllProductId(); } }
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/12 4:15:32

G-Helper深度指南:华硕笔记本的轻量级性能控制神器

G-Helper深度指南&#xff1a;华硕笔记本的轻量级性能控制神器 【免费下载链接】g-helper Lightweight Armoury Crate alternative for Asus laptops with nearly the same functionality. Works with ROG Zephyrus, Flow, TUF, Strix, Scar, ProArt, Vivobook, Zenbook, Exper…

作者头像 李华
网站建设 2026/5/12 4:14:08

Cursor历史版本归档仓库:软件分发与版本管理的开源实践

1. 项目概述&#xff1a;一个被忽视的宝藏仓库如果你和我一样&#xff0c;是个重度依赖代码编辑器的开发者&#xff0c;那么“Cursor”这个名字对你来说一定不陌生。它以其强大的AI辅助编程能力&#xff0c;迅速在开发者社区中积累了极高的人气。但今天要聊的&#xff0c;不是C…

作者头像 李华
网站建设 2026/5/12 4:14:07

网站性能监控与优化实战指南

1. 网站性能监控的核心指标解析作为运维工程师&#xff0c;我们每天都要面对各种性能数据&#xff0c;但真正能反映网站健康状况的核心指标其实就那几个。先来看这份监控报告中的关键数据&#xff1a;平均响应时间&#xff1a;845ms最大响应时间&#xff1a;1.04s最小响应时间&…

作者头像 李华
网站建设 2026/5/12 4:14:06

如何快速解锁网易云音乐:3步完成NCM格式转换的完整指南

如何快速解锁网易云音乐&#xff1a;3步完成NCM格式转换的完整指南 【免费下载链接】ncmdump 项目地址: https://gitcode.com/gh_mirrors/ncmd/ncmdump 还在为网易云音乐下载的NCM加密文件无法在其他设备播放而烦恼吗&#xff1f;你是否曾遇到过车载音响无法识别NCM文件…

作者头像 李华
网站建设 2026/5/12 4:13:45

为Jekyll Hyde主题打造现代化交互增强:hydeclaw扩展实战

1. 项目概述&#xff1a;一个为Hyde主题打造的“猫爪”扩展如果你和我一样&#xff0c;是个喜欢折腾静态博客的开发者&#xff0c;那你对Jekyll和它的主题Hyde一定不陌生。Hyde以其简洁、优雅的设计和极佳的响应式布局&#xff0c;成为了许多技术博客的首选。但用久了&#xff…

作者头像 李华
网站建设 2026/5/12 4:10:32

【C语言】生成随机数(rand\srand\time)

一、随机数&#xff08;1&#xff09;真随机数&#xff1a;没有规律的随机生成&#xff0c;完全不可预测&#xff08;2&#xff09;伪随机数&#xff1a;通过一个确定的数学公式计算出来&#xff0c;看起来毫无规律&#xff0c;但本质上是可预测的、可重复的&#xff08;3&…

作者头像 李华