简介:面向SAP S/4HANA环境的ABAP开发者,这份文档系统梳理ABAP新语法,帮助读者理解现代ABAP在关键字、数据类型与Open SQL写法上的变化,并明确不适用于ECC等老系统。压缩包仅含1个docx文件,约1.8MB,内容以英文技术目录分专题编排,围绕新关键字、数据类型、Open SQL三大板块展开,涵盖NEW、CORRESPONDING、REDUCE等语法点,并对结构体深拷贝、聚合、分组与过滤等迁移难点给出对应说明。文档还延伸至常量定义、宿主变量与表达式、聚合表达式,以及数值、字符串、日期时间、时区、时间戳等内置函数和CASE分支处理。目录层级清晰,便于按语法点检索,对日常开发中的新旧语法替换、内表处理、数据库读取与条件分支写法具有一定参考价值,适合从传统ABAP迁移到S/4HANA的开发者系统学习与查漏补缺。目前已有1168人学习下载。
1. 分水岭:为什么同一段代码换套系统就活不过来
把一段写着DATA(lv) = ...、VALUE #( )的代码原样搬到一套老系统的编辑器里,激活那一刻语法检查直接飘红,抛出来的还是最朴素的一句话:语句无法识别。不少人第一反应是编辑器太旧、插件没装,折腾半天才发现方向从一开始就歪了——ABAP 新语法的分水岭在内核版本,不在工具链。ECC 那一代普遍跑在 7.31/7.40 之前的内核上,根本不认识这套关键字;而 S/4HANA 落地时内核早就到 7.5x 往上,构造表达式、内联声明、带@转义的 Open SQL 都成了默认能力。搞懂这条线划在哪,才能明白为什么同一段代码换个环境就活不过来,也才看得懂那份 2020 年版语法清单为什么要按 DATA、NEW、VALUE、Open SQL、数据处理分块来组织。
2. 内联声明:DATA(...)到底是编译期糖还是运行期机制
2.1 声明点、可见范围与跨迭代生命周期
写惯了老语法的人,看到DATA(lv_x) = 1.的第一反应是「这不就是把 DATA 和赋值合成一行」。语义上确实等价,但作用域规则完全不是一回事,而这条规则决定了大量新代码到底是省事还是挖坑。
老写法里,DATA: lv_x TYPE i.一写在程序顶部,整个处理块都能看见。内联声明不一样:变量的可见范围是当前处理块(方法、FORM、函数模块、报表主体),起点是声明语句本身,终点是处理块结束。也就是说,声明之前不能引用,声明之后到块结束都能用,跨越 IF 分支、循环体、CASE 分支都有效。
METHOD demo_scope. " 循环体里声明的内联变量,作用域并不是循环体,而是整个方法 LOOP AT gt_data INTO DATA(ls_row). DATA(lv_count) = 0. " 第二圈迭代时这里会直接语法报错:重复声明 lv_count = lv_count + 1. ENDLOOP. " 循环结束后 ls_row 依然可见,保留的是最后一次迭代的值 WRITE: / ls_row-matnr. ENDMETHOD.这段代码有两个反直觉点。第一,内联变量不是每次循环迭代重新创建的,它的生命周期从声明点一直延伸到方法结束,所以第二圈迭代再次执行到DATA(lv_count)时,编译器认定这是重复声明,直接拒绝。这跟很多人的直觉相反——大家习惯把循环体内的局部变量当成「每圈一份」。第二,ls_row在循环结束后仍然活着,值停在最后一行,这在调试时经常让人以为程序「读到了不存在的行」。
那循环里的累加器怎么写?只能提前声明:
METHOD demo_counter. DATA lv_count TYPE i. " 累加器必须在循环外显式声明 LOOP AT gt_data INTO DATA(ls_row). lv_count = lv_count + 1. " 每圈复用的是同一个变量 ENDLOOP. ENDMETHOD.理解这一点之后,内联声明的定位就清楚了:它是为「用一次就没了的临时变量」设计的,比如接收一行数据、接一个方法的返回参数、接一次 CONCATENATE 的结果。它的价值不是少打字,而是把变量的可见范围压到最小,读代码的人不用翻到方法开头去找这个变量在哪定义、类型是什么。
还有一个细节:内联声明的类型是编译期确定的,不是运行期猜的。DATA(lv) = 15.里的15是整数字面量,编译器就把lv定成i,之后无论你往里塞什么,类型都不会变。这也解释了为什么它没有运行期开销——它在语法阶段就被展开成一条普通的声明加一条赋值,生成的字节码和老写法一模一样。
2.2FIELD-SYMBOL(...)与ASSIGN的绑定时机
字段符号的内联声明比变量更容易写错,因为它多了一个隐含约束:声明必须和绑定同时发生。
老的FIELD-SYMBOLS <fs> TYPE ty.是一句独立的声明,字段符号可以先悬空存在,等后面某处再ASSIGN。内联版本没有这个自由度,它只能出现在ASSIGN、LOOP AT ... ASSIGNING、READ TABLE ... ASSIGNING这类语句的接收端:
" 正确写法:声明即绑定 LOOP AT gt_data ASSIGNING FIELD-SYMBOL(<ls_row>). <ls_row>-flag = 'X'. " 直接改原表,不需要 MODIFY ENDLOOP. " 动态字段名场景:把变量名当字段名去绑 DATA(lv_fieldname) = 'MATNR'. ASSIGN (lv_fieldname) TO FIELD-SYMBOL(<lv_value>). IF <lv_value> IS ASSIGNED. WRITE: / <lv_value>. ENDIF.注意ASSIGNING FIELD-SYMBOL(<fs>)和ASSIGNING (<fs>)是两种不同写法:后者绑定的是已经用 FIELD-SYMBOLS 声明过的字段符号,前者才是内联声明。这两个混用是新手最容易踩的第一颗雷,报错信息还偏偏很含糊。
为什么值得用ASSIGNING而不是INTO?内存语义不同。INTO DATA(ls_row)是把表行拷贝到工作区,改工作区不影响原表,必须补一句MODIFY gt_data FROM ls_row,而MODIFY又要重新定位行号,性能上是双倍的开销。ASSIGNING拿的是引用,直接读写内表内存。对于几万行的内表做批量打标这类操作,两种写法的耗时差距能到数量级。
2.3 类型推断的边界:为什么DATA(x) = '1.5'会翻车
内联声明的类型来自等号右边的表达式,右边长什么样,左边就是什么类型。这套规则在字符和整数上看着很顺,一旦碰到小数就露出边界。
DATA(lv_char) = 'This is a sentence'. " 单引号:类型 c,长度 18,定长 DATA(lv_string) = `This is a sentence`. " 反引号:类型 string,变长 DATA(lv_int) = 15. " 整数字面量:类型 i " 下面这条根本通不过语法检查:ABAP 没有小数字面量这个概念 " DATA(lv_float) = 1.5. " 想要小数,必须显式转换 DATA(lv_dec) = CONV decfloat34( '1.5' ).单引号和反引号的区别是这里最实用的一条:'...'推断出定长c,长度就是字面量的字符数;`...`推断出string。如果你的变量后面要拼接变长内容,用单引号推断出来的定长类型会在拼接时被截断——这种 bug 不会报错,只会默默丢数据。
从 SELECT 推断是另一条常用路径,也是原文档里标注「常用」的那条:
" 字段类型与 SELECT 列表严格对应,不需要提前建结构 SELECT matnr, maktx FROM makt INTO TABLE @DATA(lt_data). " 列表里带常量时必须给别名,否则内表没有对应字段名 SELECT SINGLE matnr, 'FIXED' AS field_name FROM makt INTO @DATA(lwa_data).带常量的那条要特别注意:SQL 里的常量列没有列名,不给AS别名,内表就不知道该生成什么字段。这属于推断规则本身的限制,不是 bug。
原文档里那句提醒很实在——「虽然可以用,但是不建议使用,因为到后面你自己都会忘记是什么类型」。这话听着像老派程序员的唠叨,但它指向一个真实的工程问题:内联声明的类型是不可见的,代码审查时没人能从DATA(lv_x) = ...一眼看出这是string还是c LENGTH 10,等到半年后有人往里塞中文再取出来发现乱码,就得回头做考古。稳妥的做法是:短生命周期的临时变量用内联,跨方法传递、参与字符串拼接、或者结果要落库的变量,老老实实显式声明类型。
3. 构造表达式:VALUE、BASE、REDUCE的求值模型
3.1VALUE从不「部分更新」:构造语义与BASE的关系
关于VALUE,流传最广的一句描述是「它会覆盖原有数据」。这句话只说对了一半,而且错的那一半正是最容易出事故的地方。
准确的说法是:VALUE是一个构造表达式,它的语义是「从初始值开始,构造出一个新值,然后赋给目标」。这意味着没有在括号里列出来的字段,不是「保持原样」,而是被重置成初始值。
TYPES: BEGIN OF ty_mat, matnr TYPE matnr, mtart TYPE mtart, text1 TYPE char50, END OF ty_mat. DATA(ls_data) = VALUE ty_mat( matnr = 'MAT-001' mtart = 'FOOD' text1 = 'FIRST' ). " 只想改 mtart,结果 text1 被悄悄清空了 ls_data = VALUE #( matnr = 'MAT-001' mtart = 'WATR' ).跑完第二段,ls_data-text1是空的。这不是「覆盖」,这是重建。第一次赋值时text1有值,第二次没写它,构造表达式就按初始值填了。
真要保留原有字段,得显式说「以这个值为起点开始构造」,这就是BASE的作用:
" BASE 指定构造起点,未列出的字段沿用起点里的值 ls_data = VALUE #( BASE ls_data mtart = 'WATR' ).BASE在追加上内表时更常见,而且语义正好相反——不是覆盖,是追加:
DATA(lt_data) = VALUE ty_tab( ( matnr = 'MAT-001' ) ( matnr = 'MAT-002' ) ). " 覆盖式:只剩一行 lt_data = VALUE #( ( matnr = 'MAT-003' ) ). " 追加式:变成三行 lt_data = VALUE #( BASE lt_data ( matnr = 'MAT-003' ) ).把这套语义记牢,很多「结构体字段莫名变空」的现场就能解释通了。判断标准很简单:VALUE后面跟不跟BASE,决定了这次赋值是重建还是续写。
VALUE还有一个高频用法是直接塞进语句里,省掉中间变量:
APPEND VALUE #( matnr = 'MAT-009' ) TO lt_data. MODIFY lt_data FROM VALUE #( flag = 'X' ) TRANSPORTING flag WHERE flag IS INITIAL.第二条尤其好用:以前要改一批行的某个字段,得先建个只带该字段的结构,现在一行就够。
3.2NEW与REF:引用语义、#推断与->*
NEW用来替代CREATE OBJECT,REF用来替代CREATE DATA。两者的共同点是都返回引用,区别是一个指向对象实例,一个指向数据对象。
NEW的类型推断规则很短,但边界很硬:#的形状来自等号左边。如果左边已经有确定的静态类型,可以省掉类名;如果左边正在被内联声明定义,那就形成了循环依赖,编译器没法推断。
DATA lo_grid TYPE REF TO cl_gui_alv_grid. " 左边类型已知:# 可以直接用 lo_grid = NEW #( i_parent = lo_container ). " 直接内联声明:左边类型要靠右边推断,右边类型又要靠左边推断 " 编译器无法解析,必须显式写出类名 DATA(lo_grid2) = NEW cl_gui_alv_grid( i_parent = lo_container ).REF的规则类似,但它推断出来的引用类型自动与被指向对象保持一致,不需要提前 TYPES:
DATA(ls_work) = VALUE ty_mat( matnr = 'MAT-001' mtart = 'FOOD' ). " 引用指向 ls_work,类型自动对齐 ty_mat DATA(lr_work) = REF #( ls_work ). " 通过引用整行赋值 lr_work->* = VALUE #( matnr = 'MAT-002' mtart = 'WATR' ). " 通过引用改单个字段 lr_work->text1 = 'SECOND'.这里有个非常典型的翻车点:REF产生的引用变量,不能用工作区的-语法访问字段,只能用->*或->字段名。写成lv_ref-matnr会报「字段 LV_REF 未知」之类的错,初学者对着这种报错经常懵。
还有人会问,REF指向的是原对象还是副本?是原对象。lr_work->* = ...改完之后,ls_work的值同步变了。引用语义在需要「把结构传进方法并在方法内修改」的场景下非常好用,但也意味着生命周期风险——如果被引用的局部变量先出了作用域,引用就悬空了。ABAP 的运行时对这种悬空引用不一定当场报错,而是在你下次解引用的时候崩,排查起来非常难受。
3.3FOR/REDUCE/LET:把循环折叠成表达式
这三个关键字是函数式写法的主力,也是新语法里学习曲线最陡的一段。它们的共同目标是把「循环 + 累加」这种本来要写五六行的逻辑,压成一个表达式。
REDUCE负责聚合,结构是INIT定初值、FOR遍历、NEXT更新:
" 求所有行某个数值字段的合计 DATA(lv_total) = REDUCE i( INIT sum = 0 FOR wa IN gt_data NEXT sum = sum + wa-amount ).INIT后面定义的sum是累加器,作用域只在这个表达式内部,外面看不到。NEXT每处理一行执行一次。整个过程没有副作用,不会改gt_data,也不会在外面留下临时变量。
FOR单独用的时候,主要是构造内表:
" 从原表提取部分字段,生成新表 DATA(lt_light) = VALUE ty_light_tab( FOR wa IN gt_data ( matnr = wa-matnr maktx = wa-maktx ) ).REDUCE前面经常配LET,用来定义表达式内部的辅助变量。LET的意义是避免重复计算:
" LET 里算一次,后面两处直接复用 DATA(lv_score) = REDUCE i( LET base = lv_weight * 2 IN INIT s = 0 FOR wa IN gt_data NEXT s = s + wa-qty * base ).这里要说清楚代价。表达式写法看着紧凑,但它把求值过程藏进了内核,调试器无法在REDUCE内部逐行断点,只能看最终结果。遇到结果不对的时候,你唯一的手段是把表达式拆回普通 LOOP 来定位。所以血泪经验是:逻辑超过两三行的聚合,别硬写成表达式。表达式适合「一眼能看懂在干什么」的场景,比如求和、取最大值、挑字段;一旦开始嵌套FOR里套FOR,可读性就崩了,三个月后的自己会骂人。
还有一个性能细节值得提。FOR wa IN itab遍历的是内表,如果内表没有排序键,遍历顺序就是插入顺序,这跟普通LOOP AT一致。但如果表达式里嵌套了itab[ ... ]这种线性查找,复杂度会从 O(n) 变成 O(n²),在数据量大的时候性能直接崩掉。表达式本身不带来性能优化,它只是换了个写法。
3.4 字符串模板与格式化选项:ALPHA、CURRENCY走的是哪条转换链
字符串模板|...|用起来像模板语言,但它的格式化选项不是「内核自己实现的一套格式化逻辑」,而是直接调用了字典里已有的转换例程。理解这一点,很多行为就顺了。
" 物料号去掉前导零、金额带千分位、日期按用户设置显示 DATA(lv_msg) = |Material: { ls_matnr ALPHA = OUT } Amount: { ls_amount CURRENCY = ls_waers } Date: { ls_date DATE = USER }|.ALPHA = OUT干的事,跟老代码里手动CALL FUNCTION 'CONVERSION_EXIT_ALPHA_OUTPUT'完全一样。CURRENCY =后面必须跟一个货币码变量,内核据此决定小数位数——JPY 没有小数,CNY 两位,这是字典里的定义,不是硬编码。DATE = USER则取决于用户主数据里的日期格式设置,同一个程序在不同用户下输出不一样。
这条机制解释了格式化选项的几个硬约束:
ALPHA只能作用于字符型或数字字符串型的字段,对纯数值类型没意义;CURRENCY必须同时提供货币码,只写CURRENCY不给值是语法错误;WIDTH、ALIGN、PAD是纯排版选项,它们决定不了内容,只决定填不满时补什么字符、靠哪边;NUMBER、DATE、TIME这些选项后面跟的是格式标识,不是具体格式串。
常用的还有几个:CASE = UPPER/LOWER做大小写转换,SIGN控制正负号显示位置,DECIMALS = n强制小数位,ZERO = NO在数值为零时输出空,XSD = YES转成符合 XML Schema 的格式(对接外部接口时常用),STYLE则复用 ALV 或表格的样式定义。
" 常见组合:定宽 + 右对齐 + 补零,适合拼固定格式的接口报文 DATA(lv_fixed) = |{ lv_matnr WIDTH = 18 ALIGN = RIGHT PAD = '0' }|.要注意PAD的填充只在WIDTH指定的宽度大于内容长度时生效,长度超出时会原样输出、不做截断。这一点跟很多语言的printf不一样,拼接口报文时如果指望它帮你截断超长字段,是会翻车的。
字符串函数也是表达式形式,可以直接嵌在模板里或者单独用:
DATA(lv_len) = strlen( lv_text ). " 字符数 DATA(lv_upper) = to_upper( lv_text ). " 转大写 DATA(lv_pos) = find( val = lv_text sub = `X` ). " 找不到返回 -1 DATA(lv_cnt) = count( val = lv_text sub = `X` ). " 出现次数 DATA(lv_sub) = substring( val = lv_text off = 2 len = 5 ). DATA(lv_new) = replace( val = lv_text sub = `A` with = `B` ).这套函数的好处是能嵌套、能直接当参数传,省掉一堆中间变量。需要注意的是find找不到时返回-1而不是0,判断存在性建议直接用contains( ),语义更清楚。
内表表达式是同一套思路的延伸,itab[ ... ]可以直接取值:
DATA(ls_first) = gt_data[ 1 ]. " 第一行 DATA(ls_hit) = gt_data[ matnr = 'MAT-001' ]. " 按条件找 DATA(lv_idx) = line_index( gt_data[ matnr = 'MAT-001' ] ). IF line_exists( gt_data[ matnr = 'MAT-001' ] ). " 存在性判断 ENDIF. " 找不到时不抛异常,返回默认值 DATA(ls_safe) = VALUE ty_mat( gt_data[ matnr = 'MAT-999' OPTIONAL ] DEFAULT VALUE #( ) ).OPTIONAL和DEFAULT的组合是这里的关键:不带OPTIONAL的话,itab[ ... ]找不到行会抛CX_SY_ITAB_LINE_NOT_FOUND,程序当场终止。这个异常在循环里出现时特别隐蔽,因为外层可能有TRY...CATCH把它吞掉了。
4. Open SQL 重写:@转义、Strict Mode 与表达式下推
4.1 为什么加了@就进入严格模式
新 Open SQL 最表面的变化是主机变量要加@,但这种「多加一个符号」的背后是整套语法检查策略的切换。
" 老写法:变量名裸奔,编译器不做类型检查 SELECT matnr maktx FROM makt INTO TABLE lt_data WHERE spras = sy-langu. " 新写法:@ 明确标记这是应用层变量,不是数据库字段 SELECT matnr, maktx FROM makt WHERE spras = @sy-langu INTO TABLE @DATA(lt_data).关键在于:一旦用了@转义,语法检查器就切到严格模式,把过去那些「能跑但语义模糊」的写法全部禁掉。禁掉的有哪几类?
第一类是目标结构不匹配。老写法允许SELECT *塞进一个字段数对不上的结构,对不上就留空,运行时也不报错,等到下游用的时候才发现少了个字段。严格模式直接要求:查询列表和目标结构必须严格对应。
第二类是客户端字段处理。过去有人手写WHERE mandt = '001'绕过客户端机制,严格模式下这种隐式处理不允许,必须显式声明客户端处理方式(不同版本的关键字略有差别,以系统语法检查提示为准)。
第三类是隐式类型转换。老写法里字符型和数值型在 WHERE 条件里混用,数据库层会做隐式转换,有时走不了索引,性能莫名其妙崩掉。严格模式把这类写法逼出来。
理解这套设计的动机,其实是在回答一个问题:老 SQL 把太多判断留给了运行期,而运行期的错误代价高——一次类型不匹配可能就是生产环境的一次 dump。严格模式把这部分检查前移到语法阶段,代价是迁移时要改一批老代码,收益是「能通过语法检查的,基本不会在运行时因为 SQL 结构问题炸掉」。
4.2 聚合与内建函数:哪些算在数据库里,哪些算在应用层
这是新 Open SQL 最值钱的部分,也是最容易被误用的部分。核心事实是:写在 SELECT 列表里的聚合函数和内建函数,是在数据库层执行的,不是内核把数据捞回来再算。
" 求和:数据库算完只返回一个数 SELECT SUM( netwr ) AS total FROM vbrk WHERE bukrs = @lv_bukrs INTO @DATA(lv_total). " 分组统计:数据库分组,返回多行 SELECT matnr, SUM( menge ) AS qty, COUNT(*) AS cnt FROM mseg WHERE budat >= @lv_from GROUP BY matnr INTO TABLE @DATA(lt_stat).这个差别在大表上非常明显。老写法是SELECT * INTO TABLE lt_all,把几十万行捞进应用服务器内存,再LOOP AT累加——网络传输和内存占用都是灾难。新写法让数据库只返回一行结果,网络流量从几十兆降到几十字节。
常用的数值函数包括ABS、CEIL、FLOOR、ROUND、DIV、MOD这几个,字符串函数有CONCAT、SUBSTRING、LENGTH、UPPER、LOWER、TRIM、REPLACE,日期时间也有一整套。
" 字符串拼接和截取都在数据库端完成 SELECT CONCAT( carrid, connid ) AS key, SUBSTRING( carrname, 1, 10 ) AS short_name FROM scarr INTO TABLE @DATA(lt_scarr).但这里必须泼一盆冷水:函数下推不等于性能一定好。如果函数包在 WHERE 的字段外面,索引就废了。比如WHERE SUBSTRING( matnr, 1, 5 ) = 'MAT-0',数据库没法用matnr上的索引,只能全表扫。函数该用在 SELECT 列表里做输出转换,不要轻易用在 WHERE 的左侧。
判断一条语句能不能下推,有个朴素的方法:看它是不是「只依赖于当前行的数据」。SUM(...)依赖多行,所以要配GROUP BY;CONCAT(a, b)只依赖当前行,可以随便用;而任何跨行、跨表的计算,都得靠 JOIN 或者干脆拉回应用层做。
4.3NULL、CAST、CASE的三值逻辑
数据库的NULL在 ABAP 里没有对应概念,内核把NULL映射成目标字段的初始值。这个映射在读取时无害,在计算时会导致结果偏离预期。
" NULL 参与算术运算,结果仍然是 NULL " 而不是很多人以为的「当 0 处理」 SELECT SUM( amount ) AS total FROM ztable WHERE id = @lv_id INTO @DATA(lv_total). " 如果所有 amount 都是 NULL,lv_total 拿到的是 0(初始值), " 但你无法区分「真的是 0」和「全是 NULL」要区分这两者,得用COALESCE显式给NULL兜底:
SELECT COALESCE( text, 'N/A' ) AS text_display, COALESCE( amount, 0 ) AS safe_amount FROM ztable WHERE id = @lv_id INTO TABLE @DATA(lt_result).IS NULL/IS NOT NULL可以作为条件使用,但要记住:WHERE field <> 'X'不会把NULL行选出来,因为NULL <> 'X'的结果是UNKNOWN而不是TRUE。这是三值逻辑的经典陷阱,很多「为什么少了几条数据」的排查最后都落到这里。
CAST用来在 SQL 层做类型转换,类型写的是字典类型:
SELECT CAST( matnr AS CHAR( 18 ) ) AS matnr_char, CAST( netwr AS DEC( 15, 2 ) ) AS amount_dec FROM vbrp INTO TABLE @DATA(lt_cast).CASE则是条件表达式,可以在 SELECT 列表里做分类:
SELECT matnr, CASE WHEN menge > 100 THEN 'HIGH' WHEN menge > 10 THEN 'MID' ELSE 'LOW' END AS level FROM ztable INTO TABLE @DATA(lt_level).这套东西写起来很顺,但要清楚它们的边界:CASE里的条件只能引用当前行的字段,不能做子查询(在这个语法层级上)。想做跨行比较,得回到 JOIN 或应用层。
4.4JOIN、OFFSET与 CDS 带参查询的语法边界
JOIN在新语法里的写法更接近标准 SQL,INNER JOIN、LEFT OUTER JOIN都能直接写在FROM后面:
SELECT a~matnr, a~maktx, b~mtart FROM makt AS a INNER JOIN mara AS b ON a~matnr = b~matnr WHERE a~spras = @sy-langu INTO TABLE @DATA(lt_join).老写法里的INNER JOIN需要在FROM后面接JOIN加ON,字段用~限定表别名。这套规则新老一致,但新语法要求所有主机变量必须@转义,包括 JOIN 条件里的。
OFFSET用来做分页,语法上挂在UP TO n ROWS后面:
SELECT matnr, maktx FROM makt ORDER BY matnr INTO TABLE @DATA(lt_page) UP TO 20 ROWS OFFSET 40.这里有个必须知道的坑:没有ORDER BY的OFFSET结果是不稳定的。
本文还有配套的精品资源,点击获取